Module 4: Performance, Resilience, and Testing
2:30 – 3:45 PM · Concepts 25 min · Lab 45 min
Learning objectives
By the end of this module you can cache safely at two layers, move slow work off the request path, make outbound calls resilient, add health checks and traces, and write integration tests that exercise the real pipeline including authentication.
Where the time actually goes
Before caching anything, measure. The X-Elapsed-Ms header from Lab 1 is still there. The menu endpoint on the instructor's machine:
The first two rows are the same HTTP contract; the difference is what EF Core is asked to do. Caching is what you reach for after the query is already good.
Output caching: cache the response
Output caching stores the whole HTTP response and serves it without running your handler.
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", p => p
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("roast", "inStock")
.Tag("products"));
});
// …
app.UseOutputCache(); // after UseRouting/UseCors, before endpoints
products.CacheOutput("Products"); // on the public group
When a barista changes a product, evict everything with the tag:
await cache.EvictByTagAsync("products", ct); // IOutputCacheStore
Two rules the framework enforces for you: responses to requests carrying an Authorization header are not cached, and only GET and HEAD are cached. Both are the right default; caching a customer's order list and serving it to another customer is the kind of bug that ends careers.
HybridCache: cache the data
HybridCache caches inside a handler, for data you need in the middle of a request: the product prices used to total an order, for example. It combines an in-process L1 with an optional distributed L2, and it protects against cache stampedes, where a hundred concurrent misses all hit the database at once.
builder.Services.AddHybridCache();
// in the handler:
var product = await cache.GetOrCreateAsync(
$"product:{id}",
async ct => await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5) },
tags: ["products"],
cancellationToken: ct);
Use output caching for whole public responses. Use HybridCache for lookups inside authenticated handlers. Roastery uses both, and they share the products tag so one eviction clears both.
Move slow work off the request
When an order becomes Ready, the customer gets a notification. Sending it inside the PUT /status request makes the barista wait on an email provider. Instead, drop a message on a channel and let a background service do the work:
public record OrderReadyMessage(int OrderId, string CustomerEmail);
builder.Services.AddSingleton(Channel.CreateBounded<OrderReadyMessage>(100));
builder.Services.AddHostedService<OrderReadyNotifier>();
public class OrderReadyNotifier(Channel<OrderReadyMessage> channel, ILogger<OrderReadyNotifier> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
log.LogInformation("Order {OrderId} is ready; notifying {Email}", message.OrderId, message.CustomerEmail);
await Task.Delay(500, stoppingToken); // stand-in for the real send
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "Failed to notify for order {OrderId}", message.OrderId);
}
}
}
}
warning
An unhandled exception inside ExecuteAsync stops the background service, and by default stops the host. The try/catch around each message is not optional.
For operations whose result the client needs, return 202 Accepted with a Location the client can poll. For fire-and-forget side effects like this one, the request completes normally.
Outbound resilience
Roastery charges the card through a payment provider. The workshop's Roastery.Payments project fails 30% of the time on purpose. One line gives the typed client a retry, a circuit breaker, and a timeout:
dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient<PaymentClient>(c => c.BaseAddress = new Uri("https://localhost:7281"))
.AddStandardResilienceHandler();
Every retry shows up as a span in the trace, which is how you will see it in the Aspire dashboard in Module 5.
Health checks and telemetry
builder.Services.AddHealthChecks()
.AddDbContextCheck<RoasteryDbContext>();
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new() { Predicate = _ => false }); // liveness: is the process up?
/alive runs no checks and answers "the process is running." /health runs all of them and answers "the process can do its job." Orchestrators use the first to decide whether to restart you and the second to decide whether to send you traffic.
OpenTelemetry for ASP.NET Core and HttpClient is a handful of registrations. The lab puts them in place directly; in Module 5 they move into the ServiceDefaults project, which is the conventional Aspire home for exactly this code.
Testing the real thing
WebApplicationFactory<Program> boots the full application in memory, pipeline and all, and gives you an HttpClient pointed at it. Tests go through HTTP, the way clients do, so routing, binding, validation, filters, and authorization are all exercised.
Two substitutions make it practical:
A test database. SQLite in-memory mode, with a connection kept open for the life of the factory:
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<RoasteryDbContext>>();
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
services.AddDbContext<RoasteryDbContext>(o => o.UseSqlite(_connection));
});
A test authentication handler. Rather than issuing real JWTs in tests, register a scheme that reads the desired identity from a header:
public class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> o, ILoggerFactory l, UrlEncoder e)
: AuthenticationHandler<AuthenticationSchemeOptions>(o, l, e)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-User", out var spec))
return Task.FromResult(AuthenticateResult.NoResult());
// "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.ToString().Split(':')[1]));
var identity = new ClaimsIdentity(claims, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
}
The policies from Module 3 run unchanged against whatever identity the header describes, so the tests verify the real authorization rules.
Head to the lab
Lab 4: Fast, Resilient, and Proven