.NET 8 Minimal APIs: Endpoint Filters, DI, and OpenAPI wiring
.NET 8 Minimal APIs require explicit wiring for validation and OpenAPI; this guide shows how to add endpoint filters, register scoped services, and generate Swagger UI without controller boilerplate.
30 Sept 2025, 05:43 UTC

.NET 8 Minimal APIs strip away controller scaffolding, but that freedom comes with wiring responsibilities. When you need dependency injection, model validation, and an OpenAPI contract without the [ApiController] safety net, the Program.cs file becomes the sole place for configuration. This article shows how to add an endpoint filter for validation, register scoped services, and generate Swagger UI in a minimal API project intended for microservices.
With a few explicit calls in Program.cs you can retain the minimal-hosting benefits while keeping validation, DI, and machine-readable APIs intact.
Wiring the hosting model and OpenAPI services
builder.Services.AddEndpointsApiExplorer();builder.Services.AddSwaggerGen();
These calls must precede builder.Build(); otherwise the middleware pipeline won't see the service registrations. After var app = builder.Build(), conditionally enable the Swagger middleware for development:
if (app.Environment.IsDevelopment()){app.UseSwagger();app.UseSwaggerUI();}
Risk: In trimmed or AOT-published builds (PublishAot=true), reflection-based schema generation may drop types. Test dotnet publish -c Release before committing to AOT.
Registering dependency injection services
Minimal APIs use the same DI container as controllers. Register services after builder.Build() is not possible; all registrations happen on the builder. For scoped per-request resolution, use AddScoped:
builder.Services.AddScoped();
The Scoped lifetime ensures a new instance per HTTP request. If you register as Singleton, the same instance serves all requests, which may not match your test expectations.
Defining typed endpoints with model binding
record CreateWidgetRequest(string Name, int Quantity);interface ITimeProvider { DateTimeOffset Now { get; } }class SystemTimeProvider : ITimeProvider { public DateTimeOffset Now => DateTimeOffset.UtcNow; }app.MapPost("/widgets", async (CreateWidgetRequest req, ITimeProvider clock) => Results.Created("/widgets/{Guid.NewGuid()}", new { Id = Guid.NewGuid(), req.Name, req.Quantity, CreatedAt = clock.Now }));app.MapGet("/widgets/{id:guid}", (Guid id) => Results.Ok(new { Id = id, Name = "Sample", Quantity = 1, CreatedAt = DateTimeOffset.UtcNow }));app.MapGet("/health", () => Results.Ok(new { Status = "Healthy" }));
Parameters are bound automatically: route values (id:guid), query strings, and JSON bodies (CreateWidgetRequest). The ITimeProvider resolves per request because it's registered as Scoped.
Adding an endpoint filter for validation
app.AddEndpointFilter(async (context, next) =>{ foreach (var arg in context.Arguments){ if (arg is CreateWidgetRequest req && (string.IsNullOrWhiteSpace(req.Name) || req.Quantity <= 0)){ return Results.BadRequest(new { Error = "Name required and Quantity must be > 0" }); } } return await next(context); });
Place this after app = builder.Build() but before endpoint mappings so it wraps all subsequent endpoints. For larger projects, extract filters to separate classes implementing IEndpointFilter, and scope them with app.MapPost(...).AddEndpointFilter(...) for per-endpoint control.
Run and verify
From the project root (MinimalApiDemo/), execute:
dotnet run
Expected console output includes Now listening on: http://localhost:5xxx (port varies). Open http://localhost:5xxx/swagger in a browser — Swagger UI should render with three endpoints: POST /widgets, GET /widgets/{id}, GET /health.
Concrete checks
- Health endpoint: curl -s http://localhost:5xxx/health returns {"Status":"Healthy"}.
- Valid widget creation: curl -s -X POST http://localhost:5xxx/widgets -d '{"name":"Gadget","quantity":5}' returns 201 Created with a JSON body.
- Validation rejection: curl -s -X POST http://localhost:5xxx/widgets -d '{"name":"","quantity":0}' returns 400 Bad Request.
- DI scope: Call POST /widgets twice; each response shows a distinct CreatedAt timestamp, confirming per-request ITimeProvider resolution.
- Build passes: dotnet build succeeds with zero warnings about missing Swashbuckle.AspNetCore references.
Common failure modes and recovery
| Symptom | Cause | Fix |
|---|---|---|
| Port already in use | Another process holds the default port | Edit Properties/launchSettings.json → applicationUrl or add app.Urls.Add("http://localhost:5001") before app.Run() |
| Swagger UI shows blank page | AddEndpointsApiExplorer or AddSwaggerGen called after Build() | Move registrations before builder.Build() |
| DI throws InvalidOperationException: "No service for type..." | Service registered after Build() or wrong lifetime | Ensure builder.Services.AddScoped<...>() precedes Build() |
| AOT publish loses OpenAPI schemas | Trimming removes reflection metadata | Add to .csproj or disable AOT for OpenAPI builds |
If the project is unrecoverable, delete the MinimalApiDemo folder and re-run dotnet new web. No global state is modified.
Limitations to consider
- No automatic model validation attributes: [Required], [Range] on record properties are ignored. Use endpoint filters, FluentValidation, or manual checks.
- Top-level statements hide Main: Breakpoints in startup logic require "Just My Code" enabled or breaking in the implicit Program class.
- Endpoint filters run for all mapped endpoints: The example filter inspects every argument; scope it with app.MapPost(...).AddEndpointFilter(...) for per-endpoint control.
- OpenAPI generation is reflection-based: Native AOT (PublishAot=true) may require additional TrimmerRootAssembly entries or source-generated clients.
Next steps
- Replace the in-memory ITimeProvider with a real clock abstraction for testability.
- Add structured logging (builder.Logging.AddJsonConsole()) and correlation IDs via middleware.
- Generate a typed client from the OpenAPI document using dotnet tool install -g Microsoft.dotnet-openapi and dotnet openapi add url http://localhost:5xxx/openapi/v1.json.
- Containerize: dotnet publish -c Release -o out and a multi-stage Dockerfile based on mcr.microsoft.com/dotnet/aspnet:8.0.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.