Minimal APIs in ASP.NET Core — Build Fast, Lean REST Endpoints
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.
// 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);
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
}
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.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); }
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 }
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.
// ── 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}'
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
}
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.
// 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>>();
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
| Feature / Aspect | Minimal APIs | Controller-Based APIs |
|---|---|---|
| Boilerplate to get started | ~5 lines in Program.cs | Controller class + attributes + routing config |
| Startup time | Faster — no controller discovery | Slower — full MVC pipeline initialisation |
| Memory footprint | Lower — fewer middleware components | Higher — MVC infrastructure always loaded |
| Dependency injection | Parameters injected into lambda directly | Constructor injection in controller class |
| Filters / middleware | Endpoint Filters (.AddEndpointFilter()) | Action Filters + Global Filters via MvcOptions |
| OpenAPI / Swagger support | Full support via .WithOpenApi() | Full support via [ProducesResponseType] attributes |
| Unit testability of handlers | Excellent with TypedResults | Good — controller methods are regular methods |
| Route grouping | MapGroup() with shared prefix + middleware | Controller base route + [Route] attribute |
| API versioning | Supported but less mature tooling | Mature ecosystem (Asp.Versioning.Http) |
| Team convention enforcement | Relies on agreed patterns + extension methods | Controller conventions enforce structure automatically |
| Best suited for | Microservices, BFFs, serverless, internal tools | Large team APIs, complex enterprise apps, MVC migrations |
🎯 Key Takeaways
- Minimal APIs skip MVC's controller discovery at startup — that's not laziness, it's a real performance win that matters in cold-start serverless environments.
- TypedResults over Results — always. It locks in the return type at compile time, generates accurate OpenAPI schemas, and makes your handlers unit-testable without a test server.
- The MapGroup() + extension method pattern is what keeps Minimal APIs maintainable beyond 10 endpoints. Endpoint registration belongs in feature files, not Program.cs.
- Minimal APIs and controller-based APIs can coexist in the same ASP.NET Core application. Choosing one doesn't mean abandoning the other — migrate incrementally or use both where each shines.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Registering services AFTER calling builder.Build() — The symptom is an InvalidOperationException at startup: 'Cannot modify ServiceCollection after application has been built.' Fix: all builder.Services.Add*() calls must happen before var app = builder.Build(). Think of Build() as sealing the DI container — nothing gets in after that.
- ✕Mistake 2: Using Results instead of TypedResults for handlers you want to unit test — The symptom is that your unit test can't assert the HTTP status code without casting to IStatusCodeHttpResult, and your OpenAPI spec shows the response type as 'any' in Swagger. Fix: switch to TypedResults.Ok
(), TypedResults.NotFound(), etc. The return type becomes part of the method signature, enabling compile-time checks and accurate schema generation. - ✕Mistake 3: Putting all endpoints directly in Program.cs as the app grows — The symptom is a 500-line Program.cs that nobody wants to touch. There's no compile error, just mounting pain. Fix: use the MapGroup() + extension method pattern from the start. Create one static class per domain entity (e.g., ProductEndpoints, OrderEndpoints), each with a MapXxxEndpoints(this WebApplication app) extension method. Program.cs stays under 30 lines regardless of how many endpoints you add.
Interview Questions on This Topic
- QWhat's the difference between Results and TypedResults in Minimal APIs, and why does it matter for OpenAPI documentation and unit testing?
- QHow do you apply authentication and rate limiting to a group of Minimal API endpoints without duplicating code on each one — and what's the correct middleware order in Program.cs?
- QA colleague says Minimal APIs can't scale to a production app with 50+ endpoints because everything ends up in Program.cs. How do you respond, and what pattern would you show them?
Frequently Asked Questions
Can Minimal APIs in ASP.NET Core replace controller-based APIs completely?
For most new projects, yes — especially microservices and lightweight APIs. Minimal APIs now support auth, rate limiting, output caching, OpenAPI, and filters. The remaining gap is in complex API versioning scenarios and very large team codebases where MVC conventions enforce consistency automatically. You can also mix both styles in one app.
How do you add Swagger/OpenAPI documentation to a Minimal API?
Call builder.Services.AddEndpointsApiExplorer() and builder.Services.AddSwaggerGen() in the service registration section. Then call app.UseSwagger() and app.UseSwaggerUI() in the middleware section. Use .WithOpenApi() on your MapGroup() or individual endpoints, and switch to TypedResults to get accurate response schemas generated automatically.
Where do I put validation logic in a Minimal API if there are no model validators auto-registered like in MVC?
Use Endpoint Filters — create a generic ValidationFilter
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.