S

super-dev — full-stack

@super-dev.app · Full-stack · ★ open to work
12.8k subscribers·47 videos·Active since 2017

Full-stack .NET / Angular dev with a DevOps streak on Azure Cloud. Also fluent in Flutter + Firebase. I build products, ship them, and keep them healthy.

Back to articles
ƒ()
.NET
.NET

Minimal APIs + EF Core: a clean .NET 8 API

Article 2 of 5 — Modern .NET
Minimal APIs, CQRS, gRPC, source generators: clean, testable .NET 8 without the ceremony.

Minimal APIs have a bad reputation: people think they're only good for throwaway demos. In reality, with a bit of discipline, they yield a .NET 8 API that's more readable and more testable than a classic controller — as long as you don't pile everything into Program.cs .

Split with route groups

The beginner trap is stacking thirty app.MapGet calls in Program.cs . The fix is one word: `MapGroup` . Each resource gets its own group, with its prefix, filters and metadata, defined in a dedicated extension method:

C#
1public static class TodoEndpoints
2{
3 public static RouteGroupBuilder MapTodos(this IEndpointRouteBuilder app)
4 {
5 var group = app.MapGroup("/todos")
6 .WithTags("Todos")
7 .WithOpenApi();
8
9 group.MapGet("/", GetAllAsync);
10 group.MapGet("/{id:int}", GetByIdAsync);
11 group.MapPost("/", CreateAsync);
12
13 return group;
14 }
15
16 private static async Task<Ok<List<Todo>>> GetAllAsync(AppDbContext db) =>
17 TypedResults.Ok(await db.Todos.AsNoTracking().ToListAsync());
18}

Program.cs then boils down to app.MapTodos(); — one entry point per resource, everything else lives in cohesive files.

DbContext and migrations

EF Core remains the backbone of data access. You register the DbContext via AddDbContext , model in OnModelCreating , and above all never let the schema drift by hand: every change goes through a versioned migration.

C#
1builder.Services.AddDbContext<AppDbContext>(options =>
2 options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

You then generate the migration with dotnet ef migrations add InitialCreate , and apply it on startup with db.Database.MigrateAsync() — never EnsureCreated , which short-circuits the whole history. The official docs cover the workflow in the EF Core migrations guide .

Typed results and validation

This is where Minimal APIs really shine. Rather than returning an opaque IActionResult , you return a typed results union : the signature documents the possible HTTP codes, and OpenAPI exposes them automatically.

C#
1private static async Task<Results<Created<Todo>, ValidationProblem>> CreateAsync(
2 CreateTodoRequest request, AppDbContext db)
3{
4 if (string.IsNullOrWhiteSpace(request.Title))
5 {
6 return TypedResults.ValidationProblem(new Dictionary<string, string[]>
7 {
8 ["title"] = ["Title is required."],
9 });
10 }
11
12 var todo = new Todo { Title = request.Title };
13
14 db.Todos.Add(todo);
15 await db.SaveChangesAsync();
16
17 return TypedResults.Created($"/todos/{todo.Id}", todo);
18}

The return type Results<Created<Todo>, ValidationProblem> is self-documenting : no need for redundant [ProducesResponseType] attributes.

Keeping it testable

Once handlers are extracted into static methods that receive their dependencies as parameters, they become trivial to test without an HTTP server : you instantiate an AppDbContext on the in-memory or SQLite provider, call the handler, and inspect the TypedResults . For end-to-end integration tests, WebApplicationFactory<T> spins up the full application in memory and lets you hit the real endpoints.

A Minimal API isn't a budget API. Well split into groups and typed results, it exposes less ceremony for more guarantees — which is exactly what you want from a modern framework.
super-dev — portfolio.app
// More in .NET
{ }
Strangle a .NET 8 monolith without breaking everything
9 min • 3.2k reads
CQRS and vertical slices without the ceremony
7 min • 2.3k reads
.NET microservices with gRPC
9 min • 1.8k reads