DI Registration
Both approaches require identical service registration. Place DI configuration in Program.cs or a dedicated DependencyInjection.cs file.
var builder = WebApplication.CreateBuilder(args);
// Core services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Application services
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderValidator, OrderValidator>();
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
var app = builder.Build();
Step 2: Controller Implementation
Controllers use explicit routing, model binding, and action results. They integrate seamlessly with filters, middleware, and OpenAPI generation.
[ApiController]
[Route("api/v1/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _repo;
private readonly IOrderValidator _validator;
public OrdersController(IOrderRepository repo, IOrderValidator validator)
{
_repo = repo;
_validator = validator;
}
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
return BadRequest(validationResult.Errors);
var order = await _repo.CreateAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrder(Guid id)
{
var order = await _repo.GetByIdAsync(id);
return order is null ? NotFound() : Ok(order);
}
}
Step 3: Minimal API Implementation
Minimal APIs use delegate registration and IResult helpers. Routing is implicit via Map* methods. Validation and error handling must be wired explicitly.
app.MapPost("/api/v1/orders", async (
CreateOrderRequest request,
IOrderRepository repo,
IOrderValidator validator) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
return Results.BadRequest(validationResult.Errors);
var order = await repo.CreateAsync(request);
return Results.Created($"/api/v1/orders/{order.Id}", order);
}).WithName("CreateOrder").WithOpenApi();
app.MapGet("/api/v1/orders/{id:guid}", async (
Guid id,
IOrderRepository repo) =>
{
var order = await repo.GetByIdAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
}).WithName("GetOrder").WithOpenApi();
Step 4: Middleware & Global Error Handling
Both approaches benefit from centralized error handling. Register middleware before routing to catch unhandled exceptions.
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var error = new { Message = "Internal server error", Detail = exceptionHandlerFeature?.Error.Message };
await JsonSerializer.SerializeAsync(context.Response.Body, error);
});
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
Architecture Decisions & Rationale
- Routing: Controllers use attribute routing, enabling explicit URL contracts and versioning strategies. Minimal APIs use path-based registration, which is faster to write but harder to refactor when endpoints multiply.
- DI Integration: Controllers resolve dependencies via constructor injection, guaranteeing lifecycle consistency. Minimal APIs resolve dependencies via method parameters, which delegates to
HttpContext.RequestServices. This works but requires careful attention to scoped vs singleton lifetimes.
- Validation: Controllers integrate with
ModelState and action filters. Minimal APIs require manual validation calls or custom endpoint filters. Production systems should standardize on FluentValidation or DataAnnotations regardless of approach.
- OpenAPI: Controllers generate metadata automatically. Minimal APIs require
.WithOpenApi() and explicit ProducesResponseType equivalents via .Produces<T>() or custom OpenApiOperation builders.
Pitfall Guide
-
Treating Minimal APIs as a monolith replacement
Minimal APIs lack structural boundaries. When 30+ endpoints share a single Program.cs or scattered RouteGroup files, navigation, code review, and merge conflicts become unmanageable. Controllers enforce file-per-resource conventions that scale with team size.
-
Ignoring DI scope resolution in minimal endpoints
Parameter injection in minimal APIs uses HttpContext.RequestServices. If you resolve a scoped service inside a singleton delegate or cache the delegate, you'll encounter disposed context exceptions or stale data. Always resolve per-request or use IServiceProvider explicitly within the delegate.
-
Overusing inline delegates instead of extracting handlers
Writing business logic directly in MapGet/MapPost creates untestable, unmockable code. Extract to handler classes or static methods. Minimal APIs should delegate to application layer classes, just like controllers.
-
Assuming controllers are inherently slower in steady state
Reflection-based controller discovery happens once during startup. After warmup, routing uses compiled expression trees and action descriptors. The runtime performance difference is <0.05ms per request. Optimizing for cold-start at the expense of maintainability is a misallocation of engineering effort.
-
Neglecting OpenAPI/Swagger configuration differences
Controllers generate endpoints automatically. Minimal APIs require explicit .WithOpenApi() and metadata configuration. Forgetting this breaks client SDK generation and API documentation. Standardize on a source generator or endpoint metadata convention to avoid drift.
-
Mixing patterns without architectural boundaries
Using controllers for admin endpoints and minimal APIs for public APIs in the same project creates inconsistent error handling, validation pipelines, and testing strategies. Choose one pattern per bounded context, or isolate them in separate projects/assemblies.
-
Skipping validation/error handling middleware
Both approaches default to returning raw exceptions or inconsistent JSON shapes. Production systems must implement global exception handling, standardized error responses (ProblemDetails), and validation middleware. Neither pattern provides this out of the box.
Best Practices from Production
- Use vertical slice architecture: group by feature, not by layer. This works identically for both approaches.
- Standardize on
ProblemDetails for error responses. Configure Microsoft.AspNetCore.Mvc.ProblemDetails in Program.cs.
- Extract all business logic to application handlers. Controllers and minimal endpoints should only handle HTTP concerns.
- Use
RouteGroupBuilder to namespace minimal APIs. It provides prefix routing, shared metadata, and filter pipelines.
- Measure routing performance only after JIT warmup. Cold-start metrics are irrelevant for containerized deployments with health checks and pre-warm strategies.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Microservice with <10 endpoints, single developer | Minimal APIs | Low ceremony, rapid iteration, reduced boilerplate | Lower initial dev cost, higher refactoring cost if scope expands |
| Enterprise monolith, 3+ teams, complex domain | Controllers | Explicit routing, mature testing, predictable DI lifecycle | Higher initial setup cost, lower long-term maintenance cost |
| Rapid prototype or internal tooling | Minimal APIs | Fastest path to working HTTP surface, minimal configuration | Near-zero setup cost, technical debt accumulates if promoted to production |
| Team with ASP.NET MVC/Web API background | Controllers | Familiar patterns, reduced onboarding friction, existing skill leverage | Zero retraining cost, faster feature delivery |
Configuration Template
// Program.cs - Production-ready baseline
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.AddJsonOptions(opts => opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.CustomSchemaIds(t => t.FullName));
// Validation
builder.Services.AddScoped(typeof(IValidator<>), typeof(Validator<>));
// Repositories & Handlers
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<CreateOrderHandler>();
// Global error handling
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseSwagger();
app.UseSwaggerUI();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// Controllers
app.MapControllers();
// Minimal APIs (if used)
var orders = app.MapGroup("/api/v1/orders");
orders.MapPost("/", async (CreateOrderRequest req, CreateOrderHandler handler) =>
{
var result = await handler.ExecuteAsync(req);
return Results.Created($"/api/v1/orders/{result.Id}", result);
}).WithName("CreateOrder").WithOpenApi();
app.Run();
Quick Start Guide
- Create a new project:
dotnet new web -n ApiArchitecture && cd ApiArchitecture
- Add dependencies:
dotnet add package Microsoft.AspNetCore.OpenApi && dotnet add package Swashbuckle.AspNetCore
- Replace
Program.cs with the Configuration Template above, adjust service registrations to match your domain
- Run:
dotnet run and navigate to /swagger to verify endpoints, validation, and error handling are operational
This setup compiles, configures DI, wires OpenAPI, and provides both controller and minimal API registration points. From here, extract handlers, add integration tests, and enforce architectural boundaries per bounded context. The choice between Minimal APIs and Controllers is not a performance optimization; it is a structural contract. Align it with team size, domain complexity, and long-term maintenance expectations.