MS Stack Ch 5 — ASP.NET Core
Minimal hosting, dependency injection, middleware, routing, configuration, options, model binding, ProblemDetails, BackgroundService, health checks. The web framework you'll spend the next decade in.
Chapter 5 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
ASP.NET Core is the web framework Microsoft bet the .NET ecosystem on. It powers Stack Overflow, Bing, Office Online, GitHub internal services, and most of Azure's first-party APIs. Every backend you write on the MS stack will use it. The framework's surface area is wide — minimal hosting, DI, middleware, routing, configuration, options, model binding, validation, auth, ProblemDetails, hosted services, health checks — but each piece is small once you grasp the others. This chapter is the production-grade ASP.NET Core you will write into the capstone and every job after.
The big shift since 2020 was the minimal hosting model: one Program.cs file with WebApplication.CreateBuilder(args) replaces the old Startup.cs + Program.cs pair. The minimal API surface (app.MapGet("/api/users/{id}", ...)) sits alongside the traditional MVC controllers — pick per-endpoint, not per-app. Almost every other feature (DI, configuration, middleware, options) is shared between the two and works the same way.
Shipping-tier means you can stand up a production-shaped API in an hour: minimal hosting, correct DI lifetimes, middleware in the right order, IOptionsSnapshot bound and validated, ProblemDetails for errors, live and ready health checks, and no secrets in appsettings.json. Expert-tier means you have written a custom IConfigurationProvider, a custom IAuthorizationHandler, tuned Kestrel limits, debugged a captive-dependency bug from a memory dump, and used source-generated JSON serialisation for AOT.
You finish this chapter when you can read any ASP.NET Core repo at a glance, reason about service lifetimes without thinking, place a new middleware in the right pipeline position by feel, and explain why UseAuthentication must come before UseAuthorization.
Concepts and depth
Minimal hosting — WebApplication.CreateBuilder + Services + Build + Use/Map/Run
The modern entry point is one file:
var builder = WebApplication.CreateBuilder(args);
// 1. Register services
builder.Services.AddProblemDetails();
builder.Services.AddScoped<IUserRepository, UserRepository>();
// 2. Build the app
var app = builder.Build();
// 3. Middleware pipeline (order matters)
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// 4. Endpoints
app.MapGet("/api/users/{id:guid}", async (Guid id, IUserRepository repo, CancellationToken ct) =>
await repo.GetAsync(id, ct) is { } u ? Results.Ok(u) : Results.NotFound())
.RequireAuthorization();
app.Run();
CreateBuilder(args) wires defaults: logging (console + debug), configuration (appsettings + env vars + command line + user secrets in Dev), Kestrel as the in-process server, the DI container, and the host. builder.Services is the DI registration phase — register everything before Build(). app.Use* registers middleware in execution order. app.Map* declares endpoints. app.Run() starts the host and blocks until shutdown.
The shape repeats for every ASP.NET Core app you will write. The same builder is used by background-worker hosts (Host.CreateApplicationBuilder) and Azure Functions — register, build, run.
Kestrel as the in-process server
Kestrel is the cross-platform, in-process HTTP server. It is the default in ASP.NET Core and is plenty fast for production — TechEmpower benchmarks consistently rank it among the top three HTTP servers in any language. Kestrel supports HTTP/1.1, HTTP/2, HTTP/3 (over QUIC), TLS 1.3, and Unix domain sockets. Configure via appsettings.json or in Program.cs:
builder.WebHost.ConfigureKestrel(o =>
{
o.Limits.MaxConcurrentConnections = 1000;
o.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
o.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(10);
o.ListenAnyIP(5001, listen => listen.UseHttps());
});
In production behind Azure App Service, IIS, or NGINX, the reverse proxy terminates TLS and forwards to Kestrel over HTTP — but Kestrel still handles the request lifecycle. YARP (Yet Another Reverse Proxy) is the .NET-native option if you need an in-process reverse proxy.
Configuration — sources, precedence, IConfiguration
Configuration is a layered key-value store. Sources are added in order; later sources override earlier ones for the same key. The default chain is: appsettings.json → appsettings.{Environment}.json → user secrets (Dev only) → environment variables → command-line args. Add more (Azure Key Vault, Azure App Configuration, custom IConfigurationProvider) in builder.Configuration.
Nested keys use : (or __ in environment variables, since : is invalid in some shells):
{ "Db": { "ConnectionString": "...", "CommandTimeout": 30 } }
export Db__ConnectionString="Server=..."
export Db__CommandTimeout=60
IConfiguration is the runtime API — config["Db:ConnectionString"], config.GetSection("Db"). For typed access, strongly-typed options are the right answer — see the next concept.
Environment names and appsettings..json
ASPNETCORE_ENVIRONMENT (typically Development, Staging, Production) selects the per-environment overlay. appsettings.Development.json overrides appsettings.json when env is Development. The framework provides convenience predicates: builder.Environment.IsDevelopment(), IsProduction(). Use them sparingly — most code should be environment-agnostic with config differences expressed via options. Per-developer machine overrides live in appsettings.Development.local.json (gitignored) or user-secrets.
Dependency injection — built-in container, lifetimes, captive dependency, factory + keyed
ASP.NET Core ships a built-in DI container (Microsoft.Extensions.DependencyInjection). Every service is registered with a lifetime:
- Singleton — one per app. Created once; same instance for every consumer. Must be thread-safe. Use for caches,
HttpClientfactories, config-derived state, expensive resources. - Scoped — one per request scope. Created when the scope starts; disposed when the scope ends. Same instance for everyone in the same request. Use for
DbContext, repositories, per-user state. - Transient — new every time. Resolved fresh on every injection point. Disposed when the enclosing scope ends. Use for stateless helpers.
Factory registrations (services.AddSingleton<IClient>(sp => new Client(sp.GetRequiredService<IConfiguration>()["Url"]))) let you compute the instance at registration time. Keyed services (.NET 8+) let you register multiple implementations of the same interface and resolve by key:
services.AddKeyedSingleton<IGreeter, EnglishGreeter>("en");
services.AddKeyedSingleton<IGreeter, SpanishGreeter>("es");
app.MapGet("/hi/{lang}", ([FromKeyedServices("en")] IGreeter en) => en.Hi());
Multiple registrations of the same key replace each other; resolving IEnumerable<IGreeter> returns all registrations.
- • Pick lifetimes deliberately
- • Constructor injection only
- • Factory registrations for config-derived state
- • Keyed services for strategy patterns
- •
IServiceProviderIsServicefor optional dependencies - • Custom
IServiceProviderFactoryfor Autofac / DryIoc integration
IOptions / IOptionsSnapshot / IOptionsMonitor
Three flavours of strongly-typed options:
IOptions<T>— singleton snapshot at app start; never reloads. Inject into singletons safely. Use for stable config.IOptionsSnapshot<T>— scoped; re-bound per request scope if config changed. Inject into scoped/transient services. Use for most app config.IOptionsMonitor<T>— singleton with change notifications. Subscribe viaOnChange. Use for background services that react to config changes.
services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection("Jwt"))
.ValidateDataAnnotations()
.Validate(o => Uri.IsWellFormedUriString(o.Authority, UriKind.Absolute), "Bad authority")
.ValidateOnStart();
ValidateOnStart() fails fast at startup if validation fails — better than discovering at the first request. Pair with DataAnnotations ([Required], [Url]) on the options POCO for declarative checks.
Middleware — pipeline order, Use vs Run vs Map, custom middleware
Middleware is a chain of RequestDelegate functions. Each one receives the HttpContext and a next delegate; it can do work before, around, and after calling next. The order in Program.cs is the execution order on the way in, and the reverse on the way out.
app.UseExceptionHandler(); // wraps everything; catches throws from later middleware
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles(); // before routing — static files short-circuit
app.UseRouting(); // match endpoint into HttpContext.GetEndpoint()
app.UseCors("spa"); // between routing and auth
app.UseAuthentication(); // populate HttpContext.User
app.UseAuthorization(); // enforce policies on the matched endpoint
app.UseEndpoints(_ => { }); // or app.MapGet / MapGroup directly
app.Use is the standard form. app.Run is a terminal middleware that never calls next. app.Map("/admin", branch => ...) branches the pipeline on a path prefix — useful for mounting a sub-app. app.UseWhen(ctx => ..., branch => ...) is a conditional branch that rejoins.
Custom middleware can be a lambda (app.Use(async (ctx, next) => { ... await next(ctx); ... })) or an IMiddleware class for DI-friendly state and testability.
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
try { await next(ctx); }
finally
{
sw.Stop();
ctx.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
}
});
Routing — conventional vs attribute, templates, parameters, constraints, route order
ASP.NET Core has two routing paradigms. Attribute routing (controllers + minimal APIs) declares the route on the endpoint: [HttpGet("/api/users/{id:guid}")] or app.MapGet("/api/users/{id:guid}", ...). Conventional routing (MapControllerRoute("default", "{controller}/{action}/{id?}")) is the MVC legacy form, rarely used in new APIs.
Route templates support parameters ({id}), constraints ({id:guid}, {n:int:min(1)}, {slug:alpha:length(3,50)}, {type:regex(^a|b|c$)}), optional parameters ({id?}), catch-all ({**rest}), and default values ({lang=en}). Constraints run before the handler, so malformed input becomes 404 instead of 500 inside binding.
Route order matters when templates overlap: ASP.NET Core picks the most specific match (literal segments > constrained parameters > unconstrained parameters > catch-all). When two routes are equally specific, attribute order in source decides — but this is fragile; design routes so they cannot ambiguously match.
var users = app.MapGroup("/api/users").RequireAuthorization();
users.MapGet("/", async (IUserRepository repo, CancellationToken ct) => await repo.ListAsync(ct));
users.MapGet("/{id:guid}", async (Guid id, IUserRepository repo, CancellationToken ct) =>
await repo.GetAsync(id, ct) is { } u ? Results.Ok(u) : Results.NotFound());
users.MapPost("/", async (CreateUser dto, IUserRepository repo, CancellationToken ct) =>
Results.Created($"/api/users/{(await repo.CreateAsync(dto, ct)).Id}", dto));
Controllers vs minimal APIs — trade-offs
Minimal APIs are the new default for most projects. Lower ceremony (one file per group, no controller class), faster (no MVC pipeline overhead), excellent for microservices. They support all the same features (DI, model binding, validation, filters via EndpointFilter, OpenAPI). The ergonomics are weaker for: deep model binding with complex [Bind] semantics, action filters that need to wrap many endpoints with shared state, and server-rendered MVC views.
MVC controllers are fully supported and remain idiomatic for: API surfaces with rich filter pipelines, file-upload-heavy endpoints, MVC server views (Razor Pages and Razor Views), and teams already invested in the controller pattern. Both can coexist in the same app — pick per endpoint group, not per app.
Model binding — [FromBody], [FromQuery], [FromRoute], [FromForm]
Binding decides where each handler parameter comes from. For minimal APIs, ASP.NET Core infers: route parameters first, then bind primitives from query string, then a single complex type from the body, services from DI. Use explicit attributes ([FromBody], [FromQuery], [FromRoute], [FromForm], [FromHeader], [FromServices], [FromKeyedServices(key)]) when inference is ambiguous or you want to be explicit.
For controllers, the same attributes apply; the inference rules are slightly different (model binding looks at all sources for complex types unless you say otherwise). For [FromForm] and file uploads, use IFormFile/IFormFileCollection from Microsoft.AspNetCore.Http. For streaming large uploads, prefer reading from HttpContext.Request.Body directly — model binding loads the whole body.
Validation — data annotations, FluentValidation
Two approaches. Data annotations are attributes on the model: [Required], [EmailAddress], [StringLength(100, MinimumLength = 2)], [Range(1, 100)], [RegularExpression(...)]. The framework runs them on bound models in controllers automatically (set ApiBehaviorOptions.SuppressModelStateInvalidFilter = false to also auto-return 400 with errors). For minimal APIs you need to wire it manually or use a small library.
FluentValidation is a separate library that defines validators as code:
public class CreateUserValidator : AbstractValidator<CreateUser>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().Length(2, 100);
RuleFor(x => x.BirthDate).LessThan(DateOnly.FromDateTime(DateTime.UtcNow));
}
}
app.MapPost("/api/users", async (CreateUser dto, IValidator<CreateUser> v, IUserRepository repo, CancellationToken ct) =>
{
var r = await v.ValidateAsync(dto, ct);
if (!r.IsValid) return Results.ValidationProblem(r.ToDictionary());
var created = await repo.CreateAsync(dto, ct);
return Results.Created($"/api/users/{created.Id}", created);
});
Use FluentValidation for any non-trivial validation (cross-field rules, async checks, conditional rules). Data annotations are fine for simple flat DTOs.
Authentication vs authorization — schemes vs handlers vs policies
Authentication answers "who are you?" — a scheme (JWT Bearer, cookies, OpenID Connect, API key) plus a handler that validates credentials and produces a ClaimsPrincipal attached to HttpContext.User. Register one or more schemes; the default scheme runs unless an endpoint specifies otherwise.
Authorization answers "are you allowed?" — a policy (one or more IAuthorizationRequirements and matching IAuthorizationHandlers) attached to endpoints. Built-in primitives: roles ([Authorize(Roles = "Admin")]), schemes ([Authorize(AuthenticationSchemes = "Bearer")]), policies ([Authorize(Policy = "UsersRead")]). For anything beyond roles, write a custom policy:
builder.Services.AddAuthorization(opts =>
{
opts.AddPolicy("UsersRead", p => p.RequireClaim("scope", "users.read"));
});
app.MapGet("/api/users", ...).RequireAuthorization("UsersRead");
Combine schemes for hybrid apps (cookie for SPA, Bearer for service-to-service). Reach for IAuthorizationHandler for resource-based authorization (e.g., "user can edit this specific note") — the handler receives the resource as context.
Exception handling — UseExceptionHandler, ProblemDetails RFC 7807
builder.Services.AddProblemDetails(opts =>
{
opts.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
};
});
app.UseExceptionHandler(); // converts unhandled exceptions to ProblemDetails JSON
app.UseStatusCodePages(); // converts bare status codes to ProblemDetails JSON
UseExceptionHandler (with no path) plus AddProblemDetails is the modern wiring. Unhandled exceptions become RFC 7807 problem documents:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"instance":"/api/users/abc",
"traceId": "00-..."
}
Standardised, machine-parseable, plays well with every modern API client. For specific exceptions, write an IExceptionHandler (.NET 8+) that returns a tailored problem (e.g., 409 for DbUpdateConcurrencyException).
Static files, default files, SPA fallback
app.UseDefaultFiles(); // serve index.html for /
app.UseStaticFiles(); // serve /css/*, /js/*, etc.
app.MapFallbackToFile("index.html"); // any unmatched route → SPA shell
UseStaticFiles serves anything under wwwroot/ by default. UseDefaultFiles rewrites / to /index.html. MapFallbackToFile is the SPA pattern: any GET that did not match an API endpoint returns the SPA shell, letting the client-side router take over. Combine with MapWhen(ctx => !ctx.Request.Path.StartsWithSegments("/api"), branch => ...) to keep static assets and SPA separate from the API surface.
For production, set cache headers on static assets: app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable" }); — works because content-hashed filenames change on every build.
Hosted services — IHostedService, BackgroundService, PeriodicTimer
IHostedService is the lifecycle interface for long-running background work that lives alongside HTTP serving — same DI container, same lifetime, same shutdown signal. BackgroundService is the abstract base that simplifies the common case:
public class QueueProcessor(IQueueClient queue, ILogger<QueueProcessor> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var msg = await queue.ReceiveAsync(stoppingToken);
try
{
await Process(msg, stoppingToken);
await queue.DeleteAsync(msg, stoppingToken);
}
catch (Exception ex)
{
log.LogError(ex, "Failed to process {Id}", msg.Id);
}
}
}
}
builder.Services.AddHostedService<QueueProcessor>();
Honour stoppingToken so graceful shutdown works — no abrupt mid-message kills. For periodic work, PeriodicTimer (.NET 6+) is the modern, allocation-free choice:
var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
await RefreshCachesAsync(stoppingToken);
IHttpContextAccessor and when not to use it
IHttpContextAccessor exposes the current HttpContext from anywhere — useful in libraries that cannot accept it as a parameter. The catch: it relies on AsyncLocal under the hood, which adds a small per-request cost, and using it in singletons creates the classic "current request" leak across threads.
Default to NOT injecting it. Pass what you need from HttpContext (the user, the cancellation token, the trace ID) as explicit parameters. Reach for IHttpContextAccessor only when you are writing a cross-cutting framework component (logging enricher, telemetry processor) that genuinely cannot get the context any other way.
Health checks — ready vs live probes
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddSqlServer(builder.Configuration["Db:Conn"]!, tags: new[] { "ready" })
.AddAzureBlobStorage(builder.Configuration["Storage:Url"]!, tags: new[] { "ready" });
app.MapHealthChecks("/health/live", new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
Liveness = "is the process alive?" — a failed liveness probe makes Kubernetes restart the pod or App Service restart the instance. Keep it minimal — do not check dependencies; a transient DB blip should not restart your app. Readiness = "can I take traffic?" — a failed readiness probe takes the instance out of the load-balancer pool but does not restart it. Check critical dependencies (DB, blob, downstream APIs).
Kubernetes and App Service warmup care about the distinction. Get it right and rolling deploys are smooth; get it wrong and a single bad downstream takes down every instance.
Worked examples
1. A production-shaped Notes API in one file
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());
builder.Services.AddProblemDetails();
builder.Services.AddCors(o => o.AddPolicy("spa", p => p
.WithOrigins("https://app.example.com").AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => builder.Configuration.Bind("Jwt", o));
builder.Services.AddAuthorization(opts =>
{
opts.AddPolicy("NotesWrite", p => p.RequireClaim("scope", "notes.write"));
});
builder.Services.AddOptions<DbOptions>()
.Bind(builder.Configuration.GetSection("Db"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddScoped<INoteRepository, NoteRepository>();
builder.Services.AddValidatorsFromAssemblyContaining<CreateNoteValidator>();
builder.Services.AddHealthChecks().AddSqlServer(builder.Configuration["Db:ConnectionString"]!, tags: new[] { "ready" });
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseCors("spa");
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health/live", new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
var notes = app.MapGroup("/api/notes").RequireAuthorization();
notes.MapGet("/", async (INoteRepository repo, CancellationToken ct) => await repo.ListAsync(ct));
notes.MapGet("/{id:guid}", async (Guid id, INoteRepository repo, CancellationToken ct) =>
await repo.GetAsync(id, ct) is { } n ? Results.Ok(n) : Results.NotFound());
notes.MapPost("/", async (CreateNote dto, IValidator<CreateNote> v, INoteRepository repo, CancellationToken ct) =>
{
var r = await v.ValidateAsync(dto, ct);
if (!r.IsValid) return Results.ValidationProblem(r.ToDictionary());
var created = await repo.CreateAsync(dto, ct);
return Results.Created($"/api/notes/{created.Id}", created);
}).RequireAuthorization("NotesWrite");
app.Run();
What to notice:
Serilogconfigured fromappsettings.jsonviaReadFrom.Configuration.- ProblemDetails + ExceptionHandler give RFC 7807 errors for every unhandled throw.
MapGroupsharesRequireAuthorization()across child endpoints.RequireAuthorization("NotesWrite")overrides the group's default with a stricter policy on POST.ValidateOnStart()makes the app refuse to start with bad config — fail fast.
2. A custom timing middleware as an IMiddleware class
public class TimingMiddleware(ILogger<TimingMiddleware> log) : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var sw = Stopwatch.StartNew();
try
{
await next(ctx);
}
finally
{
sw.Stop();
ctx.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
log.LogInformation("{Method} {Path} => {Status} in {Ms} ms",
ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode, sw.ElapsedMilliseconds);
}
}
}
// Program.cs
builder.Services.AddScoped<TimingMiddleware>();
app.UseMiddleware<TimingMiddleware>();
What to notice:
- Implementing
IMiddlewaregives you DI for free (no constructor capture games). - Register the middleware as Scoped (or Singleton if stateless).
- Sits early in the pipeline so the timing wraps everything downstream.
- Setting headers must happen before the response body starts — for late headers, use
OnStarting.
3. Cookie + JWT hybrid authentication
builder.Services.AddAuthentication(opts =>
{
opts.DefaultScheme = "smart";
opts.DefaultChallengeScheme = "smart";
})
.AddPolicyScheme("smart", "Cookie or Bearer", opts =>
{
opts.ForwardDefaultSelector = ctx =>
ctx.Request.Headers.Authorization.ToString().StartsWith("Bearer ")
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(opts =>
{
opts.Cookie.Name = "__Host-app.auth";
opts.Cookie.HttpOnly = true;
opts.Cookie.SecurePolicy = CookieSecurePolicy.Always;
opts.Cookie.SameSite = SameSiteMode.Lax;
})
.AddJwtBearer(opts => builder.Configuration.Bind("Jwt", opts));
What to notice:
- A "smart" policy scheme dispatches per-request based on the presence of a
Bearerheader. - SPA browser flows use the cookie; service-to-service calls use JWT.
- Hardened cookie attributes (
__Host-prefix,HttpOnly,Secure,SameSite=Lax) match Chapter 2's guidance. - Same authorization policies apply to both schemes — write policies once, reuse.
4. A BackgroundService with PeriodicTimer + linked cancellation
public class CacheRefresher(IServiceScopeFactory scopes, ILogger<CacheRefresher> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
cts.CancelAfter(TimeSpan.FromMinutes(2));
await using var scope = scopes.CreateAsyncScope();
var cache = scope.ServiceProvider.GetRequiredService<IDashboardCache>();
await cache.RefreshAsync(cts.Token);
log.LogInformation("Cache refreshed");
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
log.LogInformation("Shutting down cache refresher");
}
}
}
builder.Services.AddHostedService<CacheRefresher>();
builder.Services.AddSingleton<IDashboardCache, DashboardCache>();
What to notice:
PeriodicTimeris allocation-free and respects thestoppingToken.- Each iteration creates its own scope to consume scoped services safely (avoids captive dependency).
- A linked CTS gives each iteration a 2-minute hard timeout.
- Graceful shutdown is the only place where
OperationCanceledExceptionis swallowed — and only after confirming it was the shutdown.
Hands-on exercises
-
Notes API end-to-end. Goal: ship a complete minimal API.
- Scaffold a minimal API for
Notes(GET/POST/PUT/DELETE /api/notes). - Use
recordDTOs, FluentValidation, ProblemDetails. - Persist to an in-memory
ConcurrentDictionaryfirst. - Add
WebApplicationFactoryintegration tests. - Done when: all four verbs round-trip with correct status codes and validation errors return ProblemDetails.
- Scaffold a minimal API for
-
Serilog wiring. Goal: structured logging end-to-end.
- Add Serilog with console + JSON file sinks.
- Configure from
appsettings.jsonviaReadFrom.Configuration. - Enrich every log with the correlation/request ID.
- Confirm
ILogger<T>everywhere uses the configured pipeline. - Done when: logs are structured and you can filter by request ID across files.
-
Custom timing middleware. Goal: feel pipeline order.
- Implement
TimingMiddleware(see worked example). - Place it before
UseAuthentication; observeElapsed-Msincludes auth time. - Move it after
UseAuthorization; observe the timing is now smaller. - Write down which version you want in production and why.
- Done when: you can place a new middleware in the right slot by feel.
- Implement
-
Options validation. Goal: fail fast on misconfiguration.
- Define
DbOptionswith[Required]and[Url]data annotations. - Wire
AddOptions<DbOptions>().Bind(...).ValidateDataAnnotations().ValidateOnStart(). - Deliberately misconfigure; confirm startup throws with a clear message.
- Hot-reload
appsettings.json; confirmIOptionsSnapshot<DbOptions>reflects the new value next request. - Done when: bad config never reaches the first request.
- Define
-
Typed HttpClient with Polly. Goal: resilient outbound HTTP.
- Add
IUpstreamClientand anUpstreamClient(HttpClient http)implementation. - Register via
services.AddHttpClient<IUpstreamClient, UpstreamClient>(). - Add a Polly retry policy (3 tries, exponential backoff).
- Add a circuit breaker that opens after 5 consecutive failures.
- Done when: transient upstream failures retry transparently and a sustained outage opens the breaker.
- Add
-
BackgroundService + PeriodicTimer. Goal: scheduled background work.
- Implement
CacheRefresher(see worked example). - Honour
stoppingToken; test graceful shutdown via Ctrl+C. - Add a linked CTS for per-iteration timeout.
- Confirm scoped services are resolved in a fresh scope each iteration.
- Done when: the worker shuts down cleanly within 5 s of Ctrl+C.
- Implement
-
Live + ready health checks. Goal: distinct probes.
- Add
AddHealthCheckswithself(live) andSqlServer+BlobStorage(ready). - Map
/health/liveand/health/readywithPredicatefilters by tag. - Hit each with
curl; deliberately stop the DB and watch/health/readygo red while/health/livestays green. - Configure App Service / Kubernetes probes to use them.
- Done when: a dependency outage drops you out of the LB without restarting the process.
- Add
Self-check questions
- What is the captive-dependency trap, and what is the standard fix?
- Why does middleware order matter? What goes wrong if
UseAuthenticationcomes afterUseAuthorization? - What is the difference between
IOptions<T>,IOptionsSnapshot<T>, andIOptionsMonitor<T>? - What does
ValidateOnStartchange, and why do you want it on every options binding? - What is the difference between liveness and readiness probes, and why does Kubernetes care?
- When do you reach for minimal APIs vs MVC controllers?
- What does
Results.Createdset on the response thatResults.Okdoes not? - Why is
RequireAuthorization()on aMapGroupcleaner than per-endpoint? - What is the difference between an authentication scheme, an authentication handler, and an authorization policy?
- What is
IHttpContextAccessorand when should you NOT use it? - What does
PeriodicTimergive you that aTask.Delayloop does not? - How does
MapFallbackToFile("index.html")interact withMapGroup("/api")— what does the client see for/some-spa-route?
High-signal resources
Official docs
- ASP.NET Core fundamentals (Microsoft Learn) — start here.
- Minimal APIs overview — the modern entry point.
- Dependency injection in ASP.NET Core — lifetimes, captive deps, keyed services.
- Configuration in ASP.NET Core — sources, providers, options.
- Middleware — order, custom middleware, branching.
Books or courses
- ASP.NET Core in Action (3rd edition) — Andrew Lock. The book.
- You're a Wizard, .NET (Microsoft eShop reference) — read the eShop repo as a modern production-style sample.
Practitioner posts
- Andrew Lock's blog — the canonical practitioner site. Search anything; the answer is usually there.
- David Fowler's gists — async dos and don'ts, minimal API patterns, from the lead architect.
- Khalid Abuhakmeh on minimal APIs — practical short-form pieces.
- Stephen Cleary on hosted services — the gotchas.
Weekly milestones
- Day 1 — Minimal hosting + DI lifetimes. Build the Notes API skeleton (exercise 1).
- Day 2 — Middleware + routing + groups. Do exercise 3 (timing middleware). Read the middleware docs.
- Day 3 — Configuration + options + validation. Do exercise 4. Read Andrew Lock on options.
- Day 4-5 — Auth + ProblemDetails + HttpClient. Wire JWT, do exercise 5 (Polly), confirm error responses are RFC 7807.
- Day 6-7 — BackgroundService + health checks. Do exercises 6 and 7. Re-attempt every self-check question.
How it shows up in the capstone
The capstone API is ASP.NET Core minimal APIs grouped by resource (/api/queries, /api/dashboards, /api/users). DI lifetimes are deliberate: singletons for caches and IHttpClientFactory-backed clients, scoped for DbContext and repositories, transient for stateless helpers. Middleware is in the canonical order with custom timing middleware right after UseExceptionHandler. IOptionsSnapshot is used for every reload-friendly config; everything is validated with ValidateOnStart.
Authentication is the smart cookie+JWT scheme from worked example 3 — the SPA gets a __Host-app.auth cookie, service-to-service callers send Authorization: Bearer .... Authorization policies (DashboardsRead, DashboardsWrite, Admin) are declared once and reused. Errors go through ProblemDetails with traceId enrichment so support can correlate user reports to logs.
A CacheRefresher BackgroundService warms dashboard panels on startup and every 5 minutes via PeriodicTimer; a QueueProcessor consumes a Service Bus queue for slow analytics. Live and ready health checks distinguish "process alive" from "ready for traffic"; App Service warmup probes only /health/ready. When the capstone deploys cleanly and survives a dependency outage without restarts, this chapter has paid off.
Previous chapter ← Ch 4 — .NET runtime + async Next chapter → Ch 6 — Modern frontend baseline