Intermediate 13 min · March 06, 2026

Minimal APIs — Missing Auth Middleware Exposes Endpoints

All /products endpoints expose data because auth middleware runs after endpoint mapping.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Minimal APIs define HTTP endpoints directly in Program.cs without controllers
  • Route groups (MapGroup) keep code organized as you scale
  • TypedResults enable compile-time return type checking and accurate OpenAPI schemas
  • Endpoint filters (AddEndpointFilter) handle cross-cutting concerns like validation without handler pollution
  • Performance advantage: 20-40% faster startup than controller-based APIs in benchmarks
Plain-English First

Imagine you run a food truck. A full restaurant needs a host stand, a maître d', printed menus, linen tablecloths, and a reservations system — even if you're only selling tacos. A food truck skips all that and just hands you a taco through a window. Minimal APIs are ASP.NET Core's food truck: you get exactly what you need (an HTTP endpoint that responds to requests) without setting up a full restaurant (controllers, action filters, routing attributes, view engines). When all you need is a taco, don't build a restaurant.

REST APIs power almost every app you use today — from the weather widget on your phone to the checkout button on an e-commerce site. In the .NET world, the traditional way to build those APIs meant spinning up controllers, wiring up attributes, following the MVC folder structure, and writing a surprising amount of code before a single byte reached a client. For large, team-built enterprise apps that structure is genuinely useful. But for microservices, lightweight backend-for-frontend services, rapid prototypes, or serverless functions, it's overkill that slows you down.

Minimal APIs, introduced in .NET 6 and significantly matured in .NET 7 and .NET 8, let you define an entire HTTP endpoint in a single line of C#. No controller class. No [HttpGet] attribute. No routing conventions to memorise. The framework infers everything it can and gets out of your way. The result is a Program.cs file that reads almost like plain English, compiles faster, and starts up faster — which matters enormously in containerised, auto-scaling environments where cold-start time costs real money.

By the end of this article you'll know exactly when to choose Minimal APIs over controller-based APIs (and vice versa), how to structure a real-world Minimal API with dependency injection, validation, error handling, and OpenAPI docs, and the gotchas that will bite you if you don't know about them. We'll build a working Product Catalogue API from scratch — one section at a time — so every piece of code you see connects to a real need.

Your First Minimal API — What's Actually Happening Under the Hood

Before we build something real, let's understand what the framework is actually doing when you write a Minimal API endpoint. When you call app.MapGet(), you're registering a route handler directly with the endpoint routing middleware. There's no controller class being instantiated, no action method being discovered via reflection at startup, and no IActionResult being unwrapped. The lambda you pass IS the handler — it runs directly.

This matters because it changes the performance profile. Controller-based APIs use model binding and action filters that add overhead on every request. Minimal API handlers use a source-generated parameter binder (from .NET 7 onward) that's resolved at compile time. That means faster startup and slightly lower per-request overhead — which adds up at scale.

The builder/app split in Program.cs is also worth understanding. WebApplication.CreateBuilder() sets up the DI container and configuration. builder.Build() locks it in and returns the WebApplication. Everything you call on app after that is middleware or endpoint registration. Order matters here — we'll hit that in the Gotchas section.

MinimalApiBasics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Program.cs — a complete, runnable Minimal API
// Requires: dotnet new web -o ProductApi
// No controllers, no separate Startup.cs, no folder structure required.

var builder = WebApplication.CreateBuilder(args);

// Register services into the DI container BEFORE calling Build()
builder.Services.AddEndpointsApiExplorer(); // Enables OpenAPI endpoint discovery
builder.Services.AddSwaggerGen();           // Wires up Swagger UI

var app = builder.Build();

// Middleware runs in the order you register it — this matters!
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();    // Serves the OpenAPI JSON spec at /swagger/v1/swagger.json
    app.UseSwaggerUI(); // Serves the browser UI at /swagger
}

// MapGet registers a GET handler for the route "/"
// The lambda IS the handler — no controller needed
app.MapGet("/", () => "Product Catalogue API is running!");

// MapGet with a route parameter — {id} is captured and injected automatically
// ASP.NET Core infers the int type from the parameter name and type annotation
app.MapGet("/products/{id}", (int id) =>
{
    // In a real app this would call a service; here we prove the binding works
    return Results.Ok(new { ProductId = id, Name = $"Product #{id}", Price = 9.99m });
});

// MapPost accepts a JSON body — the [FromBody] attribute is inferred automatically
// The record type is deserialized from the request body using System.Text.Json
app.MapPost("/products", (CreateProductRequest request) =>
{
    // Results.Created sets HTTP 201 and the Location header — correct REST semantics
    return Results.Created($"/products/42", new { Id = 42, request.Name, request.Price });
});

app.Run();

// Define your request/response models — keep them close to where they're used
// Using records gives you immutability and value equality for free
public record CreateProductRequest(string Name, decimal Price);
Output
// GET /
Product Catalogue API is running!
// GET /products/7
{
"productId": 7,
"name": "Product #7",
"price": 9.99
}
// POST /products { \"name\": \"Widget\", \"price\": 4.99 }\n// HTTP 201 Created\n// Location: /products/42\n{\n \"id\": 42,\n \"name\": \"Widget\",\n \"price\": 4.99\n}"
}

Parameter Binding Sources in Minimal APIs — Where Does Each Parameter Come From?

One of the most powerful yet surprising aspects of Minimal APIs is how ASP.NET Core automatically determines the source of each parameter in your handler lambda. The framework uses a set of conventions to decide whether a parameter comes from the route, query string, request body, HTTP headers, DI container, or form data. Understanding these binding rules is essential to avoid unexpected 400 errors or incorrect parameter values in production.

The binding source is determined by the parameter's type and name (and optional attributes). The table below summarises the default sources for common parameter types:

Parameter Type / AttributeBinding SourceExample
Simple types (int, string, Guid) matching a route template tokenRouteint id from /products/{id}
Simple types NOT matching any route tokenQuery stringstring name from /products?name=foo
Complex types (record, class) without explicit attributeRequest body (JSON)CreateProductRequest request
IFormFile / IFormFileCollectionForm dataIFormFile file
HttpContext, HttpRequest, HttpResponse, CancellationToken, ClaimsPrincipalSpecial services from the frameworkHttpContext ctx
Services registered in DI (IProductService, LinkGenerator, etc.)DI container (automatically resolved)IProductService svc
Parameters with [FromRoute], [FromQuery], [FromBody], [FromHeader]Explicit attribute overrides convention[FromHeader] string apiKey

For simple types, the framework first checks if the parameter name matches a route parameter name. If it does, it's bound from the route; otherwise, from the query string. Complex types (records, classes) are bound from the JSON body by default. You can override these conventions using attributes from Microsoft.AspNetCore.Mvc, such as [FromQuery] on a complex type to read it from query string fields.

The source-generated parameter binder is one of the reasons Minimal APIs outperform controller-based APIs. At compile time, for each endpoint, the source generator emits code that efficiently reads and converts parameters from their sources. This eliminates runtime reflection and reduces JIT compilation overhead. The improvement is most noticeable during cold starts in serverless environments.

When a parameter binding fails (e.g., a non-numeric route parameter where an integer is expected, or a malformed JSON body), ASP.NET Core returns a 400 Bad Request with a Problem Details response if AddProblemDetails is registered. This is a significant improvement over controller-based APIs, where invalid model binding often results in a generic 400 without structured error information.

ParameterBindingDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Demonstrates all parameter binding sources in a single handler
// This endpoint accepts both route and query parameters, a JSON body, and DI services

app.MapPost("/products/{categoryId:int}/check", async (
    int categoryId,                              // From route: /products/12/check
    string? filter,                              // From query: /products/12/check?filter=active
    CheckInventoryRequest request,               // From body as JSON
    [FromHeader(Name = "X-Tenant-Id")] string tenantId,  // From header using attribute
    IProductService productService,              // From DI container
    HttpContext httpContext,                      // Special framework service
    CancellationToken cancellationToken,         // Cancellation token from DI
    LinkGenerator linkGenerator) =>               // Another DI service
{\n    var result = await productService.CheckInventoryAsync(\n        categoryId, filter, request, tenantId, cancellationToken);\n\n    var location = linkGenerator.GetPathByName(httpContext, \"GetProductById\", new { id = result.Id });\n    return TypedResults.Created(location, result);\n});\n\n// Validation: If you send a non-integer categoryId, the framework returns 400 automatically\n// If you omit the body, it also returns 400 with validation details\n\npublic record CheckInventoryRequest(string Sku, int Quantity);",
        "output": "// Valid request:\n// POST /products/12/check?filter=active HTTP/1.1\n// Content-Type: application/json\n// X-Tenant-Id: acme-corp\n//\n// { \"sku\": \"WIDGET-001\", \"quantity\": 5 }\n//\n// HTTP 201 Created\n// Location: /products/42\n\n// Invalid route:\n// POST /products/abc/check ⇒ 400 Bad Request (cannot parse 'abc' as int)\n\n// Missing body:\n// POST /products/1/check ⇒ 400 with ProblemDetails (request body missing)"
      }

Structuring a Real-World Minimal API — Dependency Injection and Route Groups

The single-file demo looks tidy at 30 lines. At 300 lines it becomes a maintenance nightmare. The good news is Minimal APIs have a clean answer: Route Groups and extension methods. You split your endpoints across feature files and group them under a shared route prefix — without giving up any of the performance benefits.

Route Groups (added in .NET 7) let you call app.MapGroup('/products') and then register all product-related endpoints on that group object. The prefix is applied automatically, middleware like authentication can be attached to the group, and your Program.cs stays clean.

Dependency injection works exactly as you'd expect — you declare your service as a parameter in the handler lambda and ASP.NET Core resolves it from the DI container. There's no constructor injection because there's no class, but the result is the same. Typed parameters from the route, query string, body, and DI container are all resolved by position and type — the framework figures out where each one comes from.

This pattern scales well. You get one feature file per domain entity, a clean Program.cs, and full access to the DI container. It's the pattern used in production Minimal API services today.

ProductEndpoints.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ── ProductEndpoints.cs ──────────────────────────────────────────────────────
// Extension method that groups all /products endpoints in one place.
// Program.cs calls app.MapProductEndpoints() — keeping the entry file clean.

using Microsoft.AspNetCore.Http.HttpResults; // Typed Results — strongly-typed return types

public static class ProductEndpoints
{
    public static void MapProductEndpoints(this WebApplication app)
    {
        // RouteGroupBuilder applies the prefix AND any shared middleware to every endpoint below
        var group = app.MapGroup("/products")
                       .WithTags("Products")       // Groups endpoints in Swagger UI
                       .WithOpenApi();             // Generates OpenAPI metadata automatically

        group.MapGet("/",    GetAllProducts);
        group.MapGet("/{id:int}", GetProductById); // :int constrains the route — 404 on non-int
        group.MapPost("/",   CreateProduct);
        group.MapPut("/{id:int}",    UpdateProduct);
        group.MapDelete("/{id:int}", DeleteProduct);
    }

    // Handler methods are static — no allocations for a class instance per request
    // IProductService is resolved from DI; int id comes from the route segment
    static async Task<Ok<IEnumerable<Product>>> GetAllProducts(\n        IProductService productService)
    {
        var products = await productService.GetAllAsync();
        return TypedResults.Ok(products); // TypedResults gives compile-time HTTP status verification
    }

    static async Task<Results<Ok<Product>, NotFound>> GetProductById(\n        int id,\n        IProductService productService)
    {
        var product = await productService.GetByIdAsync(id);

        // Pattern match on null — returns 404 if not found, 200 with body if found
        return product is null
            ? TypedResults.NotFound()
            : TypedResults.Ok(product);
    }

    static async Task<Created<Product>> CreateProduct(
        CreateProductRequest request,       // Bound from JSON request body
        IProductService productService,
        LinkGenerator linkGenerator,        // Built-in service to generate URLs
        HttpContext httpContext)
    {\n        var created = await productService.CreateAsync(request);\n\n        // Generate the Location header pointing to the new resource — correct REST\n        var location = linkGenerator.GetPathByName(httpContext, \"GetProductById\",\n                                                   new { id = created.Id });\n        return TypedResults.Created(location, created);\n    }\n\n    static async Task<Results<NoContent, NotFound>> UpdateProduct(\n        int id,\n        UpdateProductRequest request,\n        IProductService productService)\n    {\n        var success = await productService.UpdateAsync(id, request);\n        return success ? TypedResults.NoContent() : TypedResults.NotFound();\n    }\n\n    static async Task<Results<NoContent, NotFound>> DeleteProduct(\n        int id,\n        IProductService productService)\n    {\n        var success = await productService.DeleteAsync(id);\n        return success ? TypedResults.NoContent() : TypedResults.NotFound();\n    }\n}\n\n// ── Program.cs (the clean version) ──────────────────────────────────────────\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddEndpointsApiExplorer();\nbuilder.Services.AddSwaggerGen();\n\n// Register your domain services — Minimal APIs use the SAME DI system as controllers\nbuilder.Services.AddScoped<IProductService, ProductService>();\n\nvar app = builder.Build();\n\nif (app.Environment.IsDevelopment())\n{\n    app.UseSwagger();\n    app.UseSwaggerUI();\n}\n\napp.UseHttpsRedirection();\n\n// One line per feature area — Program.cs stays readable at any scale\napp.MapProductEndpoints();\n\napp.Run();\n\n// ── Domain models (simplified for clarity) ───────────────────────────────────\npublic record Product(int Id, string Name, decimal Price, int StockCount);\npublic record CreateProductRequest(string Name, decimal Price, int StockCount);\npublic record UpdateProductRequest(string Name, decimal Price, int StockCount);\n\npublic interface IProductService\n{\n    Task<IEnumerable<Product>> GetAllAsync();\n    Task<Product?> GetByIdAsync(int id);\n    Task<Product>  CreateAsync(CreateProductRequest request);\n    Task<bool>     UpdateAsync(int id, UpdateProductRequest request);\n    Task<bool>     DeleteAsync(int id);\n}",
        "output": "// GET /products\nHTTP 200 OK\n[\n  { \"id\": 1, \"name\": \"Widget\", \"price\": 4.99, \"stockCount\": 100 },\n  { \"id\": 2, \"name\": \"Gadget\", \"price\": 19.99, \"stockCount\": 45 }\n]\n\n// GET /products/99 (non-existent)\nHTTP 404 Not Found\n\n// GET /products/abc (route constraint :int fails)\nHTTP 404 Not Found  ← route never matched, no handler runs\n\n// POST /products\nHTTP 201 Created\nLocation: /products/3\n{ \"id\": 3, \"name\": \"Sprocket\", \"price\": 2.49, \"stockCount\": 200 }"
      }

Integrating Authentication and Authorization into Minimal API Routes

Authentication and authorization are first-class concerns in Minimal APIs, and the implementation is almost identical to controller-based APIs. You register the authentication handler (e.g., JWT Bearer), define authorization policies, and apply them to entire route groups or individual endpoints using the .RequireAuthorization() method.

The key difference from controllers is that authorization is applied at the route group or endpoint level rather than at the controller or action level. This aligns perfectly with the Minimal API philosophy of reducing ceremony while retaining power. You can also use endpoint filters for fine-grained access control, though the built-in authorization middleware is usually sufficient.

A common pitfall, as illustrated in the Production Incident section, is placing authentication middleware after endpoint mapping. The pipeline order must be: 1. UseAuthentication() 2. UseAuthorization() 3. Endpoint registration (MapGroup, MapGet, etc.)

Failure to follow this order means the endpoint executes before the auth middleware, so .RequireAuthorization() effectively does nothing.

You can also use authorization policies defined with builder.Services.AddAuthorizationCore(), and apply them by name. For example, a policy that requires the "Admin" role can be applied with .RequireAuthorization("AdminOnly"). Custom policies use the same infrastructure as controllers — there's no second-class handling.

AuthIntegration.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ── Program.cs: Register JWT authentication and authorization policies ─────
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://your-identity-server";
        options.Audience = "products-api";
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireClaim("role", "admin"));
    options.AddPolicy("ReadAccess", policy =>
        policy.RequireAuthenticatedUser()
              .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
});

var app = builder.Build();

// ★ Middleware must come before endpoints ★
app.UseAuthentication();
app.UseAuthorization();

// ── Apply auth to a route group ────────────────────────────────────────────
var adminGroup = app.MapGroup("/admin")
    .RequireAuthorization("AdminOnly");   // All /admin endpoints require Admin role

adminGroup.MapGet("/products", async (IProductService svc) =>
{
    var products = await svc.GetAllAsync();
    return TypedResults.Ok(products);
});

// ── Apply auth to a single endpoint ────────────────────────────────────────
var publicGroup = app.MapGroup("/products");

publicGroup.MapGet("/public", () => "This is public") // No auth
           .AllowAnonymous();

publicGroup.MapGet("/", async (IProductService svc) =>
    {
        var products = await svc.GetAllAsync();
        return TypedResults.Ok(products);
    })
    .RequireAuthorization("ReadAccess");

app.Run();

// ── Integration test example ───────────────────────────────────────────────
// To test authenticated endpoints, create a WebApplicationFactory with a test auth handler
// or generate a real JWT and include it in the HttpClient's DefaultRequestHeaders.
Output
// GET /products/public (no auth required)
HTTP 200 OK
"This is public"
// GET /products (authenticated user with read access)
HTTP 200 OK + list of products
// GET /products without Authorization header
HTTP 401 Unauthorized
// GET /admin/products with a user that has no admin role
HTTP 403 Forbidden
Common Gotcha: AllowAnonymous on a Group
If you call .AllowAnonymous() on a route group that also has .RequireAuthorization(), the permit-all wins for the entire group. To mix auth/no-auth within a group, apply .AllowAnonymous() only on specific endpoints after the group-level .RequireAuthorization().
Production Insight
In production, avoid hard-coding policy names as strings. Define a static class with constants for policy names (e.g., AuthPolicies.AdminOnly). This prevents typos and makes policy renaming easier when auditing security permissions.
Key Takeaway
Minimal APIs use the same authentication and authorization system as controllers. Apply policies at route group or endpoint level. Always place UseAuthentication/UseAuthorization before endpoint registration.

Validation, Error Handling, and Filters — Keeping Handlers Thin

One criticism of Minimal APIs is that validation logic can creep into handlers, making them fat and hard to test. The mature answer is Endpoint Filters — the Minimal API equivalent of Action Filters in MVC. A filter wraps your handler, runs before and after it, and can short-circuit the pipeline by returning early.

For validation, the community-standard library FluentValidation pairs beautifully with a small generic filter. You register your validators in DI, write one filter, and every endpoint that opts in gets automatic 400 responses with structured error details — no validation code inside any handler.

For global error handling, UseExceptionHandler middleware catches unhandled exceptions and returns a structured Problem Details response (RFC 7807) instead of a naked 500. This is critical for APIs because clients need machine-readable error shapes.

The pattern below shows both working together. Notice how the handler itself stays clean — it trusts that by the time it runs, the input is valid. That separation of concerns is what makes Minimal APIs maintainable at scale.

ValidationAndErrorHandling.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ── ValidationFilter.cs ─────────────────────────────────────────────────────
// A generic endpoint filter that validates any request model using FluentValidation.
// Add NuGet: FluentValidation.AspNetCore

using FluentValidation;

public class ValidationFilter<TRequest> : IEndpointFilter
{
    private readonly IValidator<TRequest> _validator;

    public ValidationFilter(IValidator<TRequest> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {\n        // Find the argument that matches our request type in the handler's parameter list\n        var request = context.Arguments\n                             .OfType<TRequest>()\n                             .FirstOrDefault();\n\n        if (request is null)\n            return Results.BadRequest(\"Request body is missing.\");\n\n        var validationResult = await _validator.ValidateAsync(request);\n\n        if (!validationResult.IsValid)\n        {\n            // Return a structured 400 with all validation errors — client-friendly\n            var errors = validationResult.Errors\n                .GroupBy(e => e.PropertyName)\n                .ToDictionary(\n                    group => group.Key,\n                    group => group.Select(e => e.ErrorMessage).ToArray()\n                );\n\n            return Results.ValidationProblem(errors); // Returns RFC 7807 Problem Details\n        }\n\n        // Validation passed — call the next filter or the actual handler\n        return await next(context);\n    }\n}\n\n// ── CreateProductRequestValidator.cs ─────────────────────────────────────────\npublic class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>\n{\n    public CreateProductRequestValidator()\n    {\n        RuleFor(r => r.Name)\n            .NotEmpty().WithMessage(\"Product name is required.\")\n            .MaximumLength(100).WithMessage(\"Name cannot exceed 100 characters.\");\n\n        RuleFor(r => r.Price)\n            .GreaterThan(0).WithMessage(\"Price must be greater than zero.\");\n\n        RuleFor(r => r.StockCount)\n            .GreaterThanOrEqualTo(0).WithMessage(\"Stock count cannot be negative.\");\n    }\n}\n\n// ── How to wire it up in ProductEndpoints.cs ─────────────────────────────────\n// Apply the filter to only the POST endpoint — surgical, not global\ngroup.MapPost(\"/\", CreateProduct)\n     .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();\n     // That's it. The handler stays clean.\n\n// ── Program.cs additions for global error handling ───────────────────────────\nbuilder.Services.AddProblemDetails(); // Registers the RFC 7807 Problem Details formatter\n\n// Register all FluentValidation validators from the current assembly automatically\nbuilder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();\n\n// ...\n\nvar app = builder.Build();\n\n// UseExceptionHandler MUST come before endpoint mapping\n// In .NET 8+, this one-liner handles all unhandled exceptions as Problem Details\napp.UseExceptionHandler();\napp.UseStatusCodePages(); // Turns empty 404/405 responses into Problem Details too\n\n// ── Testing the validation (curl example) ────────────────────────────────────\n// curl -X POST https://localhost:5001/products \\\n//      -H 'Content-Type: application/json' \\\n//      -d '{\"name\": \"\", \"price\": -5, \"stockCount\": -1}'",
        "output": "// POST /products with invalid body\nHTTP 400 Bad Request\nContent-Type: application/problem+json\n\n{\n  \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.5.1\",\n  \"title\": \"One or more validation errors occurred.\",\n  \"status\": 400,\n  \"errors\": {\n    \"Name\": [\"Product name is required.\"],\n    \"Price\": [\"Price must be greater than zero.\"],\n    \"StockCount\": [\"Stock count cannot be negative.\"]\n  }\n}\n\n// Unhandled exception (e.g., DB connection dropped)\nHTTP 500 Internal Server Error\nContent-Type: application/problem+json\n\n{\n  \"type\": \"https://tools.ietf.org/html/rfc9110#section-15.6.1\",\n  \"title\": \"An error occurred while processing your request.\",\n  \"status\": 500\n}"
      }

Minimal APIs vs Controller-Based APIs — When to Choose Which

This is the question that matters most in a real project, and the honest answer is: it depends on what you're building, not on personal preference. Minimal APIs genuinely win for microservices, internal tooling APIs, BFF (Backend for Frontend) layers, and any scenario where startup time, memory footprint, and simplicity are priorities. Controller-based APIs still win for large team codebases where conventions enforce consistency, for APIs that need complex action filters, for apps already built on MVC, and anywhere OData or API versioning via the established libraries is required.

The most important thing to understand is that this isn't an either/or per project. In .NET 6+ you can mix both in the same application. You might use Minimal APIs for your health check endpoint and lightweight query endpoints, while your complex write operations live in a controller with full model validation pipelines.

From .NET 7 onward, the feature gap has closed significantly. Minimal APIs now support filters, output caching, rate limiting, authentication, authorisation, and OpenAPI — the things that kept teams on controllers. The remaining gap is tooling: Swagger attribute decoration and complex versioning scenarios are still smoother on controllers today.

AuthAndRateLimiting.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Demonstrating auth, rate limiting, and output caching on Minimal API endpoints
// These show Minimal APIs can handle production concerns — not just toy examples

// ── Program.cs service registrations ─────────────────────────────────────────
builder.Services.AddAuthentication().AddJwtBearer(); // JWT auth in one line
builder.Services.AddAuthorization();

builder.Services.AddOutputCache(options =>
{
    // Define a named cache policy — endpoints opt in by name
    options.AddPolicy("ProductCachePolicy", policyBuilder =>
        policyBuilder.Expire(TimeSpan.FromSeconds(30))
                     .Tag("products")); // Tag lets us invalidate by group later
});

builder.Services.AddRateLimiter(options =>
{
    // Fixed window: max 100 requests per minute per IP address
    options.AddFixedWindowLimiter("ApiRateLimit", limiterOptions =>
    {\n        limiterOptions.PermitLimit = 100;\n        limiterOptions.Window = TimeSpan.FromMinutes(1);\n        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;\n        limiterOptions.QueueLimit = 5; // Buffer up to 5 excess requests, then 503\n    });

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

// ── app middleware — ORDER MATTERS ────────────────────────────────────────────
app.UseAuthentication(); // Must come before UseAuthorization
app.UseAuthorization();
app.UseOutputCache();
app.UseRateLimiter();

// ── Endpoint with all production concerns attached ───────────────────────────
var group = app.MapGroup("/products")
               .WithTags("Products")
               .RequireAuthorization()        // Every endpoint in this group needs a valid JWT
               .RequireRateLimiting("ApiRateLimit"); // Apply rate limit to the whole group

// This specific endpoint also uses output caching — expensive DB read cached for 30s
group.MapGet("/", async (IProductService productService) =>
    {
        var products = await productService.GetAllAsync();
        return TypedResults.Ok(products);
    })
    .CacheOutput("ProductCachePolicy"); // Only GET /products is cached, not the whole group

// Cache invalidation when a product is created
group.MapPost("/", async (\n    CreateProductRequest request,\n    IProductService productService,\n    IOutputCacheStore cacheStore,          // Inject the cache store directly\n    CancellationToken cancellationToken) =>
{
    var created = await productService.CreateAsync(request);

    // Invalidate all cached responses tagged "products" so GET / reflects the new item
    await cacheStore.EvictByTagAsync("products", cancellationToken);

    return TypedResults.Created($"/products/{created.Id}", created);
})
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Output
// GET /products (first call — cache miss, hits DB)
HTTP 200 OK
Cache-Control: public, max-age=30
[ ... products ... ]
// GET /products (within 30s — cache hit, no DB call)
HTTP 200 OK
Age: 12
[ ... products ... ] ← served from memory in ~0.1ms
// GET /products (101st request in 1 minute from same IP)
HTTP 429 Too Many Requests
Retry-After: 43
Interview Gold: The Startup Performance Difference
In benchmarks with a simple CRUD API, Minimal APIs typically start 20-40% faster than the equivalent controller-based API because they skip MVC's controller discovery and action descriptor population at startup. In AWS Lambda or Azure Container Apps where cold starts are billed, this translates directly to cost savings.
Production Insight
Mixing Minimal and controller-based APIs in one project is possible but creates two mental models.
Your team must know both styles — onboarding new devs is harder.
Be deliberate: pick one as the primary pattern and use the other only when the feature gap forces it.
Key Takeaway
Minimal APIs: microservices, BFFs, serverless.
Controller APIs: large teams, complex versioning, existing MVC apps.
Mix them when needed, but standardize on one pattern per project.

Advantages and Disadvantages of Minimal APIs

Choosing between Minimal APIs and controller-based APIs is a trade-off, not a universal recommendation. Below is a balanced comparison of the pros and cons to help you decide based on your project's requirements, team size, and long-term maintainability.

AdvantageWhy It Matters
Faster startup and lower memory footprintCritical for serverless and containerized environments where cold-start costs are real. A 30-50% faster startup can reduce infrastructure bills.
Less boilerplateYou can build a functional endpoint in 3-5 lines of code. This accelerates prototyping and microservice development.
Compile-time source generationParameter binding is generated at compile time, not via reflection. This improves runtime performance and reduces memory allocations.
Excellent unit testabilityHandlers are static methods returning TypedResults — easy to test without spinning up a test server.
Route groups with shared middlewareMapGroup allows applying auth, rate limiting, and validation to a group of endpoints without code duplication.
Supports all modern ASP.NET Core featuresAuthentication, authorization, output caching, rate limiting, OpenAPI, health checks — all available.
DisadvantageWhy It Matters
Less structure for large teamsWithout controllers, there's no enforced folder or naming convention. Teams need discipline and code reviews to keep code organized.
API versioning is less matureThe Asp.Versioning.Http library works with Minimal APIs but requires more setup than the controller attribute versioning.
Complex action filters are harderEndpoint filters are simpler but don't support the full action filter pipeline (e.g., OnActionExecuting/Acting). For complex cross-cutting concerns, filters may need more work.
Tooling for OpenAPI attributesController-based APIs can use [ProducesResponseType] and [SwaggerOperation] attributes extensively; Minimal APIs rely more on .WithOpenApi() and TypedResults, which may not cover all edge cases.
Learning curve for new .NET developersDevelopers familiar with MVC may find Minimal APIs' lambda style disorienting at first, though the learning curve is short.
Not suitable for very complex APIsIf your API has dozens of endpoints with intricate routing, multiple versioning strategies, and heavy use of action filters, controllers may still be the better choice.

When to choose Minimal APIs: Microservices, BFF layers, internal CRUD tools, serverless functions, health checks, and any scenario where developer velocity is more important than strict conventions.

When to choose Controller APIs: Large enterprise applications with multiple teams, APIs requiring strict API versioning (e.g., OData), migration from legacy MVC projects, or when you need the full action filter pipeline.

Reality check: Most projects can start with Minimal APIs and graduate to controllers only when the feature gap becomes painful. Many successful production APIs use a hybrid approach.

Hybrid Architecture: Best of Both Worlds
There's no rule that says you must pick one pattern for the entire application. You can have a controller for a complex orders endpoint and Minimal API endpoints for simple list queries and health checks. The DI container and middleware pipeline are shared, so mixing works seamlessly. Just ensure consistency in error handling and logging.
Production Insight
In production, the biggest risk with Minimal APIs is not the technology but the team's adherence to patterns. Without conventions, different developers may write endpoints differently, leading to inconsistent error handling, logging, and authentication. Address this with mandatory code reviews and a shared style guide.
Key Takeaway
Minimal APIs offer significant advantages for speed and simplicity but trade away some structure. Choose based on your team size, application complexity, and deployment environment. A hybrid approach is often the pragmatic choice.

Testing Minimal APIs — Unit Tests Without a Test Server

One of the hidden advantages of Minimal APIs is that your handlers become testable static methods when you extract them into extension classes. Because we used TypedResults, each handler returns a strongly-typed result object like Ok<Product> or Created<Product>. In a unit test, you can mock the injected services, call the handler directly, and assert the exact HTTP status code without spinning up a test server.

For integration tests, ASP.NET Core's WebApplicationFactory works the same as with controllers. You point it at your Minimal API project and call endpoints via an HttpClient. The middleware pipeline runs fully — including auth, rate limiting, and filters — so you can test the entire request/response cycle.

The key difference from controller integration tests: you need to ensure the Program.cs class is accessible. The common pattern is to use a public partial class for Program (e.g., public partial class Program { } at the end of Program.cs) or reference the project via InternalsVisibleTo.

MinimalApiTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// ── Unit test for GetAllProducts ─────────────────────────────────────────────
using Microsoft.AspNetCore.Http.HttpResults;

[Fact]
public async Task GetAllProducts_ReturnsOkWithList()
{
    // Arrange
    var mockService = new Mock<IProductService>();
    var expectedProducts = new List<Product>
    {
        new(1, "Widget", 4.99m, 100),
        new(2, "Gadget", 19.99m, 45)
    };
    mockService.Setup(s => s.GetAllAsync()).ReturnsAsync(expectedProducts);

    // Act — call the static handler directly
    var result = await ProductEndpoints.GetAllProducts(mockService.Object);

    // Assert — TypedResults gives us compile-time access to the status and value
    var okResult = Assert.IsType<Ok<IEnumerable<Product>>>(result);
    Assert.Equal(expectedProducts, okResult.Value); // Value equality on records
}

// ── Integration test using WebApplicationFactory ──────────────────────────────
// Requires: add <InternalsVisibleTo Include="YourTestProject" /> to .csproj
// or declare public partial class Program { } at end of Program.cs

public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsSuccessWithJson()
    {
        var response = await _client.GetAsync("/products");
        response.EnsureSuccessStatusCode();
        var products = await response.Content.ReadFromJsonAsync<IEnumerable<Product>>();
        Assert.NotEmpty(products);
    }

    [Fact]
    public async Task CreateProduct_WithInvalidData_ReturnsValidationProblem()
    {
        var invalid = new { Name = "", Price = -5m, StockCount = -1 };
        var content = JsonContent.Create(invalid);
        var response = await _client.PostAsync("/products", content);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.Contains("Name", problem.Errors.Keys);
    }
}
Output
// Unit test result: Passed
// Integration test result: Passed
// When auth is enabled, the integration test will fail with 401 if no token is attached.
// To test authenticated endpoints, use factory.AuthenticatedClient() (custom WebApplicationFactory pattern).
Pro Tip: Expose Program for Testing
The simplest way to make your Minimal API testable with WebApplicationFactory is to add a single line at the bottom of Program.cs: public partial class Program { }. This gives the test project visibility without exposing internal implementation details. Alternatively, use InternalsVisibleTo in the .csproj.
Production Insight
Unit testing handlers directly catches logic errors missed in integration tests.
But integration tests catch middleware order issues (auth, logging) that unit tests miss.
Run both in CI — unit tests in every commit, integration tests before deploy.
Key Takeaway
Static handlers with TypedResults make unit testing easy — no WebApplicationFactory needed.
Use WebApplicationFactory for end-to-end tests that cover the full pipeline.
Expose Program via partial class for test project access.

Practice Exercises — Build Your Own Minimal API

The best way to internalise Minimal APIs is to build one from scratch. The following three exercises progress from basic CRUD to auth-protected endpoints and API versioning. Each exercise builds on the previous one, so you'll end with a production-like API.

### Exercise 1: CRUD Task Manager API Build a Minimal API for managing tasks. Each task has an Id, Title, IsComplete flag, and CreatedAt timestamp. - Implement GET /tasks, GET /tasks/{id}, POST /tasks, PUT /tasks/{id}, DELETE /tasks/{id}. - Validate that Title is required and between 1 and 200 characters. - Use a static in-memory list as the data store (no database). - Return appropriate HTTP status codes: 201 for create, 204 for update/delete, 404 for not found. - Use TypedResults. - Add OpenAPI support with .WithOpenApi() on the route group.

Hints: Use app.MapGroup("/tasks") and create a TaskEndpoints extension class. Use a List<TaskItem> with a simple TaskService class registered as singleton. For validation, use a ValidationFilter<TaskRequest> endpoint filter with FluentValidation.

Expected outcome: A fully functional CRUD API that you can test with curl or Swagger UI.

### Exercise 2: Add Authentication and Authorization Extend the Task Manager API from Exercise 1 to require authentication. - Add JWT Bearer authentication (you can use a test issuer like dotnet user-jwts tool or configure a simple test identity). - Create two authorization policies: UserPolicy (any authenticated user) and AdminPolicy (requires claim role: admin). - Apply AdminPolicy to DELETE /tasks endpoints — only admins can delete tasks. - Apply UserPolicy to all other endpoints. - Add a public health check endpoint GET /health that requires no authentication. - Write at least one integration test using WebApplicationFactory that verifies a 401 response when the Authorization header is missing.

Hints: Use dotnet user-jwts create to generate a test token. For integration tests, create a custom WebApplicationFactory that sets environment to "Test" and configures a test authentication scheme (e.g., with AddAuthentication().AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null)).

Expected outcome: Your API now enforces authentication and role-based authorization on specific endpoints.

### Exercise 3: API Versioning with Route Groups Extend the Task Manager API to support two versions: v1 and v2. - v1: Original endpoints as built in Exercise 2. - v2: Change the response shape: include a Priority field and rename IsComplete to Done. v1 must still return the original shape. - Use route groups with version prefixes: /api/v1/tasks and /api/v2/tasks. - Share the service (TaskService) between both versions, but create separate endpoint extension methods for each version. - Add a version header X-API-Version: 2 to indicate v2 responses.

Hints: Use two MapGroup calls: app.MapGroup("/api/v1/tasks") and app.MapGroup("/api/v2/tasks"). Implement separate handler methods for v2 that return a different response record. The service layer can remain unified; mapping logic happens in the endpoint layer.

Expected outcome: Your API supports two concurrent versions with different response schemas, demonstrating how Minimal APIs can handle versioning without libraries.

For all exercises, use the patterns established in this article: TypedResults, endpoint filters for validation, middleware ordering, and clean extension method organisation.

Exercise1_Start.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Starter template for Exercise 1 — a basic task manager Minimal API

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<TaskStore>(); // In-memory store

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapTaskEndpoints();  // You will implement this

app.Run();

// You'll need: TaskItem record, TaskStore class, TaskEndpoints static class, validation filter
public record TaskItem(int Id, string Title, bool IsComplete, DateTime CreatedAt);
public record CreateTaskRequest(string Title);
public record UpdateTaskRequest(string Title, bool IsComplete);

public class TaskStore
{\n    private readonly List<TaskItem> _tasks = new();\n    private int _nextId = 1;\n\n    public TaskItem Add(string title) { /* implement */ }
    public IEnumerable<TaskItem> GetAll() => _tasks;
    public TaskItem? GetById(int id) => _tasks.FirstOrDefault(t => t.Id == id);
    public bool Update(int id, UpdateTaskRequest req) { /* implement */ }
    public bool Delete(int id) { /* implement */ }
}
Output
// After implementing, you should be able to run and test:
// curl http://localhost:5000/tasks → []
// curl -X POST -H "Content-Type: application/json" -d '{"title":"Learn Minimal APIs"}' http://localhost:5000/tasks
// → 201 Created with TaskItem
// curl http://localhost:5000/tasks/1 → 200 OK with the task
// curl -X DELETE http://localhost:5000/tasks/1 → 204 No Content
Stuck? Check the Article's Code Samples
The exercises are designed to reuse the patterns shown earlier in this article. Refer to the "ProductEndpoints.cs" pattern for route groups, the "ValidationFilter" for validation, and the "AuthIntegration" section for authentication. If you're stuck on a specific aspect, revisit the relevant section.
Production Insight
In production, versioning via route groups is simple but can become messy with many versions. Consider using the Asp.Versioning.Http library for advanced versioning scenarios, but start with route groups for small APIs. The key is to keep versioning clean and retire old versions aggressively to reduce maintenance burden.
Key Takeaway
Practice by building a CRUD API, then add auth and versioning. Each exercise reinforces the core patterns of Minimal APIs: route groups, filters, TypedResults, and clean separation of concerns.
● Production incidentPOST-MORTEMseverity: high

Missing Authentication Middleware Exposes Endpoints

Symptom
All /products endpoints return data without requiring a valid JWT, even though the route group has .RequireAuthorization().
Assumption
Calling .RequireAuthorization() on the route group is sufficient to enforce authentication.
Root cause
The app.UseAuthentication() and app.UseAuthorization() calls were placed after the call to app.MapProductEndpoints(). Auth middleware never ran on the endpoint pipeline because endpoint middleware runs before auth when registered later.
Fix
Move UseAuthentication() and UseAuthorization() before MapProductEndpoints(). The correct order in Program.cs is: builder.Build(); then app.UseAuthentication(); app.UseAuthorization(); then app.MapProductEndpoints(); then app.Run();
Key lesson
  • Middleware order in Program.cs is absolute — auth middleware must be registered before endpoint routing.
  • Always place UseAuthentication before UseAuthorization, and both before any endpoint mapping.
  • Use WebApplicationFactory integration tests to verify auth enforcement across all endpoints - don't rely on manual curl checks.
Production debug guideCommon issues you'll hit in production and how to fix them fast.4 entries
Symptom · 01
Endpoint returns 404 when it should exist
Fix
Check the route prefix in MapGroup matches the request URL. Verify route constraints like :int are correct for the parameter types.
Symptom · 02
POST /products returns 400 with no body
Fix
Ensure Content-Type: application/json header is set. Check that the request model has a parameterless constructor. Verify that System.Text.Json can deserialize the JSON.
Symptom · 03
Swagger UI shows no endpoints
Fix
Call AddEndpointsApiExplorer() in service registration. Ensure UseSwaggerUI() is called after UseSwagger() in the middleware pipeline. Add .WithOpenApi() to the group or endpoint.
Symptom · 04
Validation errors not returned as RFC 7807 Problem Details
Fix
Call builder.Services.AddProblemDetails() and app.UseStatusCodePages(). Ensure the ValidationFilter returns ValidationProblem(errors) rather than BadRequest.
★ Minimal API Quick Debug ChecklistDiagnose and fix the most common Minimal API production failures with these targeted commands.
All endpoints return 404
Immediate action
Check route registration and middleware order
Commands
dotnet run --environment Development
curl -v http://localhost:5000/products
Fix now
Ensure app.MapGet() or MapPost() calls are present before app.Run() and after the middleware pipeline. Use app.UseRouting() is not needed - Minimal APIs wire routing internally.
Validation errors are not returned as problem details+
Immediate action
Ensure AddProblemDetails and UseStatusCodePages are wired
Commands
curl -X POST -H 'Content-Type: application/json' -d '{"name":""}' http://localhost:5000/products
Check response headers for Content-Type: application/problem+json
Fix now
Call builder.Services.AddProblemDetails() and app.UseStatusCodePages() in the middleware pipeline before endpoint mapping.
DI error: cannot resolve IProductService+
Immediate action
Verify service registration order in Program.cs
Commands
dotnet build (catches missing DI references)
Check builder.Services.AddScoped<IProductService, ProductService>() is before builder.Build()
Fix now
Register the service before calling builder.Build(). Use AddValidatorsFromAssemblyContaining for validators.
🔥

That's ASP.NET. Mark it forged?

13 min read · try the examples if you haven't

Previous
Blazor Basics
9 / 14 · ASP.NET
Next
gRPC with ASP.NET Core