Lab 4: Fast, Resilient, and Proven
45 minutes · Starts from checkpoint-3 · Ends at checkpoint-4
Six steps, each independently useful. If time is short, the instructor will tell you which to skip; the tests in Step 6 are never skipped.
Step 1: Output caching on the menu
Add app.UseOutputCache(); after app.UseCors(...) and before the Map calls. Apply .CacheOutput("Products") to the public products group (the GETs, not the staff group).
In the staff CreateProduct, UpdateProduct, and DeleteProduct handlers, inject IOutputCacheStore cache and call await cache.EvictByTagAsync("products", ct); after SaveChangesAsync.
Send GET /api/products three times and watch X-Elapsed-Ms drop to zero and the Age header appear. Then update a product as staff and confirm the next GET is a miss.
Step 2: HybridCache for pricing
dotnet add package Microsoft.Extensions.Caching.Hybrid
Register AddHybridCache(). In CreateOrder, replace the direct db.Products lookup with a small IProductPricing service that uses HybridCache.GetOrCreateAsync per product id with the products tag. Add RemoveByTagAsync("products") beside the output-cache eviction in the staff handlers.
Step 3: Order-ready notifications in the background
Add OrderReadyMessage, the bounded Channel, and OrderReadyNotifier from the concepts page. In the status endpoint, after a successful transition to Ready:
if (order.Status == OrderStatus.Ready)
await channel.Writer.WriteAsync(new OrderReadyMessage(order.Id, customer.Email), ct);
Advance an order to Ready and watch the log line appear about half a second after the response returned.
Step 4: A resilient payment client
Start the fake provider in a second terminal:
cd src/Roastery.Payments && dotnet run
It listens on https://localhost:7281 and returns 503 for roughly 30% of requests.
dotnet add package Microsoft.Extensions.Http.Resilience
Create Services/PaymentClient.cs with a ChargeAsync(decimal amount, CancellationToken ct) method that posts to /charge. Register it:
builder.Services.AddHttpClient<PaymentClient>(c =>
c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!))
.AddStandardResilienceHandler();
Call it from CreateOrder before saving. Place several orders and watch the log: some show a retry, all succeed. Then stop the payments process, place an order, and watch the circuit open after repeated failures and the request fail fast with a 502 from your exception handler.
Step 5: Health and telemetry
builder.Services.AddHealthChecks().AddDbContextCheck<RoasteryDbContext>();
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new() { Predicate = _ => false });
Add the OpenTelemetry packages and a minimal registration (tracing and metrics for ASP.NET Core, HttpClient, and EF Core, exported over OTLP). The exact block is in snippets/otel.cs in the repo; paste it into Program.cs now, and in Module 5 you will move it into ServiceDefaults.
Hit /health and /alive. Rename the database file and hit /health again; it should report Unhealthy while /alive still says Healthy.
Step 6: Integration tests
Create the test project:
dotnet new xunit -n Roastery.Tests -o src/Roastery.Tests
cd src/Roastery.Tests
dotnet add reference ../Roastery.Api
dotnet add package Microsoft.AspNetCore.Mvc.Testing
Add public partial class Program { } at the bottom of the API's Program.cs so the test project can reference it.
Create RoasteryFactory : WebApplicationFactory<Program> with the in-memory SQLite substitution and the TestAuthHandler registration from the concepts page, and a small HttpClient extension AsStaff() / AsCustomer(int id) that sets the X-Test-User header.
Write these four tests:
[Fact] public async Task Anonymous_can_read_menu()
[Fact] public async Task Customer_cannot_change_order_status() // expect 403
[Fact] public async Task Staff_can_change_order_status() // expect 200
[Fact] public async Task Illegal_transition_returns_409_problem() // and body is application/problem+json
Run them:
dotnet test
success
If all four pass, you have proof that your authorization rules and your domain rules hold across the real pipeline. That proof is what lets you refactor tomorrow.
Stretch goals
- Add
GET /api/orders/{id}/eventsthat streams status changes withTypedResults.ServerSentEvents(...), fed by a second channel. Watch it in the browser. - Load-test the menu with
k6orbombardierbefore and after output caching and compare requests per second. - Add a test that places an order and asserts stock was decremented, using
TimeProviderto pinPlacedAt.
Common pitfalls
- Cache never hits.
UseOutputCacheis after the endpoints, or the request carries anAuthorizationheader (which is correct behavior). - Tests fail with a redirect. Set
AllowAutoRedirect = falseand disable HTTPS redirection in the test factory, or the401becomes a307. - Background service disappears. An exception escaped
ExecuteAsync. Check thetry/catcharound each message. No service for type IOutputCacheStore.AddOutputCache()is missing.
Checkpoint
git add -A && git commit -m "Lab 4 complete"
# or: git checkout checkpoint-4
Next: Module 5: Aspire, Containers, and Shipping