A full-day, hands-on workshop
9:00 AM – 5:00 PM
Roastery — an ordering API for a small coffee roaster
| Time | Module |
|---|---|
| 9:20 | 1 · Fundamentals & the endpoint pipeline |
| 10:45 | 2 · Data, validation & the shape of a good API |
| 1:00 | 3 · Identity, tokens & authorization |
| 2:30 | 4 · Performance, resilience & testing |
| 3:45 | 5 · Aspire, containers & shipping |
Morning break 10:30 · Lunch 12:00 · Afternoon break 2:15 · Wrap-up 4:40
git checkout checkpoint-N and rejoin the groupPending -> Roasting -> Ready -> PickedUp
|
+-------> Cancelled
The API enforces every transition. Illegal moves return 409.
.http files have every request ready to sendcd src/00-start then dotnet runhttps://localhost:7181/health → HealthyEnds at let's build
Route groups · binding · TypedResults · filters · OpenAPI
9:20 – 10:30 AM
Four parts — every line belongs to one
var builder = WebApplication.CreateBuilder(args); // 1. builder
builder.Services.AddOpenApi(); // 2. services (DI)
var app = builder.Build();
app.UseHttpsRedirection(); // 3. pipeline
app.MapGet("/health", () => "Healthy"); // 4. endpoints
app.Run();
“Minimal” = fewer abstractions between you and HTTP, not fewer capabilities.
| Controller world | Minimal API world |
|---|---|
[Route("api/[controller]")] | app.MapGroup("/api/products") |
[HttpGet("{id}")] | group.MapGet("/{id}", handler) |
ActionResult<T> | Results<Ok<T>, NotFound> |
| Action filters | Endpoint filters |
[Authorize] | .RequireAuthorization() |
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products").WithTags("Products");
group.MapGet("/", GetProducts).WithName("GetProducts");
group.MapGet("/{id:int}", GetProduct).WithName("GetProduct");
group.MapPost("/", CreateProduct).WithName("CreateProduct");
return group;
}
Anything applied to the group — a tag, filter, policy, cache — applies to every endpoint in it.
| Source | How it binds |
|---|---|
Route /{id} | Parameter with a matching name |
| Query string | Simple types not matched by a route value |
| Body | A complex type on POST / PUT / PATCH |
| Header | [FromHeader] |
| Services | Any registered service |
| Special | HttpContext, CancellationToken, ClaimsPrincipal |
Bind a group of query values into one record with [AsParameters].
static async Task<Results<Ok<ProductDto>, NotFound>> GetProduct(int id, IProductCatalog catalog)
{
var product = await catalog.FindAsync(id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product.ToDto());
}
The Results<…> union is the contract: compile-time correctness and OpenAPI for free.
group.AddEndpointFilter(async (context, next) =>
{
var sw = Stopwatch.StartNew();
var result = await next(context);
context.HttpContext.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
return result;
});
Middleware runs outside filters; filters run outside the handler — and see the bound arguments.
builder.Services.AddOpenApi();
// ...
app.MapOpenApi(); // /openapi/v1.json
app.MapScalarApiReference(); // /scalar/v1 (browsable UI)
Built in — no Swashbuckle. Metadata flows from TypedResults, .WithName, .WithSummary.
/api/products with filters, a single-item GET, and createTypedResults; add a timing endpoint filterProduces bugEnds at checkpoint-1
EF Core · DTOs · validation · ProblemDetails · paging
10:45 AM – 12:00 PM
ProblemDetails error shapepublic 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")));
dotnet ef migrations add InitialCreatedotnet ef database updateEnsureCreated() is a dead end — you can't evolve itpublic 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.
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.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// ...
app.UseExceptionHandler();
app.UseStatusCodePages();
Binding errors, validation, domain exceptions, unhandled crashes — all application/problem+json.
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.
RowVersion concurrency tokenDbUpdateConcurrencyExceptionProblemDetailsEnds at checkpoint-2
Identity · JWT · policies · resource authz · hardening
1:00 – 2:15 PM
Who are you? vs. May you do this?
app.UseAuthentication(); // validate token -> ClaimsPrincipal
app.UseAuthorization(); // evaluate policies against it
app.MapProductEndpoints(); // endpoints come after both
// no principal -> 401 Unauthorized
// principal, denied -> 403 Forbidden
role + customer_idbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
{
ValidIssuer = jwt.Issuer, ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ClockSkew = TimeSpan.FromSeconds(30),
});
Signing key lives in user secrets / env — never appsettings.json. HS256 needs ≥ 32 bytes.
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Staff", p => p.RequireRole("Staff"))
.AddPolicy("OwnsOrder", p => p.AddRequirements(new OwnsOrderRequirement()));
var staff = products.MapGroup("/").RequireAuthorization("Staff");
staff.MapPost("/", CreateProduct); // and PUT, DELETE
Policy on the group → new endpoints are protected by default. Fail safe.
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx, OwnsOrderRequirement req, Order order)
{
if (ctx.User.IsInRole("Staff") ||
ctx.User.FindFirstValue("customer_id") == order.CustomerId.ToString())
ctx.Succeed(req);
return Task.CompletedTask;
}
Return 404 (not 403) for another customer's order — a 403 confirms it exists.
user.FindFirstValue("customer_id")customerId in the body = order as someone else| Concern | What you add |
|---|---|
| Brute force on login | Rate limiter, fixed window on /api/auth/* |
| Abuse elsewhere | Global sliding-window limiter |
| Cross-origin calls | CORS with an explicit origin list |
| Transport | HTTPS redirection + HSTS |
| Response headers | nosniff, Referrer-Policy, remove Server |
customer_id claimsEnds at checkpoint-3
caching · background work · resilience · health · tests
2:30 – 3:45 PM
| Version | Elapsed |
|---|---|
| Tracking query, entities, mapped in memory | ~9 ms |
| AsNoTracking + projection (Module 2) | ~3 ms |
| Output cached | < 1 ms |
Measure first. Caching is what you reach for after the query is already good.
builder.Services.AddOutputCache(o => o.AddPolicy("Products", p => p
.Expire(TimeSpan.FromMinutes(5)).SetVaryByQuery("roast","inStock").Tag("products")));
// ...
products.CacheOutput("Products");
await cache.EvictByTagAsync("products", ct); // when a product changes
Authenticated responses are never cached — that's the right default.
builder.Services.AddHybridCache();
var product = await cache.GetOrCreateAsync($"product:{id}",
async ct => await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct),
tags: ["products"], cancellationToken: ct);
In-process L1 (+ optional L2), with stampede protection. Used for pricing inside a handler.
builder.Services.AddSingleton(Channel.CreateBounded<OrderReadyMessage>(100));
builder.Services.AddHostedService<OrderReadyNotifier>();
// in the status handler, when the order becomes Ready:
await channel.Writer.WriteAsync(new OrderReadyMessage(order.Id, email), ct);
A Channel + BackgroundService. Wrap each message in try/catch — an escape stops the host.
builder.Services.AddHttpClient<PaymentClient>(c => c.BaseAddress = new(paymentsUrl))
.AddStandardResilienceHandler();
// retry (backoff+jitter) · circuit breaker · timeout · rate limiter
The fake payment provider fails ~30% on purpose. Every retry is a span you'll see in Aspire.
WebApplicationFactory<Program> boots the full app in memory// X-Test-User: "staff" or "customer:1"
var claims = new List<Claim> { new(ClaimTypes.Name, spec) };
if (spec == "staff") claims.Add(new(ClaimTypes.Role, "Staff"));
else claims.Add(new("customer_id", spec.Split(':')[1]));
return AuthenticateResult.Success(new(new ClaimsPrincipal(id), Scheme));
Real JWTs aren't needed in tests — the header describes the identity; the policies do the rest.
dotnet test stays greenEnds at checkpoint-4
AppHost · dashboard · containers · versioning
3:45 – 4:40 PM
azd deployvar builder = DistributedApplication.CreateBuilder(args);
var payments = builder.AddProject<Projects.Roastery_Payments>("payments");
builder.AddProject<Projects.Roastery_Api>("api")
.WithReference(payments)
.WithExternalHttpEndpoints();
builder.Build().Run();
WithReference injects the payment address; service discovery resolves https://payments.
builder.AddServiceDefaults(); // OTel, health, discovery, resilience
// ...
app.MapDefaultEndpoints(); // /health and /alive
One extension every service calls. The OTel + health code from Lab 4 moves here.
payments, the retry spansOrderReadyNotifier log, correlated by trace iddotnet publish src/Roastery.Api -c Release \
--os linux --arch x64 /t:PublishContainer
# base image, non-root user, port 8080 — all chosen for you
docker run -p 8080:8080 -e Jwt__SigningKey=$KEY roastery-api
Double-underscore env vars map to config sections. Same code, laptop → container → cloud.
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1);
o.ReportApiVersions = true;
});
var v1 = app.MapGroup("/api/v{version:apiVersion}")
.WithApiVersionSet(versions).MapToApiVersion(1);
v1.MapProductEndpoints();
URLs become /api/v1/…; a v2 endpoint lives beside v1 with no change to v1's code.
| Target | How |
|---|---|
| Azure Container Apps | azd up from the Aspire manifest |
| Kubernetes | Aspire manifest → manifests / Helm |
| Any container host | The image + environment variables |
The instructor plays a recorded azd up — conference Wi-Fi has ended more demos than bad code.
Ends at checkpoint-5
Architecture review · retrospective · where to go next
4:40 – 5:00 PM