Minimal APIs + EF Core: a clean .NET 8 API
Controller , attribute routing, and a good deal of the binding plumbing. An endpoint becomes a method that receives its dependencies as parameters. The risk is well known: without proper structuring, everything ends up piled into Program.cs . What follows shows an API backed by EF Core that stays readable and testable as it grows.
Splitting into route groups
Program.cs , all that's left is a single app.MapTodos(); per resource. WithTags feeds the documentation, WithOpenApi enriches each generated endpoint. The group also accepts an AddEndpointFilter that applies at once to all its routes, which is useful for authorization or shared validation.
{id:int} constraint in the template does its work right at routing time: a /todos/abc request matches no route and returns a 404 without ever reaching the handler. Putting the constraints in the template rather than in the body avoids a defensive int.TryParse at the start of every method.
Typed results
Results.Ok(...) returns an opaque IResult . TypedResults.Ok(...) returns a concrete Ok<T> , and that difference unlocks result unions. A signature like Results<Ok<TodoDto>, NotFound> declares the two possible outcomes: the compiler checks that the handler returns nothing else, and OpenAPI publishes both HTTP codes without a single [ProducesResponseType] attribute.
401 for instance, is done by widening the return signature, and the body of the handler stops compiling until that case is handled. This is a useful constraint: the HTTP contract lives in the type, not in a comment.
Model binding and validation
int id that matches a route segment comes from the URL, a primitive type with no match comes from the query string, a complex type comes from the JSON body, and a service registered in the container is injected directly. When the signature grows too long, [AsParameters] groups several parameters into a dedicated struct .
[Required] placed on a DTO property is ignored at binding time. There are two paths: validate by hand in the handler, as below, or wire up an AddEndpointFilter that inspects the argument via context.GetArgument<CreateTodoRequest>(0) and returns a ValidationProblem without calling next when the model is invalid. FluentValidation plugs in at the same spot.
TypedResults.ValidationProblem responds with a 400 in ProblemDetails format (RFC 7807), the same body a controller annotated [ApiController] would produce automatically. The client therefore sees the same error structure, whether it hits a Minimal API route or a classic controller.
EF Core: DbContext, queries, migrations
AddDbContext<AppDbContext> registers the context with a scoped lifetime: one instance per HTTP request, injected into the handler like any other service. This choice isn't cosmetic: a DbContext isn't thread-safe and must not be shared across requests, and the scoped lifetime guarantees exactly that isolation. For high-throughput scenarios, AddDbContextPool recycles instances instead of allocating a fresh one on every call.
AsNoTracking disables change tracking: EF Core doesn't build snapshots to compare later, which lightens queries that only return data. The Select into a record TodoDto goes further: it limits the columns actually loaded instead of materializing the whole entity before mapping it in memory. That's the difference between a SELECT Id, Title, IsDone and a SELECT * followed by a client-side projection.
db.Todos.Add(entity) followed by a single SaveChangesAsync commits the transaction. EF Core issues the INSERT and retrieves the generated key into entity.Id , immediately available to build the Created URL.
dotnet ef migrations add InitialCreate produces a versioned file, applied at startup with db.Database.MigrateAsync() . EnsureCreated does create the tables but completely ignores the migration history, which makes the first real migration impossible afterward; reserve it for disposable databases. The full workflow is described in the EF Core migrations guide .
Minimal API or controller
[ApiController] and its automatic validation, on MVC filters (action, result, exception), on rich form model binding, or on team conventions already in place.
Keeping it all testable
AppDbContext on the SQLite in-memory provider, call the handler, inspect the result. TypedResults helps here too, since the concrete return type exposes the StatusCode and the value directly, without deserializing a response.
WebApplicationFactory<Program> starts the application in memory and lets you hit the real endpoints through an HttpClient , filters and binding included. With top-level statements, the generated Program class is internal: a public partial class Program { } at the end of the file is enough to make it visible from the test project.
A well-kept Minimal API is nothing like a prototype. The style removes the ceremony inherited from controllers; the design, however, remains entirely your responsibility.