.NET Core

.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

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("/products", async (IProductRepository repo) =>
{
    var products = await repo.GetAllAsync();
    return Results.Ok(products);
});

app.MapGet("/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("/products", async (CreateProductRequest request, IProductRepository repo) =>
{
    var product = await repo.CreateAsync(request);
    return Results.Created($"/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
// Route parameters
app.MapGet("/users/{id:guid}", (Guid id) => ...);

// Query string parameters
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) => ...);

// Header binding
app.MapGet("/protected", ([FromHeader(Name = "X-Api-Key")] string apiKey) => ...);

// Body binding (automatic for complex types on POST/PUT)
app.MapPost("/orders", (CreateOrderRequest request) => ...);

// Service injection
app.MapGet("/stats", (IAnalyticsService analytics, ILogger<Program> logger) => ...);

// HttpContext access when you need full control
app.MapGet("/custom", (HttpContext context) => ...);

// AsParameters for grouping multiple bindings
app.MapGet("/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 = 1,
    int PageSize = 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("/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("/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($"/orders/{order.Id}", order);
});

The Results<T1, T2, ...> 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("/api/v1");

var products = api.MapGroup("/products")
    .WithTags("Products")
    .RequireAuthorization("ApiScope");

products.MapGet("/", GetAllProducts);
products.MapGet("/{id:int}", GetProductById);
products.MapPost("/", CreateProduct).RequireAuthorization("Admin");
products.MapPut("/{id:int}", UpdateProduct).RequireAuthorization("Admin");
products.MapDelete("/{id:int}", DeleteProduct).RequireAuthorization("Admin");

var orders = api.MapGroup("/orders")
    .WithTags("Orders")
    .RequireAuthorization();

orders.MapGet("/", GetUserOrders);
orders.MapGet("/{id:int}", GetOrderById);
orders.MapPost("/", 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("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);
    }
}

// Usage
products.MapPost("/", CreateProduct)
    .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

You can also define inline filters for simpler scenarios:

csharp
app.MapGet("/items/{id:int}", (int id) => ...)
    .AddEndpointFilter(async (context, next) =>
    {
        var id = context.GetArgument<int>(0);
        if (id <= 0)
            return TypedResults.BadRequest("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("/products/{id}", async (int id, IProductRepository repo) =>
{
    var product = await repo.GetByIdAsync(id);
    return product is not null ? Results.Ok(product) : Results.NotFound();
})
.WithName("GetProductById")
.WithDescription("Retrieves a single product by its unique identifier.")
.Produces<Product>(200)
.Produces(404)
.WithTags("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
// Features/Products/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/products").WithTags("Products");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create).RequireAuthorization("Admin");
        group.MapPut("/{id:int}", Update).RequireAuthorization("Admin");
        group.MapDelete("/{id:int}", Delete).RequireAuthorization("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();
    }

    // ... other handlers
}

// 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("Admin", policy => policy.RequireRole("admin"))
    .AddPolicy("ApiScope", policy => policy.RequireClaim("scope", "api"));

var app = builder.Build();

// Apply globally to a route group
var api = app.MapGroup("/api").RequireAuthorization("ApiScope");

// Override for specific endpoints
api.MapGet("/public/health", () => Results.Ok("Healthy")).AllowAnonymous();
api.MapDelete("/admin/cache", (ICacheService cache) => cache.Clear()).RequireAuthorization("Admin");

Centralized Validation with FluentValidation

csharp
// Register all validators from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// 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, "Unhandled exception");

        context.Response.StatusCode = exception switch
        {
            NotFoundException => 404,
            ValidationException => 400,
            UnauthorizedAccessException => 403,
            _ => 500
        };

        await context.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = context.Response.StatusCode,
            Title = exception switch
            {
                NotFoundException => "Resource not found",
                ValidationException => "Validation failed",
                _ => "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
// Bad: handler does everything
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
    // 30 lines of validation, business rules, and persistence
    // This becomes unmaintainable fast
});

// Good: handler delegates to a service
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService service) =>
{
    var result = await service.PlaceOrderAsync(req);
    return result.IsSuccess
        ? TypedResults.Created($"/orders/{result.Value.Id}", result.Value)
        : TypedResults.BadRequest(result.Error);
});

2. Ignoring CancellationToken

csharp
// Bad: no cancellation support
app.MapGet("/reports", async (IReportService service) =>
    await service.GenerateAsync());

// Good: propagate cancellation
app.MapGet("/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
// Bad: always returns 200, even for creation
app.MapPost("/items", async (Item item, IItemService service) =>
    Results.Ok(await service.CreateAsync(item)));

// Good: use semantically correct status codes
app.MapPost("/items", async (Item item, IItemService service) =>
{
    var created = await service.CreateAsync(item);
    return TypedResults.Created($"/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