.NET Minimal APIs: Lightweight and Fast Endpoints

13 min readFebruary 9, 2026Updated: Mar 9, 2026
.NET Minimal APIMinimal APIs tutorial.NET 8 minimal apiController vs Minimal APILightweight .NET APIFast .NET endpoints.NET microservicesServerless .NET

# .NET Minimal APIs: Lightweight and Fast Endpoints

Minimal APIs were introduced in .NET 6 as a radically simpler way to build HTTP endpoints in ASP.NET Core. Instead of controllers, attributes, and ceremonial class hierarchies, you define routes directly with lambda expressions. Yet beneath that simplicity lies full access to the ASP.NET Core platform: dependency injection, middleware, authentication, authorization, and OpenAPI tooling all work exactly as you'd expect.

In microservices I've built with Minimal APIs, startup time dropped noticeably and the cognitive overhead for new team members shrank dramatically. A developer can open `Program.cs`, see every route in the service, and start contributing within minutes.

Where Minimal APIs Shine

  • **Focused microservices** with a limited endpoint surface (5-20 endpoints)
  • **Internal tools and BFF services** that aggregate data for frontends
  • **MVPs and prototypes** that need fast iteration without scaffolding overhead
  • **Lightweight CRUD and webhook handlers** where controller ceremony adds no value
  • **Serverless functions** and container-based workloads where cold start time matters
  • **Event-driven services** that receive messages and expose health/status endpoints
  • Getting Started: The Basics

    A complete Minimal API can live in a single file:

    csharp
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddSingleton<IProductRepository, ProductRepository>();
    
    var app = builder.Build();
    
    app.MapGet(class="code-string">"/products", async (IProductRepository repo) =>
    {
        var products = await repo.GetAllAsync();
        return Results.Ok(products);
    });
    
    app.MapGet(class="code-string">"/products/{id:int}", async (int id, IProductRepository repo) =>
    {
        var product = await repo.GetByIdAsync(id);
        return product is not null ? Results.Ok(product) : Results.NotFound();
    });
    
    app.MapPost(class="code-string">"/products", async (CreateProductRequest request, IProductRepository repo) =>
    {
        var product = await repo.CreateAsync(request);
        return Results.Created($class="code-string">"/products/{product.Id}", product);
    });
    
    app.Run();

    Notice how dependency injection works through parameter binding. The framework inspects handler parameters and resolves services, route values, query strings, and body content automatically.

    Parameter Binding in Depth

    Minimal APIs use a convention-based binding system that's both powerful and intuitive:

    csharp
    class=class="code-string">"code-comment">// Route parameters
    app.MapGet(class="code-string">"/users/{id:guid}", (Guid id) => ...);
    
    class=class="code-string">"code-comment">// Query string parameters
    app.MapGet(class="code-string">"/search", (string? query, int page = class="code-number">1, int pageSize = class="code-number">20) => ...);
    
    class=class="code-string">"code-comment">// Header binding
    app.MapGet(class="code-string">"/protected", ([FromHeader(Name = class="code-string">"X-Api-Key")] string apiKey) => ...);
    
    class=class="code-string">"code-comment">// Body binding (automatic for complex types on POST/PUT)
    app.MapPost(class="code-string">"/orders", (CreateOrderRequest request) => ...);
    
    class=class="code-string">"code-comment">// Service injection
    app.MapGet(class="code-string">"/stats", (IAnalyticsService analytics, ILogger<Program> logger) => ...);
    
    class=class="code-string">"code-comment">// HttpContext access when you need full control
    app.MapGet(class="code-string">"/custom", (HttpContext context) => ...);
    
    class=class="code-string">"code-comment">// AsParameters for grouping multiple bindings
    app.MapGet(class="code-string">"/products", async ([AsParameters] ProductFilterRequest filter) => ...);

    The `[AsParameters]` attribute is particularly useful for GET endpoints with many query parameters:

    csharp
    public record ProductFilterRequest(
        string? Category,
        decimal? MinPrice,
        decimal? MaxPrice,
        int Page = class="code-number">1,
        int PageSize = class="code-number">20,
        [FromServices] IProductRepository Repository = default!
    );

    Typed Results for Clearer Contracts

    One of the biggest improvements since .NET 7 is `TypedResults`, which makes your endpoint contracts explicit and feeds directly into OpenAPI documentation:

    csharp
    app.MapGet(class="code-string">"/orders/{id}", async Task<Results<Ok<Order>, NotFound>> (int id, IOrderService service) =>
    {
        var order = await service.GetByIdAsync(id);
        return order is not null
            ? TypedResults.Ok(order)
            : TypedResults.NotFound();
    });
    
    app.MapPost(class="code-string">"/orders", async Task<Results<Created<Order>, ValidationProblem>> (
        CreateOrderRequest request, IOrderService service, IValidator<CreateOrderRequest> validator) =>
    {
        var validation = await validator.ValidateAsync(request);
        if (!validation.IsValid)
            return TypedResults.ValidationProblem(validation.ToDictionary());
    
        var order = await service.CreateAsync(request);
        return TypedResults.Created($class="code-string">"/orders/{order.Id}", order);
    });

    The `Results` union type tells both the compiler and the OpenAPI generator exactly which responses an endpoint can produce.

    Route Groups for Feature-Level Organization

    Route groups let you share configuration across related endpoints, reducing repetition:

    csharp
    var api = app.MapGroup(class="code-string">"/api/v1");
    
    var products = api.MapGroup(class="code-string">"/products")
        .WithTags(class="code-string">"Products")
        .RequireAuthorization(class="code-string">"ApiScope");
    
    products.MapGet(class="code-string">"/", GetAllProducts);
    products.MapGet(class="code-string">"/{id:int}", GetProductById);
    products.MapPost(class="code-string">"/", CreateProduct).RequireAuthorization(class="code-string">"Admin");
    products.MapPut(class="code-string">"/{id:int}", UpdateProduct).RequireAuthorization(class="code-string">"Admin");
    products.MapDelete(class="code-string">"/{id:int}", DeleteProduct).RequireAuthorization(class="code-string">"Admin");
    
    var orders = api.MapGroup(class="code-string">"/orders")
        .WithTags(class="code-string">"Orders")
        .RequireAuthorization();
    
    orders.MapGet(class="code-string">"/", GetUserOrders);
    orders.MapGet(class="code-string">"/{id:int}", GetOrderById);
    orders.MapPost(class="code-string">"/", PlaceOrder);

    Groups support nested composition, shared filters, and authorization policies, making them the primary building block for organizing Minimal APIs beyond trivial examples.

    Endpoint Filters: Cross-Cutting Logic

    Endpoint filters are the Minimal API equivalent of action filters in MVC. They intercept requests before and after the handler runs:

    csharp
    public class ValidationFilter<T> : IEndpointFilter where T : class
    {
        public async ValueTask<object?> InvokeAsync(
            EndpointFilterInvocationContext context,
            EndpointFilterDelegate next)
        {
            var request = context.Arguments.OfType<T>().FirstOrDefault();
            if (request is null)
                return TypedResults.BadRequest(class="code-string">"Request body is required.");
    
            var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
            if (validator is not null)
            {
                var result = await validator.ValidateAsync(request);
                if (!result.IsValid)
                    return TypedResults.ValidationProblem(result.ToDictionary());
            }
    
            return await next(context);
        }
    }
    
    class=class="code-string">"code-comment">// Usage
    products.MapPost(class="code-string">"/", CreateProduct)
        .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

    You can also define inline filters for simpler scenarios:

    csharp
    app.MapGet(class="code-string">"/items/{id:int}", (int id) => ...)
        .AddEndpointFilter(async (context, next) =>
        {
            var id = context.GetArgument<int>(class="code-number">0);
            if (id <= class="code-number">0)
                return TypedResults.BadRequest(class="code-string">"ID must be positive.");
            return await next(context);
        });

    OpenAPI Integration

    Since .NET 9, Minimal APIs have first-class OpenAPI support built into the framework:

    csharp
    builder.Services.AddOpenApi();
    
    var app = builder.Build();
    
    app.MapOpenApi();
    
    app.MapGet(class="code-string">"/products/{id}", async (int id, IProductRepository repo) =>
    {
        var product = await repo.GetByIdAsync(id);
        return product is not null ? Results.Ok(product) : Results.NotFound();
    })
    .WithName(class="code-string">"GetProductById")
    .WithDescription(class="code-string">"Retrieves a single product by its unique identifier.")
    .Produces<Product>(class="code-number">200)
    .Produces(class="code-number">404)
    .WithTags(class="code-string">"Products");

    For earlier .NET versions, Swashbuckle or NSwag integrate seamlessly. The key is that `TypedResults` automatically generates accurate schemas, so you get reliable documentation without manual annotation.

    Structuring Minimal APIs at Scale

    The single-file approach works for small services, but anything with more than a handful of endpoints needs structure. The pattern I've found most effective is organizing endpoints into feature-based static classes:

    csharp
    class=class="code-string">"code-comment">// Features/Products/ProductEndpoints.cs
    public static class ProductEndpoints
    {
        public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
        {
            var group = routes.MapGroup(class="code-string">"/products").WithTags(class="code-string">"Products");
    
            group.MapGet(class="code-string">"/", GetAll);
            group.MapGet(class="code-string">"/{id:int}", GetById);
            group.MapPost(class="code-string">"/", Create).RequireAuthorization(class="code-string">"Admin");
            group.MapPut(class="code-string">"/{id:int}", Update).RequireAuthorization(class="code-string">"Admin");
            group.MapDelete(class="code-string">"/{id:int}", Delete).RequireAuthorization(class="code-string">"Admin");
    
            return group;
        }
    
        private static async Task<Ok<List<ProductDto>>> GetAll(
            IProductRepository repo, CancellationToken ct)
        {
            var products = await repo.GetAllAsync(ct);
            return TypedResults.Ok(products);
        }
    
        private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
            int id, IProductRepository repo, CancellationToken ct)
        {
            var product = await repo.GetByIdAsync(id, ct);
            return product is not null
                ? TypedResults.Ok(product)
                : TypedResults.NotFound();
        }
    
        class=class="code-string">"code-comment">// ... other handlers
    }
    
    class=class="code-string">"code-comment">// Program.cs stays clean
    var app = builder.Build();
    app.MapProductEndpoints();
    app.MapOrderEndpoints();
    app.MapUserEndpoints();
    app.Run();

    This approach gives you the clarity of seeing every route in one place per feature while keeping `Program.cs` as a composition root. Each feature file owns its routes, handlers, and authorization requirements.

    Adding Auth, Validation, and Error Handling

    Authentication and Authorization

    csharp
    builder.Services.AddAuthentication().AddJwtBearer();
    builder.Services.AddAuthorizationBuilder()
        .AddPolicy(class="code-string">"Admin", policy => policy.RequireRole(class="code-string">"admin"))
        .AddPolicy(class="code-string">"ApiScope", policy => policy.RequireClaim(class="code-string">"scope", class="code-string">"api"));
    
    var app = builder.Build();
    
    class=class="code-string">"code-comment">// Apply globally to a route group
    var api = app.MapGroup(class="code-string">"/api").RequireAuthorization(class="code-string">"ApiScope");
    
    class=class="code-string">"code-comment">// Override for specific endpoints
    api.MapGet(class="code-string">"/public/health", () => Results.Ok(class="code-string">"Healthy")).AllowAnonymous();
    api.MapDelete(class="code-string">"/admin/cache", (ICacheService cache) => cache.Clear()).RequireAuthorization(class="code-string">"Admin");

    Centralized Validation with FluentValidation

    csharp
    class=class="code-string">"code-comment">// Register all validators from the assembly
    builder.Services.AddValidatorsFromAssemblyContaining<Program>();
    
    class=class="code-string">"code-comment">// Generic validation filter applied per route group
    public static RouteGroupBuilder WithValidation<T>(this RouteGroupBuilder group) where T : class
    {
        return group.AddEndpointFilter<ValidationFilter<T>>();
    }

    Global Error Handling

    csharp
    app.UseExceptionHandler(errorApp =>
    {
        errorApp.Run(async context =>
        {
            var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
            var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
            logger.LogError(exception, class="code-string">"Unhandled exception");
    
            context.Response.StatusCode = exception switch
            {
                NotFoundException => class="code-number">404,
                ValidationException => class="code-number">400,
                UnauthorizedAccessException => class="code-number">403,
                _ => class="code-number">500
            };
    
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = context.Response.StatusCode,
                Title = exception switch
                {
                    NotFoundException => class="code-string">"Resource not found",
                    ValidationException => class="code-string">"Validation failed",
                    _ => class="code-string">"An error occurred"
                }
            });
        });
    });

    In microservices I've built with Minimal APIs, combining a global exception handler with endpoint filters for validation covers the vast majority of error scenarios cleanly. The handler catches anything unexpected, and filters handle the predictable cases close to the endpoint.

    Minimal APIs vs Controllers: A Decision Framework

    | Aspect | Minimal APIs | Controllers |

    |---|---|---|

    | Boilerplate | Very low, lambda-based | Moderate, class + attributes |

    | Startup performance | Faster, no reflection overhead | Slightly slower |

    | Discoverability | All routes visible in one file or feature module | Scattered across controller classes |

    | Model binding | Convention-based, simpler | Richer, more configurable |

    | Filters/middleware | Endpoint filters (newer, simpler) | Action filters (mature, full-featured) |

    | OpenAPI support | Built-in with TypedResults | Attribute-driven, well-established |

    | Testing | Direct function calls | Requires controller instantiation |

    | Team familiarity | Newer paradigm | Well-known MVC pattern |

    | Large API surfaces | Needs discipline to organize | Naturally structured by class |

    | Content negotiation | Manual setup | Built-in |

    Choose Minimal APIs when: you're building focused microservices, BFFs, or any service where simplicity and startup speed matter more than MVC conventions. If your service has fewer than 30-40 endpoints, Minimal APIs will likely be cleaner.

    Choose Controllers when: you have a large API surface with complex model binding, content negotiation, or a team deeply invested in MVC patterns. There's no shame in controllers; they're battle-tested and well-understood.

    The hybrid approach also works: use Minimal APIs for simple CRUD and webhook endpoints while keeping controllers for complex areas that benefit from MVC conventions. ASP.NET Core supports both in the same project.

    Common Minimal API Mistakes

    1. Putting Business Logic in Handlers

    csharp
    class=class="code-string">"code-comment">// Bad: handler does everything
    app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, AppDbContext db) =>
    {
        class=class="code-string">"code-comment">// class="code-number">30 lines of validation, business rules, and persistence
        class=class="code-string">"code-comment">// This becomes unmaintainable fast
    });
    
    class=class="code-string">"code-comment">// Good: handler delegates to a service
    app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, IOrderService service) =>
    {
        var result = await service.PlaceOrderAsync(req);
        return result.IsSuccess
            ? TypedResults.Created($class="code-string">"/orders/{result.Value.Id}", result.Value)
            : TypedResults.BadRequest(result.Error);
    });

    2. Ignoring CancellationToken

    csharp
    class=class="code-string">"code-comment">// Bad: no cancellation support
    app.MapGet(class="code-string">"/reports", async (IReportService service) =>
        await service.GenerateAsync());
    
    class=class="code-string">"code-comment">// Good: propagate cancellation
    app.MapGet(class="code-string">"/reports", async (IReportService service, CancellationToken ct) =>
        await service.GenerateAsync(ct));

    3. Not Using Route Groups

    Defining every route at the top level with repeated `.RequireAuthorization()` and `.WithTags()` calls leads to noisy, hard-to-maintain code. Route groups exist to solve exactly this problem.

    4. Overusing `Results.Ok()` for Everything

    csharp
    class=class="code-string">"code-comment">// Bad: always returns class="code-number">200, even for creation
    app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
        Results.Ok(await service.CreateAsync(item)));
    
    class=class="code-string">"code-comment">// Good: use semantically correct status codes
    app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
    {
        var created = await service.CreateAsync(item);
        return TypedResults.Created($class="code-string">"/items/{created.Id}", created);
    });

    5. Skipping Structured Logging

    Minimal APIs don't have built-in action logging like MVC. Add request/response logging explicitly, either through middleware or endpoint filters, or you'll be debugging blind in production.

    Conclusion

    Minimal APIs are not a toy or a shortcut; they're a mature, production-ready approach to building HTTP services in .NET. The difference from controllers is syntax and ceremony, not engineering standards. You still need clean architecture, proper validation, structured error handling, and thoughtful endpoint organization.

    The key insight is that Minimal APIs remove friction without removing capability. Every feature you rely on in controller-based ASP.NET Core, including DI, auth, OpenAPI, filters, and middleware, is fully available. What you gain is clarity: your service's entire surface area is visible, composable, and easy to reason about.

    I can help evaluate whether Minimal APIs or Controllers fit your service best, and design an endpoint structure that scales cleanly.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch