Clean Architecture With Minimal APIs in .NET

Clean Architecture With Minimal APIs in .NET

6 min read··

aspnetcoreclean-architecturedotnetminimal-apis

Minimal APIs and Clean Architecture are a natural fit: the endpoint receives HTTP, dispatches a command or query to the Application layer, and maps the result back to an HTTP response. Minimal APIs strip endpoint definitions down to plain functions, and Clean Architecture gives all the logic those functions shouldn't contain a proper home.

The combination produces an API layer so thin it's almost boring, which is exactly what you want. Here's how to structure it, from endpoint organization to validation filters and Problem Details.

Why Minimal APIs With Clean Architecture?

Minimal APIs give you a lightweight, low-ceremony way to define HTTP endpoints in ASP.NET Core.

Clean Architecture gives you a structured way to organize business logic independently from infrastructure.

Together, they create a Presentation layer that's thin, readable, and easy to maintain. Your Minimal API endpoints become simple adapters that translate HTTP requests into commands/queries and return HTTP responses.

The Architecture

An HTTP request flows through the Minimal API endpoint into an Application layer command or query, and the result flows back out as an HTTP response:

An HTTP request enters a Minimal API endpoint in the Presentation layer, becomes a command or query handled in the Application layer, which uses the Domain entities and Infrastructure implementations, then returns a result that maps back to an HTTP response

The Api project is a thin coordination layer. It has two jobs:

  1. Map HTTP requests to Application layer commands and queries
  2. Map Application layer results back to HTTP responses

Defining an Endpoint

Here's a Minimal API endpoint that calls a command handler:

app.MapPost("/api/orders", async (
    PlaceOrderRequest request,
    ICommandHandler<PlaceOrderCommand, Guid> handler,
    CancellationToken cancellationToken) =>
{
    var command = new PlaceOrderCommand(request.CustomerId, request.Items);

    var result = await handler.Handle(command, cancellationToken);

    return result.IsSuccess
        ? Results.Created($"/api/orders/{result.Value}", new { id = result.Value })
        : result.ToProblemDetails();
});

The endpoint:

  1. Receives the HTTP request
  2. Creates a command from the request body
  3. Passes it to the handler (from the Application layer)
  4. Returns an appropriate HTTP response

No business logic. No database calls. Just request/response translation.

One note on wiring: the endpoint resolves ICommandHandler<PlaceOrderCommand, Guid> straight from DI. That works when your handlers are registered against these interfaces (a single Scrutor assembly scan handles it). If you're using MediatR, inject ISender and call Send instead; the shape of the endpoint stays the same.

Organizing Endpoints

Minimal APIs can get messy if you dump everything into Program.cs. Organize them using static classes:

public static class OrderEndpoints
{
    public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders")
            .WithTags("Orders");

        group.MapPost("", PlaceOrder);
        group.MapGet("{orderId:guid}", GetOrderById);
        group.MapPut("{orderId:guid}/cancel", CancelOrder);
        group.MapGet("", GetOrders);
    }

    private static async Task<IResult> PlaceOrder(
        PlaceOrderRequest request,
        ICommandHandler<PlaceOrderCommand, Guid> handler,
        CancellationToken cancellationToken)
    {
        var command = new PlaceOrderCommand(request.CustomerId, request.Items);

        var result = await handler.Handle(command, cancellationToken);

        return result.IsSuccess
            ? Results.Created($"/api/orders/{result.Value}", new { id = result.Value })
            : result.ToProblemDetails();
    }

    private static async Task<IResult> GetOrderById(
        Guid orderId,
        IQueryHandler<GetOrderByIdQuery, OrderResponse> handler,
        CancellationToken cancellationToken)
    {
        var query = new GetOrderByIdQuery(orderId);

        var result = await handler.Handle(query, cancellationToken);

        return result.IsSuccess
            ? Results.Ok(result.Value)
            : Results.NotFound();
    }

    private static async Task<IResult> CancelOrder(
        Guid orderId,
        ICommandHandler<CancelOrderCommand> handler,
        CancellationToken cancellationToken)
    {
        var command = new CancelOrderCommand(orderId);

        var result = await handler.Handle(command, cancellationToken);

        return result.IsSuccess
            ? Results.NoContent()
            : result.ToProblemDetails();
    }

    private static async Task<IResult> GetOrders(
        [AsParameters] GetOrdersRequest request,
        IQueryHandler<GetOrdersQuery, PagedList<OrderSummary>> handler,
        CancellationToken cancellationToken)
    {
        var query = new GetOrdersQuery(request.Page, request.PageSize);

        var result = await handler.Handle(query, cancellationToken);

        return result.IsSuccess
            ? Results.Ok(result.Value)
            : result.ToProblemDetails();
    }
}

Then register them in Program.cs:

app.MapOrderEndpoints();
app.MapCustomerEndpoints();
app.MapProductEndpoints();

Each feature gets its own endpoint file. This follows the Screaming Architecture principle.

I shared more variations of this approach in how to structure Minimal APIs. And if you want to skip the manual Map* calls in Program.cs, you can register Minimal API endpoints automatically with a bit of reflection.

Using Carter for Endpoint Organization

Carter provides a module system for organizing Minimal API endpoints:

public class OrderModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapPost("", PlaceOrder);
        group.MapGet("{orderId:guid}", GetOrderById);
    }

    private static async Task<IResult> PlaceOrder(
        PlaceOrderRequest request,
        ICommandHandler<PlaceOrderCommand, Guid> handler,
        CancellationToken cancellationToken)
    {
        var command = new PlaceOrderCommand(request.CustomerId, request.Items);
        var result = await handler.Handle(command, cancellationToken);

        return result.IsSuccess
            ? Results.Created($"/api/orders/{result.Value}", new { id = result.Value })
            : result.ToProblemDetails();
    }

    private static async Task<IResult> GetOrderById(
        Guid orderId,
        IQueryHandler<GetOrderByIdQuery, OrderResponse> handler,
        CancellationToken cancellationToken)
    {
        var query = new GetOrderByIdQuery(orderId);
        var result = await handler.Handle(query, cancellationToken);

        return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
    }
}

Carter auto-discovers modules and registers them:

builder.Services.AddCarter();
// ...
app.MapCarter();

Adding Endpoint Filters

Endpoint Filters are the Minimal API equivalent of action filters. Use them for cross-cutting concerns:

public class ValidationFilter<TRequest> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();

        if (request is null)
        {
            return Results.BadRequest("Invalid request.");
        }

        var validator = context.HttpContext
            .RequestServices
            .GetService<IValidator<TRequest>>();

        if (validator is not null)
        {
            var validationResult = await validator.ValidateAsync(request);
            if (!validationResult.IsValid)
            {
                return Results.ValidationProblem(validationResult.ToDictionary());
            }
        }

        return await next(context);
    }
}

Apply it to endpoints:

group.MapPost("", PlaceOrder)
    .AddEndpointFilter<ValidationFilter<PlaceOrderRequest>>();

Mapping Results to HTTP Responses

Create an extension method that maps your Result pattern to Problem Details responses:

public static class ResultExtensions
{
    public static IResult ToProblemDetails(this Result result)
    {
        if (result.IsSuccess)
        {
            throw new InvalidOperationException("Cannot convert a success result to problem details.");
        }

        return Results.Problem(
            statusCode: GetStatusCode(result.Error.Type),
            title: GetTitle(result.Error.Type),
            extensions: new Dictionary<string, object?>
            {
                { "errors", new[] { result.Error } }
            });
    }

    private static int GetStatusCode(ErrorType errorType) => errorType switch
    {
        ErrorType.Validation => StatusCodes.Status400BadRequest,
        ErrorType.NotFound => StatusCodes.Status404NotFound,
        ErrorType.Conflict => StatusCodes.Status409Conflict,
        ErrorType.Forbidden => StatusCodes.Status403Forbidden,
        _ => StatusCodes.Status500InternalServerError
    };

    private static string GetTitle(ErrorType errorType) => errorType switch
    {
        ErrorType.Validation => "Bad Request",
        ErrorType.NotFound => "Not Found",
        ErrorType.Conflict => "Conflict",
        ErrorType.Forbidden => "Forbidden",
        _ => "Server Error"
    };
}

This gives you consistent Problem Details responses across all endpoints.

Minimal APIs vs Controllers

How do the two approaches compare?

  • Ceremony: Minimal APIs are just functions. Controllers need a class, a base type, and routing attributes.
  • Performance: Minimal APIs are slightly faster because they skip parts of the MVC pipeline (model binding infrastructure, filters, view support).
  • Testability: identical. In both cases you test the handler in the Application layer, not the HTTP adapter.
  • Organization: extension methods or Carter modules for Minimal APIs; controller classes for MVC.
  • OpenAPI: both work with the built-in OpenAPI document generation in .NET 9+. Minimal APIs describe metadata fluently (WithTags, Produces), controllers use attributes.
  • Model binding: Minimal APIs bind from route, query, and body by convention with [AsParameters] for grouping. Controllers use [FromBody], [FromQuery], and friends.

In a Clean Architecture setup, both approaches are thin adapters. The real logic lives in the Application layer. The choice between Minimal APIs and controllers is mostly a style preference.

I prefer Minimal APIs for new projects because they're more concise and align well with the REPR pattern.

Summary

Minimal APIs and Clean Architecture complement each other. The API layer becomes a thin adapter: receive HTTP, create command/query, call handler, return HTTP response.

Organize endpoints by feature using extension methods or Carter modules. Use endpoint filters for cross-cutting concerns. Map results to Problem Details consistently.

Keep your endpoints thin, and let the Application layer do the real work.

Thanks for reading, and stay awesome!


Frequently Asked Questions

Can you use Minimal APIs with Clean Architecture?

Yes. Minimal API endpoints work as thin adapters in the Presentation layer: they translate HTTP requests into commands or queries, invoke a handler from the Application layer, and map the result back to an HTTP response.

Should Minimal API endpoints contain business logic?

No. Endpoints should only handle request and response translation. Business logic belongs in the Application and Domain layers, which keeps it testable without spinning up a web host.

How do you organize Minimal API endpoints in a large project?

Group endpoints by feature using static classes with extension methods, or use a library like Carter that auto-discovers endpoint modules. Avoid dumping every route into Program.cs.

Are Minimal APIs better than controllers for Clean Architecture?

Both work equally well because both are thin adapters over the Application layer. Minimal APIs have less ceremony and slightly better performance; controllers offer familiar conventions. The choice is mostly style preference.

How do you handle validation in Minimal APIs?

Use endpoint filters that resolve a FluentValidation validator from DI and short-circuit with a 400 ValidationProblem response, or run validation in a MediatR pipeline behavior inside the Application layer.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.