Opening: Why Minimal APIs Deserve Your Production Workload
When Minimal APIs landed in .NET 6, many dismissed them as a lightweight demo framework for hobbyists. I made that mistake too. Three years and five production services later, I've learned that Minimal APIs: a production-ready setup is not just viable—it's often the smarter choice for microservices, feature APIs, and teams that value velocity without sacrificing quality.
The misconception lingers because early examples showed trivial endpoints in Program.cs. But that's like judging ASP.NET Core Controllers by a hello-world sample. Done right, Minimal APIs give you the same DI container, middleware pipeline, and hosting flexibility as traditional controllers, with less ceremony and better discoverability for small-to-medium teams.
Start Lean — A Minimal API Is a Full ASP.NET Core App
The first thing to understand: there's no trade-off in capability. When you create a Minimal API, you're building a genuine ASP.NET Core application. You get the exact same dependency injection, middleware, and hosting infrastructure. The difference is syntax, not substance.
Here's the absolute foundation:
var builder = WebApplication.CreateBuilder(args);
// Full DI container, just like controllers
builder.Services.AddScoped<OrderService>();
builder.Services.AddProblemDetails();
builder.Services.AddAuthentication().AddJwtBearer(...);
var app = builder.Build();
// Full middleware pipeline
app.UseAuthentication();
app.UseAuthorization();
app.UseExceptionHandler();
// Endpoints
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.Run();I've seen teams hesitate here, thinking Minimal APIs are "lightweight" in a limiting sense. Wrong. Add AddProblemDetails(), wire up authentication, inject services—it's all there. The "minimal" refers to boilerplate, not power.
In production, I always include:
AddProblemDetails()— RFC 7807 problem responses, consistent error handling- Structured logging with Serilog or similar
- Health check endpoints for load balancers
- OpenAPI/Swagger generation (more on that later)
A real health check endpoint I use:
app.MapGet("/health", async (IHealthCheckService health) =>
{
var result = await health.CheckHealthAsync();
return result.Status == HealthStatus.Healthy
? Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: Results.ServiceUnavailable(new { status = "unhealthy" });
})
.WithName("HealthCheck")
.WithOpenApi()
.AllowAnonymous();That single endpoint is production-grade: dependency injected, async, problem-aware, and documented for OpenAPI.
Group Your Routes — Shared Prefixes, Filters, and Auth Per Feature
Once you have more than three endpoints, flat mapping in Program.cs becomes a nightmare. Route groups are the solution, and they're more powerful than they first appear.
A route group lets you:
- Share a URL prefix across related endpoints
- Apply authorization, tags, and metadata once
- Keep related logic visibly grouped
Here's how I structure an Orders feature:
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.RequireAuthorization("AdminOnly") // Custom policy
.WithTags("Orders")
.WithOpenApi();
orders.MapGet("/", GetOrders)
.WithName("ListOrders")
.WithDescription("Fetch paginated orders");
orders.MapGet("/{id}", GetOrder)
.WithName("GetOrder")
.WithDescription("Fetch a single order by ID");
orders.MapPost("/", CreateOrder)
.WithName("CreateOrder")
.AllowAnonymous(); // Override group auth if neededThe beauty here: I've applied RequireAuthorization("AdminOnly") once, and all four endpoints inherit it (except the override on CreateOrder). If I need to add a custom header validation or logging filter, I do it once for the group.
Real-world example from a ride-hailing service I built: I had driver endpoints and passenger endpoints under different auth policies. Groups made it trivial:
app.MapGroup("/api/drivers")
.RequireAuthorization("DriverRole")
.MapDriverEndpoints();
app.MapGroup("/api/passengers")
.RequireAuthorization("PassengerRole")
.MapPassengerEndpoints();Each group enforces its policy without duplicating authorization attributes on every handler.
Typed Results — Explicit Responses That Feed OpenAPI Automatically
This is where Minimal APIs shine compared to ad-hoc REST APIs. TypedResults make your response contracts explicit and automatically populate your OpenAPI schema.
Without typed results, you might write:
app.MapGet("/api/orders/{id}", async (int id, AppDb db) =>
{
var order = await db.Orders.FindAsync(id);
if (order == null) return Results.NotFound();
return Results.Ok(order.ToDto());
});The issue: the method signature says nothing about what responses are possible. Your OpenAPI doc might miss the 404. Consumers reading the code are guessing.
With TypedResults, be explicit:
static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
int id,
AppDb db)
{
var order = await db.Orders.FindAsync(id);
return order is null
? TypedResults.NotFound()
: TypedResults.Ok(order.ToDto());
}
app.MapGet("/api/orders/{id}", GetOrder)
.WithName("GetOrder")
.Produces<OrderDto>(200)
.Produces(404);Now the return type documents every response. OpenAPI sees Results<Ok<OrderDto>, NotFound> and generates correct schemas. Consumers know exactly what to expect. Your IDE can even help you handle all cases.
In a CRM system I built, this prevented dozens of API integration bugs. Frontend developers could see response types upfront instead of discovering them through trial and error.
One File Per Feature — Extension Methods Keep Program.cs Readable
Here's the dirty secret of Minimal APIs: if you map all endpoints directly in Program.cs, you'll hit 500+ lines and regret your life choices. The solution is simple: extension methods.
Create a Features/Orders/OrderEndpoints.cs file:
namespace MyApi.Features.Orders;
public static class OrderEndpoints
{
public static void MapOrderEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/orders")
.WithTags("Orders")
.WithOpenApi();
group.MapGet("/", GetOrders)
.WithName("ListOrders");
group.MapGet("/{id}", GetOrder)
.WithName("GetOrder");
group.MapPost("/", CreateOrder)
.WithName("CreateOrder");
group.MapPut("/{id}", UpdateOrder)
.WithName("UpdateOrder");
group.MapDelete("/{id}", DeleteOrder)
.WithName("DeleteOrder");
}
private static async Task<Ok<List<OrderDto>>> GetOrders(OrderService svc) =>
TypedResults.Ok(await svc.ListAsync());
private static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
int id, OrderService svc) =>
await svc.GetAsync(id) is { } dto
? TypedResults.Ok(dto)
: TypedResults.NotFound();
// ... other handlers
}Then in Program.cs, one line per feature:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<InvoiceService>();
// ... DI setup
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Map features
app.MapOrderEndpoints();
app.MapInvoiceEndpoints();
app.MapCustomerEndpoints();
app.Run();Program.cs stays clean at 30 lines, and each feature is isolated. I've done this pattern on APIs with 50+ endpoints without it becoming a maintenance nightmare. Compare that to a Controllers-based API where you'd have 15 controller files—this is actually simpler.
When to Pick Controllers — There Are Still Valid Reasons
I'll be honest: Minimal APIs aren't always the answer. Controllers remain first-class citizens and shine in specific scenarios:
- Massive APIs with heavy conventions: If you have 200+ endpoints with consistent filters, model binding, and versioning, controllers' attribute-based model feels natural.
- Complex model binding: Controllers handle form data, file uploads, and binding from multiple sources more elegantly with attributes.
- Filter pipelines: Authorization filters, validation filters, and result filters are more discoverable in controller syntax if you have many of them.
- Team expertise: If your team thinks in MVC patterns and minimal APIs feel alien, the cognitive overhead isn't worth it.
I still use controllers for legacy systems and massive B2B APIs where 500 endpoints with versioning, tenancy, and fine-grained permissions are the norm. But for microservices, feature APIs, and anything under 100 endpoints? Minimal APIs win on velocity and clarity.
Common Mistakes
Mistake 1: Putting all endpoint logic in Program.cs
I've seen teams do this and regret it after 20 endpoints. Use extension methods from day one. The cost is near-zero; the benefit compounds.
Mistake 2: Forgetting to use TypedResults
If you return Results.Ok(data) from some endpoints and data from others, your OpenAPI schema becomes a mess. Pick one style per project. I always use TypedResults with the explicit return type.
Mistake 3: Not adding OpenAPI metadata early
It's tempting to skip .WithName(), .WithDescription(), and .Produces() to move fast. Don't. Add them as you go. Frontend teams depend on accurate docs, and fixing them later is tedious.
Mistake 4: Ignoring exception handling
Minimal APIs don't magically handle exceptions. You still need app.UseExceptionHandler() and problem details. Wire it up in your lean start, not later.
Mistake 5: Coupling handlers to Program.cs structure
If your handler functions reference variables or services from Program.cs in nonstandard ways, refactoring becomes hard. Keep handlers as static methods or isolated classes that only depend on their parameters. Inject everything they need.
Key Takeaways
- Minimal APIs are full ASP.NET Core applications—same DI, middleware, and hosting as controllers. The "minimal" refers to boilerplate, not capability.
- Start lean: include
AddProblemDetails(), authentication, and health checks from the beginning. Don't confuse lean with incomplete. - Use route groups to avoid repeating authorization, tags, and prefixes. One
RequireAuthorization()per group, not per endpoint. - Always use
TypedResultswith explicit return types. This documents responses and generates accurate OpenAPI schemas automatically. - Extract endpoint handlers to extension methods in feature-organized files. Keep Program.cs as a readable map of your API structure.
- Controllers and Minimal APIs coexist. Pick Minimal APIs for microservices and feature APIs under 100 endpoints; controllers still win for massive legacy systems with heavy conventions.
- Avoid five common mistakes: dumping code in Program.cs, mixing result styles, skipping OpenAPI metadata, ignoring exception handling, and tight coupling between handlers and configuration.




Comments
Loading comments…