REST API Engineering · Lesson 2 of 19
Minimal API Deep Dive — Endpoint Groups, DTOs, Full CRUD
Minimal API vs Controllers — Which to Use
Both produce identical HTTP behavior. The difference is how you write them:
| | Minimal API | Controllers |
|--|--|--|
| Syntax | app.MapGet(...) lambdas | Classes inheriting ControllerBase |
| Boilerplate | Less | More |
| File organisation | Endpoint groups | One class per resource |
| Best for | New .NET 9+ projects | Teams migrating from .NET Framework |
| Filters | Endpoint filters | Action filters |
OrderFlow uses Minimal API. It produces cleaner code and better aligns with how modern .NET is being built.
Endpoint Groups — Keep It Organised
Grouping endpoints keeps Program.cs clean:
// OrderFlow.Api/Endpoints/ProductsEndpoints.cs
public static class ProductsEndpoints
{
public static IEndpointRouteBuilder MapProductsEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/products")
.WithTags("Products")
.RequireAuthorization(); // all routes need JWT
group.MapGet("/", GetAllProducts);
group.MapGet("/{id:guid}", GetProductById);
group.MapPost("/", CreateProduct)
.RequireAuthorization("Admin"); // only admins can create
group.MapPut("/{id:guid}", UpdateProduct)
.RequireAuthorization("Admin");
group.MapDelete("/{id:guid}", DeleteProduct)
.RequireAuthorization("Admin");
return app;
}Request & Response DTOs
Never expose your domain entities directly over HTTP. Use DTOs:
// OrderFlow.Application/DTOs/ProductDtos.cs
// Response — what we send to the client
public record ProductDto(
Guid Id,
string Name,
string Sku,
decimal Price,
int StockLevel,
bool IsActive);
// Request — what the client sends to create a product
public record CreateProductRequest(
[Required][MaxLength(200)] string Name,
[Required][MaxLength(50)] string Sku,
[Range(0.01, 999_999)] decimal Price,
[Range(0, int.MaxValue)] int InitialStock = 0);
// Request — partial update (only fields provided are changed)
public record UpdateProductRequest(
[MaxLength(200)] string? Name = null,
[Range(0.01, 999_999)] decimal? Price = null,
bool? IsActive = null);GET — Fetch Products
// GET /api/v1/products?page=1&pageSize=20&search=widget
private static async Task<IResult> GetAllProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
IProductRepository repo = default!,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100); // never let client request 10,000 rows
var products = await repo.GetPagedAsync(page, pageSize, search, ct);
var total = await repo.CountAsync(search, ct);
return Results.Ok(new PagedResponse<ProductDto>(
Items: products.Select(p => p.ToDto()),
Page: page,
PageSize: pageSize,
TotalCount: total,
TotalPages: (int)Math.Ceiling(total / (double)pageSize)));
}
// GET /api/v1/products/{id}
private static async Task<IResult> GetProductById(
Guid id,
IProductRepository repo,
CancellationToken ct)
{
var product = await repo.GetByIdAsync(id, ct);
return product is null
? Results.NotFound(new ProblemDetails
{
Title = "Product not found",
Detail = $"No product with ID {id} exists.",
Status = 404,
})
: Results.Ok(product.ToDto());
}POST — Create a Product
private static async Task<IResult> CreateProduct(
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductRepository repo,
IUnitOfWork uow,
CancellationToken ct)
{
// Validate
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
// Check SKU uniqueness
var existing = await repo.GetBySkuAsync(request.Sku, ct);
if (existing is not null)
return Results.Conflict(new ProblemDetails
{
Title = "SKU already exists",
Detail = $"A product with SKU '{request.Sku}' already exists.",
Status = 409,
});
// Create via domain factory (business rules enforced)
var product = Product.Create(
request.Name,
request.Sku,
request.Price,
request.InitialStock);
repo.Add(product);
await uow.SaveChangesAsync(ct);
// 201 Created with Location header pointing to the new resource
return Results.Created($"/api/v1/products/{product.Id}", product.ToDto());
}PUT — Update a Product
private static async Task<IResult> UpdateProduct(
Guid id,
UpdateProductRequest request,
IProductRepository repo,
IUnitOfWork uow,
CancellationToken ct)
{
var product = await repo.GetByIdAsync(id, ct);
if (product is null) return Results.NotFound();
if (request.Name is not null) product.UpdateName(request.Name);
if (request.Price is not null) product.UpdatePrice(request.Price.Value);
if (request.IsActive is not null) product.SetActive(request.IsActive.Value);
repo.Update(product);
await uow.SaveChangesAsync(ct);
return Results.Ok(product.ToDto()); // return updated state
}Orders Endpoints — Full CRUD with State Machine
// OrderFlow.Api/Endpoints/OrdersEndpoints.cs
public static IEndpointRouteBuilder MapOrdersEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapGet("/", GetOrders);
group.MapGet("/{id:guid}", GetOrderById);
group.MapPost("/", CreateOrder);
group.MapPost("/{id:guid}/lines", AddOrderLine);
group.MapDelete("/{id:guid}/lines/{lineId:guid}", RemoveOrderLine);
group.MapPost("/{id:guid}/confirm", ConfirmOrder);
group.MapPost("/{id:guid}/cancel", CancelOrder);
return app;
}
// POST /api/v1/orders
private static async Task<IResult> CreateOrder(
CreateOrderRequest request,
ICustomerRepository customerRepo,
IOrderRepository orderRepo,
IOrderNumberService numberSvc,
IUnitOfWork uow,
CancellationToken ct)
{
var customer = await customerRepo.GetByIdAsync(request.CustomerId, ct);
if (customer is null)
return Results.NotFound(ProblemDetailsFor("Customer not found", request.CustomerId));
var orderNumber = await numberSvc.GenerateAsync(ct); // e.g. "ORD-2026-04-001234"
var order = Order.Create(customer, orderNumber);
orderRepo.Add(order);
await uow.SaveChangesAsync(ct);
return Results.Created($"/api/v1/orders/{order.Id}", order.ToDto());
}
// POST /api/v1/orders/{id}/confirm
private static async Task<IResult> ConfirmOrder(
Guid id,
IOrderRepository repo,
IUnitOfWork uow,
CancellationToken ct)
{
var order = await repo.GetByIdAsync(id, ct);
if (order is null) return Results.NotFound();
try
{
order.Confirm(); // domain enforces state machine
}
catch (InvalidOperationException ex)
{
return Results.UnprocessableEntity(new ProblemDetails
{
Title = "Cannot confirm order",
Detail = ex.Message,
Status = 422,
});
}
repo.Update(order);
await uow.SaveChangesAsync(ct);
return Results.Ok(order.ToDto());
}Pagination DTO
// OrderFlow.Application/DTOs/PagedResponse.cs
public record PagedResponse<T>(
IEnumerable<T> Items,
int Page,
int PageSize,
int TotalCount,
int TotalPages)
{
public bool HasPreviousPage => Page > 1;
public bool HasNextPage => Page < TotalPages;
}Client receives:
{
"items": [...],
"page": 1,
"pageSize": 20,
"totalCount": 143,
"totalPages": 8,
"hasPreviousPage": false,
"hasNextPage": true
}Global Error Handling
One piece of middleware handles all unhandled exceptions:
// OrderFlow.Api/Middleware/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext ctx)
{
try
{
await next(ctx);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception for {Method} {Path}",
ctx.Request.Method, ctx.Request.Path);
var (status, title) = ex switch
{
ArgumentException => (400, "Bad request"),
UnauthorizedAccessException => (401, "Unauthorized"),
KeyNotFoundException => (404, "Not found"),
InvalidOperationException => (422, "Business rule violation"),
_ => (500, "An unexpected error occurred"),
};
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/problem+json";
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = title,
Detail = ex.Message,
Status = status,
});
}
}
}
// Register in Program.cs
app.UseMiddleware<ExceptionHandlingMiddleware>();API Versioning
dotnet add OrderFlow.Api package Asp.Versioning.Http// Program.cs
builder.Services.AddApiVersioning(opt =>
{
opt.DefaultApiVersion = new ApiVersion(1, 0);
opt.AssumeDefaultVersionWhenUnspecified = true;
opt.ReportApiVersions = true;
opt.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/products
new HeaderApiVersionReader("api-version"), // header: api-version: 1.0
new QueryStringApiVersionReader("api-version")); // ?api-version=1.0
});// Versioned endpoint group
var v1 = app.NewVersionedApi("Products");
var groupV1 = v1.MapGroup("/api/v{version:apiVersion}/products")
.HasApiVersion(1, 0);
var groupV2 = v1.MapGroup("/api/v{version:apiVersion}/products")
.HasApiVersion(2, 0);
// V2 adds a new field to the response
groupV2.MapGet("/{id:guid}", GetProductByIdV2);HTTP Status Code Reference
200 OK — successful GET, PUT
201 Created — successful POST (include Location header)
204 No Content — successful DELETE
400 Bad Request — invalid input format
401 Unauthorized — missing or invalid token
403 Forbidden — authenticated but lacks permission
404 Not Found — resource doesn't exist
409 Conflict — business rule conflict (duplicate SKU)
422 Unprocessable— domain rule violation (confirm empty order)
500 Internal — unexpected server error (never expose details in production)Testing Your Endpoints
# Create a product
curl -X POST https://localhost:7001/api/v1/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \
-d '{ "name": "Standing Desk", "sku": "DESK-SIT-001", "price": 499.99, "initialStock": 50 }'
# Get with pagination
curl "https://localhost:7001/api/v1/products?page=1&pageSize=10&search=desk" \
-H "Authorization: Bearer {token}"
# Confirm an order
curl -X POST "https://localhost:7001/api/v1/orders/{id}/confirm" \
-H "Authorization: Bearer {token}"Key Takeaways
- Use endpoint groups with
MapGroup()to organise routes and apply shared policies - Never expose domain entities — always map to DTOs before returning from the API
- Results.Created() sets both the 201 status and the
Locationheader — always use it for POST - Validate in two places: data annotations for format (
[Required],[MaxLength]), domain objects for business rules - ProblemDetails (RFC 9457) is the standard error format for .NET APIs — consistent for API consumers
- Clamp pagination — never let a client request unbounded result sets
- Domain methods throw business rule exceptions; the API layer catches them and maps to HTTP status codes
Why should you never return domain entities directly from API endpoints?