Minimal API Workshop
Module 4
Performance, Resilience & Testing
caching · background work · resilience · health · tests
2:30 – 3:45 PM
By the end you can
- Cache safely at two layers
- Move slow work off the request path
- Make outbound calls resilient
- Add health checks and OpenTelemetry
- Test the real pipeline — including auth
Where the time goes
| 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.
Output caching — cache the response
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.
HybridCache — cache the data
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.
Move slow work off the request
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.
Outbound resilience — one line
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.
Testing the real thing
WebApplicationFactory<Program>boots the full app in memory- Tests go through HTTP, like real clients
- Swap SQLite to an in-memory connection
- A test auth handler acts as Staff or Customer
- The Module 3 policies run unchanged
Test authentication handler
// 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.
Lab
Lab 4 · Fast, Resilient & Proven
- Output + hybrid caching with tag eviction
- Background notifier · resilient payment client · health checks · OpenTelemetry
- Four integration tests —
dotnet teststays green
Ends at checkpoint-4