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

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Products", p => p
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("roast", "inStock")
        .Tag("products"));
});

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

Stretch goals

  • Add GET /api/orders/{id}/events that streams status changes with TypedResults.ServerSentEvents(...), fed by a second channel. Watch it in the browser.
  • Load-test the menu with k6 or bombardier before and after output caching and compare requests per second.
  • Add a test that places an order and asserts stock was decremented, using TimeProvider to pin PlacedAt.

Common pitfalls

  • Cache never hits. UseOutputCache is after the endpoints, or the request carries an Authorization header (which is correct behavior).
  • Tests fail with a redirect. Set AllowAutoRedirect = false and disable HTTPS redirection in the test factory, or the 401 becomes a 307.
  • Background service disappears. An exception escaped ExecuteAsync. Check the try/catch around 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