Mid-level 23 min · March 06, 2026
Minimal APIs in ASP.NET Core

Minimal APIs — Missing Auth Middleware Exposes Endpoints

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

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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
  • Production risk: misordered middleware (auth after endpoints) silently exposes all routes, undetected by standard unit tests
✦ Definition~90s read
What is Minimal APIs in ASP.NET Core?

Minimal APIs, introduced in .NET 6, are a lightweight alternative to ASP.NET Core's traditional controller-based architecture. They strip away the ceremony of controllers, action filters, model binding attributes, and the entire ControllerBase class hierarchy, letting you define HTTP endpoints as simple lambda expressions or static methods.

Imagine you run a food truck.

The core idea is that for simple CRUD services, health checks, or microservice endpoints, you don't need the full MVC machinery — you just need a function that takes a request and returns a response. Under the hood, Minimal APIs still use the same Kestrel server, middleware pipeline, and dependency injection container as full MVC; they're just a thinner programming model on top of that infrastructure.

The trade-off is that Minimal APIs deliberately omit several features that controllers provide out of the box. There's no automatic model validation via [ApiController] attributes, no built-in authorization filters, and no convention-based routing with [HttpGet] or [Authorize] attributes.

Authentication and authorization must be wired up manually using the middleware pipeline (app.UseAuthentication(), app.UseAuthorization()) and then applied per-route via RequireAuthorization() calls or custom filter factories. This is where teams commonly get burned: because the endpoint definition looks so simple — just app.MapGet("/data", handler) — it's easy to forget that authorization isn't automatically enforced unless you explicitly add it.

In production, this has led to endpoints being deployed without any auth checks, exposing internal APIs to the public.

When should you use Minimal APIs? They're ideal for small services, internal APIs, or prototypes where you want minimal boilerplate. For large applications with dozens of endpoints, complex authorization policies, or extensive model validation, the controller-based approach still wins because it provides conventions and attributes that make security requirements visible at the class and method level.

The key insight is that Minimal APIs don't eliminate the need for security — they just shift the responsibility from declarative attributes to explicit middleware calls, which is easier to miss during code reviews. If you're building a public-facing API with Minimal APIs, you need to be deliberate about every route's auth requirements, or use route groups to apply policies consistently.

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.

What Minimal APIs Actually Are — And What They Don't Do

Minimal APIs are a lightweight hosting model in ASP.NET Core that lets you define HTTP endpoints with minimal ceremony. Instead of controllers, actions, and attributes, you map lambdas directly to routes using app.MapGet(), app.MapPost(), etc. The core mechanic is a single WebApplication builder that wires middleware, routing, and configuration in a linear, top-down pipeline — no implicit conventions, no reflection-heavy discovery.

In practice, this means you define endpoints as inline delegates or static methods, and the framework compiles them into a request delegate tree at startup. There's no controller activation, no model binding via attributes (unless you opt in), and no built-in authorization filter pipeline. The middleware you add — or forget to add — is the only security boundary. If you don't call app.UseAuthorization(), every endpoint is public, regardless of [Authorize] attributes or policy names you might sprinkle on lambdas.

Use Minimal APIs for small services, health checks, or prototypes where the overhead of controllers isn't justified. But the moment you need authentication or authorization, you must explicitly add the middleware. Teams often assume that because they added AddAuthentication() and AddAuthorization() in DI, the middleware is active — it's not. The middleware must be registered in the pipeline, and its position relative to routing matters. This is where production incidents start.

Middleware Order Is Not Optional
UseAuthentication() and UseAuthorization() must appear between UseRouting() and UseEndpoints() — otherwise, requests bypass auth entirely.
Production Insight
A team deployed a public-facing API with AddJwtBearer() in DI but forgot app.UseAuthentication(). All endpoints returned 200 with data, even without tokens.
Symptom: no 401s, no 403s — every request succeeded, and logs showed zero auth failures.
Rule: always add app.UseAuthentication() and app.UseAuthorization() immediately after app.UseRouting(), and verify with a curl call that omits the token.
Key Takeaway
Minimal APIs do not auto-wire auth middleware — you must call UseAuthentication() and UseAuthorization() explicitly.
Middleware order is critical: auth must sit between routing and endpoints.
If you see no 401s in production, check your pipeline — not your policies.
Minimal API Security: Missing Auth Middleware THECODEFORGE.IO Minimal API Security: Missing Auth Middleware Flow from endpoint creation to auth exposure risk Minimal API Endpoint MapGet/MapPost with lambda handler No Auth Middleware Missing UseAuthentication/UseAuthorization Public Exposure All endpoints accessible without credentials Auth Middleware Added UseAuthentication + UseAuthorization in pipeline Secure Endpoint Requires valid token/claims for access ⚠ Forgetting auth middleware in Minimal APIs Always add UseAuthentication and UseAuthorization before endpoints THECODEFORGE.IO
thecodeforge.io
Minimal API Security: Missing Auth Middleware
Minimal Apis Aspnet Core

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
45
// 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 }
// HTTP 201 Created
// Location: /products/42
{
"id": 42,
"name": "Widget",
"price": 4.99
}
Why Records?
Using C# record types for request/response models isn't just style — records are immutable by default, serialize cleanly with System.Text.Json, and give you structural equality without writing Equals() overrides. They're the idiomatic choice for Minimal API DTOs.
Production Insight
Using Results.Ok() vs TypedResults.Ok<T>() is a common trap.
Results.Ok() returns an anonymous object — OpenAPI can't generate a schema, so Swagger shows 'any'.
Switch to TypedResults.Ok<Product>() for compile-time type safety and accurate docs.
Key Takeaway
Minimal APIs skip MVC's controller discovery — that's a real performance win.
Use records for DTOs, TypedResults for responses.
Order matters in Program.cs: services, build, middleware, endpoints, run.

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
16
17
18
19
20
21
22
23
24
25
// 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
{
    var result = await productService.CheckInventoryAsync(
        categoryId, filter, request, tenantId, cancellationToken);

    var location = linkGenerator.GetPathByName(httpContext, "GetProductById", new { id = result.Id });
    return TypedResults.Created(location, result);
});

// Validation: If you send a non-integer categoryId, the framework returns 400 automatically
// If you omit the body, it also returns 400 with validation details

public record CheckInventoryRequest(string Sku, int Quantity);
Output
// Valid request:
// POST /products/12/check?filter=active HTTP/1.1
// Content-Type: application/json
// X-Tenant-Id: acme-corp
//
// { "sku": "WIDGET-001", "quantity": 5 }
//
// HTTP 201 Created
// Location: /products/42
// Invalid route:
// POST /products/abc/check ⇒ 400 Bad Request (cannot parse 'abc' as int)
// Missing body:
// POST /products/1/check ⇒ 400 with ProblemDetails (request body missing)
Complex Types from Query String? Use [AsParameters]
If you want to bind a complex type from the query string (e.g., multiple filter parameters), decorate the parameter with [AsParameters]. This tells the source generator to treat each property of the type as an individual query string value, avoiding the need to add many individual parameters to the handler.
Production Insight
The source-generated parameter binder is a key differentiator for cold start performance.
Every millisecond matters when a new container spins up during traffic spikes.
Rule: Minimal APIs can start 30-50ms faster than equivalent controller APIs in serverless environments.
Key Takeaway
Parameter binding is automatic but follows clear rules: simple types from route/query, complex from body, services from DI. Use attributes to override defaults. The source generator makes binding fast and reduces startup overhead.

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// ── 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(
        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(
        int id,
        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)
    {
        var created = await productService.CreateAsync(request);

        // Generate the Location header pointing to the new resource — correct REST
        var location = linkGenerator.GetPathByName(httpContext, "GetProductById",
                                                   new { id = created.Id });
        return TypedResults.Created(location, created);
    }

    static async Task<Results<NoContent, NotFound>> UpdateProduct(
        int id,
        UpdateProductRequest request,
        IProductService productService)
    {
        var success = await productService.UpdateAsync(id, request);
        return success ? TypedResults.NoContent() : TypedResults.NotFound();
    }

    static async Task<Results<NoContent, NotFound>> DeleteProduct(
        int id,
        IProductService productService)
    {
        var success = await productService.DeleteAsync(id);
        return success ? TypedResults.NoContent() : TypedResults.NotFound();
    }
}

// ── Program.cs (the clean version) ────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register your domain services — Minimal APIs use the SAME DI system as controllers
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

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

app.UseHttpsRedirection();

// One line per feature area — Program.cs stays readable at any scale
app.MapProductEndpoints();

app.Run();

// ── Domain models (simplified for clarity) ─────────────────────────────────────
public record Product(int Id, string Name, decimal Price, int StockCount);
public record CreateProductRequest(string Name, decimal Price, int StockCount);
public record UpdateProductRequest(string Name, decimal Price, int StockCount);

public interface IProductService
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product?> GetByIdAsync(int id);
    Task<Product>  CreateAsync(CreateProductRequest request);
    Task<bool>     UpdateAsync(int id, UpdateProductRequest request);
    Task<bool>     DeleteAsync(int id);
}
Output
// GET /products
HTTP 200 OK
[
{ "id": 1, "name": "Widget", "price": 4.99, "stockCount": 100 },
{ "id": 2, "name": "Gadget", "price": 19.99, "stockCount": 45 }
]
// GET /products/99 (non-existent)
HTTP 404 Not Found
// GET /products/abc (route constraint :int fails)
HTTP 404 Not Found ← route never matched, no handler runs
// POST /products
HTTP 201 Created
Location: /products/3
{ "id": 3, "name": "Sprocket", "price": 2.49, "stockCount": 200 }
Pro Tip: Use TypedResults, Not Results
TypedResults.Ok<T>() returns a strongly-typed result object. This means your return type is part of the method signature, which lets ASP.NET Core generate accurate OpenAPI schemas without any extra attributes. If you use the untyped Results.Ok(), the schema shows up as 'any' in Swagger. TypedResults also makes your handlers unit-testable — you can assert the exact HTTP status in a unit test without spinning up a test server.
Production Insight
In production, mixing services registered in DI with route parameters can cause ambiguity.
If a service type matches a route parameter type (both int, for example), ASP.NET Core may resolve the wrong one.
Rule: use distinct types for IDs (e.g., record ProductId) to avoid DI collisions.
Key Takeaway
MapGroup + extension methods keep Program.cs clean beyond 10 endpoints.
DI works the same as controllers — declare parameters, framework resolves.
Use TypedResults for compile-time safety and accurate Swagger docs.

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
52
// ── 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
Avoid hard-coding policy names as strings in production.
Define a static class with constants for policy names (e.g., AuthPolicies.AdminOnly).
This prevents typos and simplifies security audits when renaming policies.
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
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// ── 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)
    {
        // Find the argument that matches our request type in the handler's parameter list
        var request = context.Arguments
                             .OfType<TRequest>()
                             .FirstOrDefault();

        if (request is null)
            return Results.BadRequest("Request body is missing.");

        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            // Return a structured 400 with all validation errors — client-friendly
            var errors = validationResult.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(
                    group => group.Key,
                    group => group.Select(e => e.ErrorMessage).ToArray()
                );

            return Results.ValidationProblem(errors); // Returns RFC 7807 Problem Details
        }

        // Validation passed — call the next filter or the actual handler
        return await next(context);
    }
}

// ── CreateProductRequestValidator.cs ───────────────────────────────────────────
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(r => r.Name)
            .NotEmpty().WithMessage("Product name is required.")
            .MaximumLength(100).WithMessage("Name cannot exceed 100 characters.");

        RuleFor(r => r.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero.");

        RuleFor(r => r.StockCount)
            .GreaterThanOrEqualTo(0).WithMessage("Stock count cannot be negative.");
    }
}

// ── How to wire it up in ProductEndpoints.cs ───────────────────────────────────
// Apply the filter to only the POST endpoint — surgical, not global
group.MapPost("/", CreateProduct)
     .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
     // That's it. The handler stays clean.

// ── Program.cs additions for global error handling ─────────────────────────────
builder.Services.AddProblemDetails(); // Registers the RFC 7807 Problem Details formatter

// Register all FluentValidation validators from the current assembly automatically
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();

// ...

var app = builder.Build();

// UseExceptionHandler MUST come before endpoint mapping
// In .NET 8+, this one-liner handles all unhandled exceptions as Problem Details
app.UseExceptionHandler();
app.UseStatusCodePages(); // Turns empty 404/405 responses into Problem Details too

// ── Testing the validation (curl example) ──────────────────────────────────────
// curl -X POST https://localhost:5001/products \
//      -H 'Content-Type: application/json' \
//      -d '{"name": "", "price": -5, "stockCount": -1}'
Output
// POST /products with invalid body
HTTP 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Product name is required."],
"Price": ["Price must be greater than zero."],
"StockCount": ["Stock count cannot be negative."]
}
}
// Unhandled exception (e.g., DB connection dropped)
HTTP 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
Watch Out: Filter Registration Order
Endpoint filters run in the order you chain .AddEndpointFilter() calls. If you add an authentication filter AND a validation filter, put authentication first. Validating a request body before confirming the caller is authenticated wastes CPU and can leak information about your schema to unauthenticated users.
Production Insight
Missing AddProblemDetails() means unhandled exceptions return plain 500 with no body.
Clients get no machine-readable error — debugging becomes manual log digging.
Always wire app.UseExceptionHandler() and app.UseStatusCodePages() early in the pipeline.
Key Takeaway
ValidationFilter + FluentValidation keeps handlers clean.
UseProblemDetails() from .NET 8+ gives RFC 7807 errors automatically.
Filter order matters: auth before validation.

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
56
57
58
59
60
61
62
63
64
// 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 =>
    {
        limiterOptions.PermitLimit = 100;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5; // Buffer up to 5 excess requests, then 503
    });

    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 (
    CreateProductRequest request,
    IProductService productService,
    IOutputCacheStore cacheStore,          // Inject the cache store directly
    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
The biggest risk with Minimal APIs is team adherence to patterns.
Without conventions, endpoints become inconsistent in error handling and logging.
Mitigate 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
32
33
34
35
// 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
{
    private readonly List<TaskItem> _tasks = new();
    private int _nextId = 1;

    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
Versioning via route groups is simple but can become messy with many versions.
For advanced scenarios, consider the Asp.Versioning.Http library.
Key: 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.

Common Mistakes with Minimal APIs in Production

Here are the mistakes that cause real production incidents — and how to fix them before they become pager alerts.

Mistake 1: Authentication middleware after endpoint mapping Symptom: Endpoints marked with .RequireAuthorization() work without any token. The endpoint runs before auth middleware is reached. Fix: Move app.UseAuthentication() and app.UseAuthorization() to appear before any MapGroup or MapGet calls. The correct order in Program.cs is: builder.Build()UseAuthentication()UseAuthorization() → endpoint registration.

Mistake 2: Missing AddProblemDetails leads to silent 500 errors Symptom: Unhandled exceptions return a plain 500 with no body. Clients can't parse the error, and debugging requires log access. Fix: Call builder.Services.AddProblemDetails() and app.UseExceptionHandler() in the middleware pipeline. This gives you RFC 7807 Problem Details responses automatically.

Mistake 3: Using Results.Ok() instead of TypedResults<T> Symptom: Swagger UI shows "any" as the response type. OpenAPI clients can't generate proper types. Fix: Always use TypedResults.Ok<T>() for endpoints returning data. The type parameter gives compile-time safety and accurate OpenAPI schemas.

Production Insight
The auth middleware ordering mistake is the most dangerous because it's silent.
Your endpoint works in development, passes integration tests (if you don't test auth), and exposes data in production.
Always add an integration test that verifies a request without a token returns 401.
Key Takeaway
Three mistakes cause 90% of Minimal API production incidents:
Auth middleware order → data exposure.
Missing AddProblemDetails → silent client failures.
Un-typed Results → broken API docs.
Fix them once, verify with tests.

Minimal API Interview Questions — Junior to Senior

Here are the questions that separate engineers who've only read about Minimal APIs from those who've shipped them.

Junior Level Question What is the simplest way to create a GET endpoint in a Minimal API? Answer: Call app.MapGet("/route", handler) in Program.cs. The handler is a lambda or method that returns the response.

Mid-Level Question How do you apply authorization to a group of endpoints without repeating code? Answer: Use a RouteGroupBuilder returned by app.MapGroup("/prefix") and call .RequireAuthorization(policyName) on it. All endpoints added to that group automatically require the specified policy.

Senior Level Question Explain how the source-generated parameter binder works and why it improves cold start performance. Answer: At compile time, the source generator emits code for each endpoint that reads parameters directly from the request (route, query, body) based on their types and names. This replaces runtime reflection used in controller-based APIs, reducing startup time by 20-40% and eliminating JIT compilation overhead for parameter binding. In serverless environments, this directly reduces cold start latency.

Production Insight
The senior interview question tests understanding beyond syntax.
The source generator is the key differentiator.
Articulating compile-time vs runtime trade-off shows performance awareness.
Key Takeaway
Interview depth: Junior knows syntax. Mid-level knows structure. Senior knows the underlying mechanism (source generation) and its impact on cold starts.

Migrating from Controller-Based APIs to Minimal APIs

Migrating an existing controller-based API to Minimal APIs is not a rewrite - it's a refactor of the endpoint layer. The service layer, repository, and domain models stay untouched. You replace controllers and action methods with extension methods and route groups. The migration can be done incrementally: start with a single endpoint to prove the pattern, then gradually move others.

Key steps: 1. Create a new extension class (e.g., ProductEndpoints) with static methods. 2. In Program.cs, call app.MapProductEndpoints() and remove the controller route registration. 3. Keep the same DI registrations - they work for both. 4. Replace IActionResult with TypedResults for compile-time safety. 5. Update integration tests to target Minimal API endpoints.

The biggest risk during migration is middleware order. Controller-based APIs often rely on global filters for validation and exception handling, while Minimal APIs use endpoint filters and explicit middleware. Ensure that UseAuthentication, UseAuthorization, and UseExceptionHandler are correctly ordered in Program.cs.

Production Insight
In production, migrating incrementally reduces risk. Run both controller and Minimal API endpoints side-by-side during the transition. Use feature flags to route traffic to the new implementation until all endpoints are migrated.
Key Takeaway
Migration is a layer refactor, not a rebuild. Keep the same DI, service layer, and domain models. Incremental migration with side-by-side endpoints reduces deployment risk.

Minimal APIs and the Pipeline: Where Your Code Actually Runs

Most devs treat app.MapGet() like black magic. It's not. Every endpoint is middleware. The RequestDelegate you pass gets wrapped in a RouteEndpoint and inserted into the pipeline at the exact position you called it — after UseRouting(), before UseEndpoints() by default.

Why this matters for production? Order of operations isn't academic. If you register exception middleware after your endpoints, you're toast. Errors bubble up but don't get caught. I've debugged silent 500s at 2 AM because someone moved UseExceptionHandler below the MapGet calls.

Here's the concrete pattern: Register all cross-cutting middleware before ANY Map* call. Then chain your groups. If you need middleware per-group, use RequireAuthorization() or custom IApplicationBuilder extensions inside the group pipeline. It's not MVC. You don't get filters for free. You get the raw pipeline. Own it.

PipelineOrder.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
// io.thecodeforge — csharp tutorial

var app = WebApplication.Create(args);

// Global middleware MUST be here
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();

// Now endpoints — they share the pipeline context
var api = app.MapGroup("/api/inventory");
api.MapGet("/", async (InventoryDb db) =>
    await db.Products.ToListAsync());

api.MapPost("/", async (Product product, InventoryDb db) =>
{
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/api/inventory/{product.Id}", product);
});

// oops — this custom middleware runs AFTER endpoints, useless
app.Use(async (ctx, next) => {
    // too late, already matched
    await next();
});

app.Run();
Output
(no output — demonstrates middleware ordering during startup)
Pipeline Gotcha:
If you put UseExceptionHandler after your Map calls, you're swallowing exceptions into middleware never-land. Always register error handling, auth, and CORS before any route definition.
Key Takeaway
Minimal API endpoints ARE middleware. Their position in Startup.cs determines when they run. Register crosscutting concerns first.

Why `UseStaticFiles` and `MapGet` Fight Each Other — And How to Win

You'd think serving an index.html alongside your /api/users endpoint is trivial. It is, until you profile and realize every static file request is hitting your middleware stack for nothing. Static files should short-circuit. They don't if you order them wrong.

Here's the problem: app.UseStaticFiles() is middleware. If you put it after your auth middleware, every .js, .css, and favicon request runs through JWT validation first. That's CPU waste at scale. I saw a client's API latency double because their CDN was hitting authenticated static endpoints.

Fix: Call UseStaticFiles() BEFORE any auth middleware. Unless you want to protect static assets (rarely the case), let them bypass the pipeline. If you DO need auth on specific static files, use MapFallbackToFile() with authorization — not UseStaticFiles at the end.

Extra sharp edge: Minimal APIs don't have [AllowAnonymous] attributes. Static files are unauthenticated by default. If you have a global auth filter, it won't touch static files. That's deliberate. Don't fight it.

StaticFilesOrder.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — csharp tutorial

var app = WebApplication.Create(args);

// Static files bypass everything — fast path
app.UseStaticFiles();

// Now auth middleware — only API routes pay the price
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/api/status", () => Results.Ok(new { live = true }));

// Protected static fallback? Use this, not UseStaticFiles
app.MapFallbackToFile("app.html")
   .RequireAuthorization();

app.Run();
Output
(no output — structural ordering, runtime behaviour depends on requests)
Senior Shortcut:
Profile your app with app.Urls.Add("http://+:5000") and Bombardier. If static files take >50ms, you're running them through auth. Move UseStaticFiles() to the top of the pipeline.
Key Takeaway
Static files MUST be served before auth middleware. Global auth middleware does NOT apply to static files by default — don't try to force it.

OpenAPI and Minimal APIs — Why Your Swagger UI is Wrong (And How to Fix It)

You added AddOpenApi() and MapOpenApi(). Swagger UI renders. Great. Now open any POST endpoint — the schema's a mess. Required fields missing. Types wrong. Nullable mismatch. That's because Minimal APIs don't implicitly generate metadata from your handler's parameters the way controllers do with [FromBody] and [ApiController].

Every request delegate is a black box to the OpenAPI generator. If you pass a Product object without defining a [EndpointSummary] or using WithOpenApi() to annotate it, Swagger guesses. And it guesses wrong — especially with record types and nullable reference types.

Production fix: Use WithOpenApi() on every non-trivial endpoint. Or better, use AddOpenApi() with an OpenApiOptions transformer. You can inject descriptions, fix request bodies, and ensure status codes are documented. Otherwise your frontend team will waste hours debugging 400 vs 422 responses because the schema says string but you return int.

Worst-case: disable the default schema generation entirely and provide explicit request/response types via [ProducesResponseType] equivalents. Minimal doesn't mean undocumented. It means you do the documentation yourself.

OpenApiFix.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
// io.thecodeforge — csharp tutorial

using Microsoft.AspNetCore.OpenApi;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((document, context, ct) =>
    {
        document.Info.Title = "Inventory API";
        return Task.CompletedTask;
    });
});

var app = builder.Build();
app.MapOpenApi();

app.MapPost("/products", async (Product product) =>
{
    // ...
    return Results.Created($"/products/{product.Id}", product);
})
.WithOpenApi(operation =>
{
    operation.Summary = "Create a new product";
    operation.Description = "Requires auth. Auto-generates ID.";
    // Fix: explicitly say 201 returns Product
    return operation;
});

app.Run();

public record Product(int Id, string Name, decimal Price);
Output
(OpenAPI JSON at /openapi/v1.json will now show correct summary and description)
Production Trap:
Never trust auto-generated Swagger from Minimal APIs. Always call .WithOpenApi() on each endpoint, or use a document transformer to override bogus schemas. Your API consumers will thank you.
Key Takeaway
Minimal APIs don't generate OpenAPI metadata from handlers automatically. Use WithOpenApi() per endpoint or a document transformer to fix schemas before your consumers see them.

Prevent Over-Posting: Stop Clients From Silently Destroying Your Data

Over-posting is the security hole nobody talks about until it bites them. A client sends a POST or PUT with extra fields — like IsAdmin: true on a user registration endpoint — and your model binder just obediently sets them. Minimal APIs make this worse because there's no natural place to enforce a boundary like [Bind] on a controller parameter.

The fix is to never bind your domain entities directly. Create a separate DTO (data transfer object) that only exposes the properties you want the client to control. Map that DTO to your domain model inside the handler — explicitly. That IsAdmin field simply doesn't exist on the DTO, so even if the client sends it, it gets ignored.

Why this works: your endpoint contract becomes the DTO shape, not your database schema. The compiler enforces what the client can touch. Senior devs don't trust client input — they design it out of the equation.

OverpostingExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — csharp tutorial

// BAD: Binds domain entity directly — vulnerable to over-posting
app.MapPost("/users", (User user) => {
    // Client can set user.IsAdmin = true
    db.Users.Add(user);
    return Results.Created();
});

// GOOD: DTO only exposes what client should control
public record CreateUserDto(string Name, string Email);

app.MapPost("/users", (CreateUserDto dto, AppDbContext db) => {
    var user = new User
    {
        Name = dto.Name,
        Email = dto.Email,
        IsAdmin = false  // Explicit default — never from client
    };
    db.Users.Add(user);
    return Results.Created($"/users/{user.Id}", user);
});
Output
Client sends { "Name": "Hacker", "Email": "x@y.com", "IsAdmin": true } → IsAdmin ignored, user saved with IsAdmin = false
Production Trap:
AutoMapper or manual mapping? Manual wins here. Any automated map that copies all properties rebinds the vulnerability. Write the two-line assignment — it's the cheapest defense.
Key Takeaway
Never bind domain entities directly in Minimal API handlers. DTOs are your contract and your shield.

Examine the PATCH Endpoint: Partial Updates Without the Full Reset

PUT replaces the entire resource. PATCH is for partial updates — but most teams implement PATCH as a glorified PUT with a null check. That destroys the entire point. A true PATCH endpoint uses JsonPatchDocument<T> from the Microsoft.AspNetCore.JsonPatch package, which applies a list of operations: add, replace, remove, test. The client sends exactly what changed, not the whole object.

Why bother? Because in production, a PUT that replaces the entire 50-field customer object litters your logs with unnecessary writes and creates merge conflicts when two services hit the same record. A PATCH with a single replace /email operation is atomic, auditable, and intentional.

Minimal APIs support JsonPatchDocument natively through the [FromBody] source. You apply it to your entity, then save. No DTO mapping required — the patch document is the contract. But watch out: you still need validation, because a test operation that fails will rollback the entire chain.

PatchEndpoint.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — csharp tutorial

using Microsoft.AspNetCore.JsonPatch;

app.MapPatch("/users/{id}", async (int id, JsonPatchDocument<User> patch, AppDbContext db) =>
{
    var user = await db.Users.FindAsync(id);
    if (user is null) return Results.NotFound();

    patch.ApplyTo(user);  // Applies 'replace', 'add', etc.
    await db.SaveChangesAsync();
    return Results.Ok(user);
});

// Client sends:
[
  { "op": "replace", "path": "/email", "value": "new@example.com" }
]
Output
PATCH /users/1 with body [{"op":"replace","path":"/email","value":"new@example.com"}] → updates only email field, returns updated user
Senior Shortcut:
Always apply JsonPatchDocument to a DTO, not the entity, if your entity has computed properties or navigation collections. Otherwise a malicious add operation can corrupt related data.
Key Takeaway
PATCH is not a lazy PUT. Use JsonPatchDocument for true partial updates — atomic, auditable, and safe.

Why Your Minimal API Needs Those Exact Packages — And What Happens If You Omit One

A Minimal API project template already includes Microsoft.AspNetCore.App implicitly, but production apps require explicit packages for features like OpenAPI, authentication, or EF Core. Omitting Microsoft.AspNetCore.OpenApi means your Swagger endpoint returns 404 with no error — the framework silently skips OpenAPI generation if the package is missing. Without Microsoft.AspNetCore.Authentication.JwtBearer, your AddAuthentication().AddJwtBearer() call compiles but throws at runtime when the first token arrives. The key insight: ASP.NET Core uses a "convention-plus-package" model — the API surface compiles but the middleware pipeline fails if the underlying package isn't resolved. Always add Microsoft.AspNetCore.OpenApi for Swagger, Swashbuckle.AspNetCore for the UI, and Microsoft.AspNetCore.Authentication.* for auth. Use dotnet list package after adding to verify transitive dependencies resolved correctly.

StartupPackages.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — csharp tutorial

// Wrong: compiles, but Swagger returns 404
builder.Services.AddOpenApi();
// Missing package: Microsoft.AspNetCore.OpenApi → exception at runtime

// Right: explicit packages
using Microsoft.AspNetCore.Authentication.JwtBearer;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(); // requires Microsoft.AspNetCore.Authentication.JwtBearer

builder.Services.AddOpenApi(); // requires Microsoft.AspNetCore.OpenApi

var app = builder.Build();
app.MapGet("/health", () => Results.Ok());
app.Run();
Output
dotnet list package | grep -i openapi
> Microsoft.AspNetCore.OpenApi 9.0.0
Production Trap:
NuGet's transitive restore often pulls the right package during development. In CI/CD with --no-restore, missing packages cause silent 500 errors. Always run dotnet restore explicitly in your pipeline.
Key Takeaway
Install the package before writing the registration code — ASP.NET Core's convention-based API hides missing dependencies until runtime.

Wrap-Up — The Single Rule That Prevents 90% of Minimal API Failures in Production

After building, deploying, and troubleshooting Minimal APIs, one pattern separates maintainable apps from disaster: every route handler must be a single-purpose function that receives exactly the data it needs and returns exactly one shape of response. If you find yourself adding HttpContext accessor, multiple using blocks inside a handler, or conditional Results.Ok vs Results.BadRequest paths, you've outgrown Minimal API patterns and should extract that logic into a service. The production failure pattern is always the same: a route handler grows beyond 15 lines, gets a null reference, and you lose the stack trace because ASP.NET Core wraps Minimal API exceptions in a generic BadHttpRequestException. Keep handlers thin — they should only bind parameters, call a method, and map the result. Anything else belongs in a separate class.

GoodHandler.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — csharp tutorial

// Good: single responsibility
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService service) =>
{
    var orderId = await service.CreateAsync(req);
    return Results.Created($"/orders/{orderId}", null);
});

// Bad: do not do this
app.MapPost("/orders", async (HttpContext ctx) =>
{
    var req = await ctx.Request.ReadFromJsonAsync<CreateOrderRequest>();
    // ... 30 more lines of logic, DB calls, error handling
    return Results.Ok();
});
Output
null — reviewers will reject the bad pattern every time
Production Trap:
If you pass HttpContext to a handler, the framework can't optimize your endpoint with source generators — your cold start latency increases by 60-120ms on every request.
Key Takeaway
A Minimal API handler's job is to dispatch, not to decide — if it needs a try/catch, extract it.

Overview

Minimal APIs in ASP.NET Core represent a paradigm shift away from the controller-heavy patterns of traditional web APIs. Instead of requiring separate classes, attributes, and complex routing logic, Minimal APIs let you define endpoints as simple lambda expressions directly within Program.cs. This approach drastically reduces boilerplate code, making it ideal for microservices, small-to-medium APIs, and rapid prototyping. However, the simplicity of Minimal APIs can be deceptive—what looks like a quick app.MapGet() can become a maintenance nightmare if production concerns like validation, error handling, and middleware ordering are ignored. This guide covers everything from interview prep to silent data corruption to OpenAPI misconfigurations. By the end, you'll understand not just how to write endpoints, but why each decision—like choosing AddSingleton over AddScoped—affects your deployment. Minimal APIs aren't a toy; they're a powerful tool when their boundaries are respected.

Program.csCSHARP
1
2
3
4
5
6
// io.thecodeforge — csharp tutorial
var app = WebApplication.Create(args);

app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));

app.Run();
Output
HTTP 200: {"status":"healthy"}
Minimal ≠ Simple
Many assume Minimal APIs are just for demos. In production, they require the same rigor as any MVC controller.
Key Takeaway
Minimal APIs reduce ceremony but not responsibility—production readiness still demands validation, structured error handling, and middleware awareness.

Prerequisites

Before you start writing Minimal APIs, ensure your environment is set up correctly. You need .NET 6 or later (preferably .NET 8 or 9 for the latest features), an IDE like Visual Studio or JetBrains Rider, and basic familiarity with HTTP verbs (GET, POST, PUT, PATCH, DELETE). Unlike controller-based APIs, Minimal APIs don't require Startup.cs, so you'll work directly with WebApplication and WebApplicationBuilder. You must understand the difference between app.MapGet() and app.UseEndpoints()—the former registers an endpoint directly, while the latter belongs to the middleware pipeline. Also, install the Microsoft.AspNetCore.OpenApi package if you need Swagger, and a serialization package like System.Text.Json (already included). If you plan to use Entity Framework Core, add the NuGet package for your provider (e.g., Npgsql.EntityFrameworkCore.PostgreSQL). Not having these packages leads to runtime exceptions, not compilation errors. Finally, know the order of middleware: UseStaticFiles must come before MapGet to serve files correctly. Miss this, and your static files will 404.

terminal.shBASH
1
2
3
4
// io.thecodeforge — csharp tutorial
dotnet new webapi -minimal -o MyMinimalApi
cd MyMinimalApi
dotnet add package Microsoft.AspNetCore.OpenApi --version 8.0.0
Output
Project created. Package added.
Production Trap:
Skipping 'dotnet restore' after adding packages causes misleading build failures. Always run it before debugging.
Key Takeaway
Ensure .NET SDK 8+, required NuGet packages, and middleware ordering are in place before the first endpoint compiles.
● 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.
Endpoints work without JWT despite RequireAuthorization()+
Immediate action
Check middleware order in Program.cs
Commands
grep -n 'UseAuthentication\|UseAuthorization\|MapGroup' Program.cs
curl -v http://localhost:5000/products (expect 401 with auth header missing)
Fix now
Move app.UseAuthentication() and app.UseAuthorization() to appear before any endpoint registration (MapGroup, MapGet, etc.).
Minimal APIs vs Controller-Based APIs
FeatureMinimal APIController API
Startup performance20-40% faster (source-generated binding)Slower (reflection-based discovery)
BoilerplateMinimal - single file or extension methodsMore - separate controller class, actions, attributes
TestabilityUnit testable static methods; integration via WebApplicationFactoryUnit testable with controller instantiation; integration via WebApplicationFactory
OrganizationExtension methods and MapGroup for structureControllers with routing attributes enforce structure
VersioningRoute groups or Asp.Versioning.HttpAttribute-based versioning (e.g., [ApiVersion])
OpenAPI supportVia TypedResults and WithOpenApi()Via [ProducesResponseType] and Swagger attributes
Action filtersEndpoint filters (simpler, less feature-rich)Full action filter pipeline (OnActionExecuting, etc.)
Learning curveLow for simple APIs; need discipline for large onesHigher initial overhead but familiar conventions
Best forMicroservices, BFFs, internal tools, serverlessLarge enterprise apps, complex versioning, MVC projects

Common mistakes to avoid

5 patterns
×

Authentication middleware after endpoint mapping

Symptom
Endpoints marked with RequireAuthorization() work without any token. The endpoint runs before auth middleware is reached.
Fix
Move app.UseAuthentication() and app.UseAuthorization() to appear before any endpoint registration (MapGroup, MapGet, etc.).
×

Missing AddProblemDetails leads to silent 500 errors

Symptom
Unhandled exceptions return a plain 500 with no body. Clients can't parse the error, and debugging requires log access.
Fix
Call builder.Services.AddProblemDetails() and app.UseExceptionHandler() in the middleware pipeline.
×

Using Results.Ok() instead of TypedResults<T>

Symptom
Swagger UI shows 'any' as the response type. OpenAPI clients can't generate proper types.
Fix
Always use TypedResults.Ok<T>() for endpoints returning data. The type parameter gives compile-time safety and accurate OpenAPI schemas.
×

Forgetting to register services before builder.Build()

Symptom
Endpoint throws InvalidOperationException at runtime: 'Cannot resolve service for type XXX'.
Fix
Ensure all service registrations (AddScoped, AddSingleton, etc.) are called before builder.Build().
×

Not exposing Program class for integration tests

Symptom
WebApplicationFactory<Program> fails with 'The type Program is not accessible'.
Fix
Add public partial class Program { } at the end of Program.cs or use InternalsVisibleTo in .csproj.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between app.MapGet() with a lambda vs a static me...
Q02SENIOR
How do you handle model validation in Minimal APIs without writing valid...
Q03SENIOR
Explain the middleware pipeline order in Minimal APIs. What happens if a...
Q04SENIOR
How does the source-generated parameter binder improve performance in Mi...
Q05SENIOR
Can you mix Minimal APIs and controller-based APIs in the same project? ...
Q01 of 05JUNIOR

What is the difference between app.MapGet() with a lambda vs a static method?

ANSWER
Both are valid. Using a lambda is convenient for simple handlers. Using a static method reference improves testability and allows you to extract handler logic into an extension class. The framework treats them identically in terms of performance.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What .NET versions support Minimal APIs?
02
How do I enable JWT authentication in a Minimal API?
03
Can I use SignalR with Minimal APIs?
04
How do I return a file in a Minimal API?
05
Is it possible to use dependency injection in Minimal API handlers without constructor injection?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's ASP.NET. Mark it forged?

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