Minimal API Workshop
Module 2
Data, Validation & the Shape of a Good API
EF Core · DTOs · validation · ProblemDetails · paging
10:45 AM – 12:00 PM
By the end you can
- Persist the domain with EF Core and SQLite
- Separate entities from contracts (DTOs)
- Validate input with the .NET 10 built-in support
- Return one consistent
ProblemDetailserror shape - Page a collection without the N+1 trap
The DbContext
public class RoasteryDbContext(DbContextOptions<RoasteryDbContext> options)
: DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
}
builder.Services.AddDbContext<RoasteryDbContext>(o =>
o.UseSqlite(builder.Configuration.GetConnectionString("Roastery")));
Migrations — even in a workshop
dotnet ef migrations add InitialCreatedotnet ef database update- Apply pending migrations at startup in Development only
EnsureCreated()is a dead end — you can't evolve it
Entities are not contracts
public record OrderDto(int Id, string Status, decimal Total,
DateTimeOffset PlacedAt, IReadOnlyList<OrderLineDto> Lines);
public record CreateOrderRequest(
[property: Required, MinLength(1)] List<OrderLineRequest> Lines);
DTOs decouple schema from contract, prevent JSON cycles, and close OWASP API3.
Validation in .NET 10
builder.Services.AddValidation(); // turn it on once
// annotations on the request record are now enforced;
// a failure returns 400 ValidationProblemDetails.
public record CreateOrderRequest(List<OrderLineRequest> Lines)
: IValidatableObject // for cross-field rules
{ /* ... */ }
Forget AddValidation() and the annotations are silently ignored.
One error shape: ProblemDetails
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// ...
app.UseExceptionHandler();
app.UseStatusCodePages();
Binding errors, validation, domain exceptions, unhandled crashes — all application/problem+json.
Paging & projection
var query = db.Orders.AsNoTracking()
.Where(o => status == null || o.Status == status)
.OrderByDescending(o => o.PlacedAt);
var total = await query.CountAsync(ct);
var items = await query.Skip((page-1)*size).Take(size)
.Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.Total, o.PlacedAt))
.ToListAsync(ct);
Project before you materialize · AsNoTracking for reads · never an unbounded list.
Concurrency in one slide
- Two baristas update the same order at once
- Add a
RowVersionconcurrency token - EF throws
DbUpdateConcurrencyException - Translate it to a 409 Conflict
Lab
Lab 2 · Real Orders in a Real Database
- Swap the in-memory catalog for EF Core — the HTTP contract doesn't change
- Place orders with validation and server-side totals
- Enforce the lifecycle; illegal transitions return a 409
ProblemDetails
Ends at checkpoint-2