Module 2: Data, Validation, and the Shape of a Good API
10:45 AM – 12:00 PM · Concepts 25 min · Lab 45 min
Learning objectives
By the end of this module you can persist the domain with EF Core and SQLite, separate entities from contracts, validate input using the .NET 10 built-in validation support, return consistent ProblemDetails errors, and page a collection properly.
The DbContext
public class RoasteryDbContext(DbContextOptions<RoasteryDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(RoasteryDbContext).Assembly);
}
builder.Services.AddDbContext<RoasteryDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Roastery")));
Keep entity configuration out of the entities themselves. One IEntityTypeConfiguration<T> class per entity keeps the model readable and the entities free of persistence attributes.
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(o => o.Total).HasPrecision(10, 2);
builder.Property(o => o.Status).HasConversion<string>();
builder.HasMany(o => o.Lines).WithOne().HasForeignKey(l => l.OrderId);
builder.HasIndex(o => new { o.CustomerId, o.PlacedAt });
}
}
Migrations, even in a workshop
dotnet ef migrations add InitialCreate
dotnet ef database update
Migrations are the record of how your schema got to where it is. EnsureCreated() is faster to type and impossible to evolve. For development convenience, apply pending migrations at startup, but only in development:
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<RoasteryDbContext>();
await db.Database.MigrateAsync();
await Seed.RunAsync(db);
}
Entities are not contracts
Returning an entity from an endpoint couples your database schema to your public API and, with navigation properties, produces JSON cycles. Request and response types are records, and mapping is a few extension methods; no library is needed at this scale.
public record OrderDto(int Id, string Status, decimal Total, DateTimeOffset PlacedAt,
IReadOnlyList<OrderLineDto> Lines);
public record CreateOrderRequest(
[property: Required, MinLength(1)] List<OrderLineRequest> Lines);
public record OrderLineRequest(
[property: Range(1, int.MaxValue)] int ProductId,
[property: Range(1, 50)] int Quantity);
Validation in .NET 10
.NET 10 adds built-in validation for Minimal APIs. Turn it on once, and every handler parameter that carries DataAnnotations is validated before the handler runs:
builder.Services.AddValidation();
A failing request gets a 400 with a ValidationProblemDetails body listing each error by property name. For cross-field rules, implement IValidatableObject on the request record:
public record CreateOrderRequest(List<OrderLineRequest> Lines) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext _)
{
if (Lines.GroupBy(l => l.ProductId).Any(g => g.Count() > 1))
yield return new ValidationResult("Each product may appear only once.", [nameof(Lines)]);
}
}
tip
Validation runs on the handler's parameters. If you forget AddValidation(), the annotations are silently ignored and your handler receives whatever the client sent.
One error shape for everything
ProblemDetails (RFC 9457) is the standard error body, and ASP.NET Core can produce it for every failure path: binding errors, validation, exceptions, and the ones you return yourself.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// …
app.UseExceptionHandler();
app.UseStatusCodePages();
public class DomainExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not InvalidOrderTransitionException e) return false;
ctx.Response.StatusCode = StatusCodes.Status409Conflict;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx,
ProblemDetails = new()
{
Title = "Invalid order transition",
Detail = e.Message,
Status = StatusCodes.Status409Conflict
}
});
}
}
For errors you can see coming, return them directly rather than throwing: TypedResults.Problem(...) or TypedResults.ValidationProblem(...). The lab uses both approaches so you can compare them.
Paging and projection
Three rules keep list endpoints fast:
- Project before you materialize.
Selectinto the DTO, thenToListAsync. EF Core translates that into a query that fetches only the columns you need. - Read-only queries use
AsNoTracking(). Tracking costs memory and time and buys nothing when you are not going to callSaveChanges. - Never return an unbounded list. Take a
pageandpageSize, clamppageSize, and return a small envelope.
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);
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) * pageSize).Take(pageSize)
.Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.Total, o.PlacedAt))
.ToListAsync(ct);
The N+1 trap: loading orders, then touching order.Lines inside a loop, issues one query per order. Either Include the lines when you need them or project to a shape that includes them in one query.
Concurrency in one slide
Two baristas update the same order at the same time. Without a concurrency token the last write silently wins. Add one:
public byte[] RowVersion { get; set; } = [];
// configuration:
builder.Property(o => o.RowVersion).IsRowVersion();
On SQLite, IsRowVersion() needs a small trigger that the EF Core docs describe; the stretch goal walks through it. When a conflict happens, EF Core throws DbUpdateConcurrencyException, and you translate it to a 409.
Head to the lab
Lab 2: Real Orders in a Real Database