Back to blog
Backend Systemsintermediate

REST API Deep Dive β€” .NET 9 Minimal API

Build production-quality REST endpoints for OrderFlow. Covers Minimal API vs Controllers, endpoint groups, request/response DTOs, validation, problem details, pagination, and API versioning.

LearnixoApril 14, 20267 min read
.NETREST APIMinimal APIASP.NET CoreSwaggerVersioningC#
Share:𝕏

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:

C#
// 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:

C#
// 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

C#
// 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

C#
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

C#
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

C#
// 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

C#
// 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:

JSON
{
  "items": [...],
  "page": 1,
  "pageSize": 20,
  "totalCount": 143,
  "totalPages": 8,
  "hasPreviousPage": false,
  "hasNextPage": true
}

Global Error Handling

One piece of middleware handles all unhandled exceptions:

C#
// 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

Bash
dotnet add OrderFlow.Api package Asp.Versioning.Http
C#
// 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
});
C#
// 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

Bash
# 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 Location header β€” 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

REST API Knowledge Check

5 questions Β· Test what you just learned Β· Instant explanations

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.