Dinesh/ Blog
application engineering

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.

25 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • Trace an ASP.NET Core 8 request through middleware, binding, validation and authorization
  • Choose DI/options lifetimes and distinguish readiness from liveness
  • Explain safe header timing and cooperative background cancellation

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 39 sections

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 combines hosting, dependency injection and an HTTP middleware pipeline. This chapter follows a Notes API from configuration through binding, validation and authorization to background work and health checks. The examples target ASP.NET Core 8 / C# 12; application-specific repository and identity types are integration seams, not an already-deployed service.

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.

First learn to assemble and explain a small API: deliberate DI lifetimes, ordered middleware, validated options and separate error/health contracts. Then extend it with resource authorization, integration tests and measured hosting limits. A custom configuration provider or authorization handler is useful only when the built-in abstractions do not cover the requirement.

You finish this chapter when you can trace one request through routing, authentication, authorization, binding, validation and a repository; identify which objects share a lifetime; and predict a rejected request without running it.

Minimal hosting — WebApplication.CreateBuilder → Services → Build → Middleware → Map → Run.

DI done right — Three lifetimes, captive dependency, factory + keyed services.

Middleware pipeline — Order matters absolutely; Use vs Run vs Map; custom middleware.

Routing + binding — Minimal APIs vs controllers, route constraints, [FromX] sources.

Configuration + options — Sources, precedence, IOptions/Snapshot/Monitor, ValidateOnStart.

Auth, errors, hosting — Schemes/handlers/policies, ProblemDetails, BackgroundService, health.

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>();
// Integration seam: configure an issuer and audience before accepting tokens.
builder.Services.AddAuthentication("Bearer").AddJwtBearer("Bearer", o =>
{
    o.Authority = builder.Configuration["Jwt:Authority"];
    o.Audience = builder.Configuration["Jwt:Audience"];
    o.MapInboundClaims = false;
});
builder.Services.AddAuthorization();
 
// 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. Worker hosts use Host.CreateApplicationBuilder, not WebApplication.CreateBuilder. Azure Functions has its own worker/hosting configuration. Register → build → run is a shared pattern, not an assertion that these hosting APIs are interchangeable.

Kestrel as the in-process server

Kestrel is the cross-platform HTTP server used by ASP.NET Core in most hosting modes. HTTP/3, TLS and sockets depend on OS/runtime/certificate support; IIS in-process hosting instead uses the IIS HTTP Server. Configure limits for the expected workload, not a claimed universal benchmark ranking:

builder.WebHost.ConfigureKestrel(o =>
{
    o.Limits.MaxConcurrentConnections = 1000;
    o.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
    o.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(10);
    o.ListenLocalhost(5001, listen => listen.UseHttps()); // local sample; needs a configured certificate
});

A reverse proxy may terminate TLS and forward to Kestrel, or use TLS on both hops. Configure trusted forwarded headers for that topology before HTTPS redirection; do not trust arbitrary client-supplied forwarding headers. IIS in-process hosting is the important Kestrel exception. YARP is a .NET reverse-proxy toolkit, not required for an ordinary API.

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.{Environment}.json

WebApplication considers both DOTNET_ENVIRONMENT and ASPNETCORE_ENVIRONMENT; with WebApplication.CreateBuilder, DOTNET_ENVIRONMENT takes precedence. The conventional names are Development, Staging and Production. The matching appsettings.{Environment}.json overlays the base file. User secrets load by default only in Development. A file named appsettings.Development.local.json is not automatically loaded: add it explicitly if wanted and decide its precedence so it does not accidentally override deployment environment variables.

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, HttpClient factories, config-derived state, expensive resources.
  • Scoped — one per request scope. Usually created on first resolution in that scope; disposed with the scope. 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.

The captive-dependency trap

Do not constructor-inject a scoped service into a singleton. Scope validation normally rejects it; without validation, resolving through the root can keep it alive until application shutdown. A transient captured by a singleton is also retained, but can be valid when designed for that lifetime and thread safety. This is not inherently a premature-disposal error. For per-operation scoped work, inject IServiceScopeFactory and create a fresh scope:

public class Cache(IServiceScopeFactory scopes) : ICache
{
    public async Task<User?> GetAsync(Guid id, CancellationToken ct)
    {
        await using var scope = scopes.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        return await db.Users.FindAsync(new object[] { id }, ct);
    }
}

Factory registrations (services.AddSingleton<IClient>(sp => new Client(sp.GetRequiredService<IConfiguration>()["Url"]))) compute the instance when the container resolves it, subject to its registered lifetime. 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/en", ([FromKeyedServices("en")] IGreeter en) => en.Hi());

Single-service resolution selects the last matching registration; registrations are not physically replaced. IEnumerable<IGreeter> resolves unkeyed registrations. GetKeyedServices<IGreeter>("en") retrieves registrations for that key. The route above deliberately resolves the fixed English key; a route parameter does not change the attribute value.

Good enough to ship

• Pick lifetimes deliberately • Constructor injection only • Factory registrations for config-derived state

Expert tier

• Keyed services for strategy patterns • IServiceProviderIsService for optional dependencies • Custom IServiceProviderFactory for Autofac / DryIoc integration

IOptions / IOptionsSnapshot / IOptionsMonitor

Three flavours of strongly-typed options:

  • IOptions<T> — singleton accessor whose value is normally created on first access; it does not reload. Inject into singletons safely. Use for stable config.
  • IOptionsSnapshot<T> — scoped; computes and caches options on first access per scope, so new scopes can see reloadable configuration changes. Inject into scoped/transient services. Use for most app config.
  • IOptionsMonitor<T> — singleton with change notifications. Subscribe via OnChange. 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
if (!app.Environment.IsDevelopment()) 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(handler) 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();
    ctx.Response.OnStarting(() =>
    {
        ctx.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
        return Task.CompletedTask;
    });
    try { await next(ctx); }
    finally { sw.Stop(); } // no header mutation after response start
});

Get the order wrong, get subtle bugs

Authentication must populate the principal before authorization evaluates it. Place CORS after routing and before authorization so it can handle preflight correctly; authentication alone does not necessarily reject preflight. Public static files served before authorization bypass it. Merely moving static-file middleware later does not automatically secure files: a suitable fallback policy or an authorized file endpoint is also needed. Middleware order and endpoint metadata work together.

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 disambiguate routes; a mismatch usually means no route matches (404). They are not input validation. An unconstrained endpoint with a typed parameter can instead reject invalid binding with 400.

Route order matters when templates overlap: ASP.NET Core picks the most specific match (literal segments > constrained parameters > unconstrained parameters > catch-all). When candidates have the same priority, matching can throw AmbiguousMatchException; source attribute order is not a tie-breaker. Use disjoint templates, constraints and deliberate endpoint order rather than relying on file order.

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 offer less ceremony and endpoint filters, but are not equivalent to the MVC pipeline. In ASP.NET Core 8, they do not automatically execute data-annotation validation. MVC supports features such as application models, model-binding extensibility and action filters. Choose the API style from those needs and team conventions, not an unmeasured speed claim.

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]

For minimal APIs, explicit binding attributes take precedence; framework types (for example HttpContext and CancellationToken), custom BindAsync, string/TryParse route-or-query values and registered DI services have their own rules before body inference. Inferred JSON body binding is not allowed for GET/HEAD/OPTIONS/DELETE. One body cannot be independently deserialized twice. Use explicit attributes when the contract should be obvious.

Controllers share many attributes, but [ApiController] enables additional binding-source inference. File binding to IFormFile uses buffering, potentially spilling to disk; it does not imply the entire request stays in RAM. For large uploads use a bounded streaming design with size checks. ASP.NET Core 8 form-bound minimal endpoints can require antiforgery middleware; a JSON endpoint and a browser form are different security contracts.

Validation — data annotations, FluentValidation

Two approaches. Data annotations are attributes on the model: [Required], [EmailAddress], [StringLength(100, MinimumLength = 2)], [Range(1, 100)], [RegularExpression(...)]. MVC validates bound models, while [ApiController] provides the automatic 400 response unless that filter is suppressed. An ordinary controller must inspect ModelState itself. 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.RequireAuthenticatedUser().RequireAssertion(ctx =>
        ctx.User.FindAll("scp").Any(c => c.Value.Split(' ',
            StringSplitOptions.RemoveEmptyEntries).Contains("users.read"))));
});
 
app.MapGet("/api/users", ...).RequireAuthorization("UsersRead");

The example assumes the issuer emits an unmapped scp claim. Validate issuer/audience/signature in the bearer handler and adapt the claim name to that issuer; machine clients may use app roles instead of delegated scopes. 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();   // handles unstarted responses; content negotiation still applies
app.UseStatusCodePages();    // handles eligible empty error responses, not existing bodies

UseExceptionHandler (with no path) plus AddProblemDetails is the modern wiring. Eligible errors can become Problem Details documents (RFC 9457 supersedes RFC 7807). This is an example 404, not the default status of every unhandled exception:

{
  "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"); // unmatched GET/HEAD non-file path → SPA shell

UseStaticFiles serves supported file types under wwwroot/ by default; unknown types are not served automatically. UseDefaultFiles rewrites directory requests when a default file exists. MapFallbackToFile matches non-file GET/HEAD paths with low priority; it can otherwise turn a missing /api/... route into HTML. Reserve the API prefix explicitly (for example a /api/{**path} not-found endpoint) and test missing API and missing asset paths as well as SPA navigation.

Apply Cache-Control: public, max-age=31536000, immutable only to content-hashed assets. Do not apply it to every static file: an unhashed index.html or runtime config.json needs revalidation or an appropriately short lifetime. Keep private files outside public static-file paths.

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 (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break; // host shutdown is not a failed-message alarm
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Failed to process {Id}", msg.Id);
            }
        }
    }
}
 
builder.Services.AddHostedService<QueueProcessor>();

A shutdown cancellation from ReceiveAsync escapes as normal BackgroundService cancellation; the filtered catch handles shutdown during processing/deletion without logging it as a message failure. Other receive failures escape and stop the host under the .NET 8 default; processing failures are logged and left for redelivery. The queue client must define receive/acknowledgment/visibility semantics. Honour stoppingToken and design message processing for redelivery: shutdown deadlines, crashes or ignored cancellation can still interrupt work. For periodic work, PeriodicTimer (.NET 6+) is an awaited timer whose ticks coalesce rather than overlap in this loop:

using 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 request context through AsyncLocal. Injecting the accessor into a singleton is supported; capturing its HttpContext in a constructor/field or using that context after the request is the danger. HttpContext is not thread-safe and can be null outside a request. Copy the small immutable values needed by background work instead.

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" })
    .AddCheck<DependencyReadinessCheck>("dependencies", tags: new[] { "ready" });
// DependencyReadinessCheck implements IHealthCheck. Its bounded read-only probes
// and injected clients are application-specific; AddSqlServer/AddAzureBlobStorage
// are third-party package APIs, not built-in ASP.NET Core methods.
 
app.MapHealthChecks("/health/live",  new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });

Liveness asks whether Kubernetes should restart a failing container; readiness decides whether it should receive service traffic. A dependency outage generally belongs in readiness. App Service Health Check is a different feature: it removes unhealthy instances from load balancing and can replace persistently unhealthy instances. Do not promise Kubernetes-style two-probe behavior on App Service.

Deployment warmup and dependency checks need platform-specific thresholds and failure tests. Keep the process-only probe cheap, and bound every dependency probe to prevent the checker itself becoming an outage source.

Worked examples

1. A production-shaped Notes API in one file

// Integration sketch: requires FluentValidation 11.x DI extensions, Serilog.AspNetCore 8.x,
// Microsoft.AspNetCore.Authentication.JwtBearer 8.x and the application types below.
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); o.MapInboundClaims = false; });
builder.Services.AddAuthorization(opts =>
{
    opts.AddPolicy("NotesWrite", p => p.RequireAssertion(ctx => ctx.User.Identity?.IsAuthenticated == true && (ctx.User.FindFirst("scp")?.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("notes.write") ?? false)));
});
 
builder.Services.AddOptions<DbOptions>()
    .Bind(builder.Configuration.GetSection("Db"))
    .ValidateDataAnnotations()
    .ValidateOnStart();
 
builder.Services.AddScoped<INoteRepository, NoteRepository>();
builder.Services.AddValidatorsFromAssemblyContaining<CreateNoteValidator>();
builder.Services.AddHealthChecks().AddCheck<DependencyReadinessCheck>("dependencies", 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();

This single-user integration sketch leaves CreateNote, CreateNoteValidator, DbOptions, repository implementations and the readiness checker to the application. In a multi-user service, every list/read/write must be scoped to the authorized subject/tenant; group authentication alone does not do that. The live endpoint intentionally selects no checks here and reports process responsiveness.

What to notice:

  • Serilog configured from appsettings.json via ReadFrom.Configuration.
  • ProblemDetails + ExceptionHandler give Problem Details responses where negotiation and response state permit; RFC 9457 supersedes RFC 7807.
  • MapGroup shares RequireAuthorization() across child endpoints.
  • RequireAuthorization("NotesWrite") adds a stricter policy to the group requirements 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();
        ctx.Response.OnStarting(() => {
            ctx.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
            return Task.CompletedTask;
        });
        try
        {
            await next(ctx);
        }
        finally
        {
            sw.Stop();
            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 IMiddleware gives 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.
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 ", StringComparison.OrdinalIgnoreCase)
                ? 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);
        opts.MapInboundClaims = false; // retain scp for the policies used in this chapter
    });

What to notice:

  • A "smart" policy scheme dispatches per-request based on the presence of a Bearer header.
  • This selector chooses cookie or bearer validation; it does not implement login or authorization grants. Interactive delegated tokens may carry scp; app-only service tokens commonly use issuer-defined app roles instead. Define and test a separate app-role policy rather than assuming service tokens satisfy notes.write.
  • Hardened cookie attributes (__Host- prefix, HttpOnly, Secure, SameSite=Lax) match Chapter 2's guidance.
  • Authorization requirements must match the grant model for each scheme. A cookie principal might not carry delegated scopes. Set an explicit sign-in scheme for cookie login, protect cookie-authenticated mutations against CSRF, and configure API cookie challenges to return 401/403 rather than .NET 8 login redirects. SameSite is defense-in-depth, not a complete CSRF solution.

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>();
                try
                {
                    await cache.RefreshAsync(cts.Token);
                    log.LogInformation("Cache refreshed");
                }
                catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested && cts.IsCancellationRequested)
                {
                    log.LogWarning("Refresh exceeded its cooperative deadline");
                }
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            log.LogInformation("Shutting down cache refresher");
        }
    }
}
 
builder.Services.AddHostedService<CacheRefresher>();
builder.Services.AddScoped<IDashboardCache, DashboardCache>(); // writes to a separately owned shared cache

What to notice:

  • PeriodicTimer avoids overlapping timer callbacks in this awaited loop and respects the stoppingToken.
  • Each iteration creates its own scope to consume scoped services safely (avoids captive dependency).
  • A linked CTS gives each iteration a 2-minute cooperative cancellation deadline.
  • Per-iteration deadline cancellation is logged and the loop continues; shutdown cancellation exits. Other failures escape and, with .NET 8 default host behavior, stop the host. Choose that failure policy deliberately.

Hands-on exercises

  1. Notes API end-to-end. Goal: ship a complete minimal API.

    1. Scaffold a minimal API for Notes (GET/POST/PUT/DELETE /api/notes).
    2. Use record DTOs, FluentValidation, ProblemDetails.
    3. Persist to an in-memory ConcurrentDictionary first.
    4. Add WebApplicationFactory integration tests.
    5. Done when: all four verbs round-trip with correct status codes and validation errors return ProblemDetails.
  2. Serilog wiring. Goal: structured logging end-to-end.

    1. Add Serilog with console + JSON file sinks.
    2. Configure from appsettings.json via ReadFrom.Configuration.
    3. Enrich every log with the correlation/request ID.
    4. Confirm ILogger<T> everywhere uses the configured pipeline.
    5. Done when: logs are structured and you can filter by request ID across files.
  3. Custom timing middleware. Goal: feel pipeline order.

    1. Implement TimingMiddleware (see worked example).
    2. Place it before UseAuthentication; observe Elapsed-Ms includes auth time.
    3. Move it after UseAuthorization; it now excludes that work and misses requests short-circuited by authorization. Compare scope, not a guaranteed smaller measurement.
    4. Write down which version you want in production and why.
    5. Done when: you can place a new middleware in the right slot by feel.
  4. Options validation. Goal: fail fast on misconfiguration.

    1. Define DbOptions with [Required] for the connection string and a positive timeout rule; a SQL connection string is not a URL.
    2. Wire AddOptions<DbOptions>().Bind(...).ValidateDataAnnotations().ValidateOnStart().
    3. Deliberately misconfigure; confirm startup throws with a clear message.
    4. Change a reloadable JSON provider; confirm a new IOptionsSnapshot<DbOptions> scope observes it. Then make the value invalid and observe validation on access: startup validation is not a lifetime guarantee.
    5. Done when: invalid startup values stop startup, and invalid reloads have a documented failure strategy.
  5. Typed HttpClient with Polly. Goal: resilient outbound HTTP.

    1. Add IUpstreamClient and an UpstreamClient(HttpClient http) implementation.
    2. Register via services.AddHttpClient<IUpstreamClient, UpstreamClient>().
    3. Use a version-pinned resilience package and a fake upstream; retry only safe/idempotent operations, with a total deadline and bounded attempts.
    4. Configure and test the selected breaker API: newer Polly ratio-based strategies are not the same as a historical consecutive-failure breaker.
    5. Done when: transient upstream failures retry transparently and a sustained outage opens the breaker.
  6. BackgroundService + PeriodicTimer. Goal: scheduled background work.

    1. Implement CacheRefresher (see worked example).
    2. Honour stoppingToken; test graceful shutdown via Ctrl+C.
    3. Add a linked CTS for per-iteration timeout.
    4. Confirm scoped services are resolved in a fresh scope each iteration.
    5. Done when: cooperative fakes cancel within a chosen test deadline, and an ignored-token fake demonstrates why cancellation is not a hard kill.
  7. Live + ready health checks. Goal: distinct probes.

    1. Add AddHealthChecks with self (live) and SqlServer + BlobStorage (ready).
    2. Map /health/live and /health/ready with Predicate filters by tag.
    3. Hit each with curl; deliberately stop the DB and watch /health/ready go red while /health/live stays green.
    4. On paper, map the checks to Kubernetes probes or App Service Health Check separately; do not assume identical behavior.
    5. Done when: a simulated dependency outage fails readiness but not liveness; platform action remains a separate deployment test.

Self-check questions

  1. What is the captive-dependency trap, and what is the standard fix?
  2. Why does middleware order matter? What goes wrong if UseAuthentication comes after UseAuthorization?
  3. What is the difference between IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>?
  4. What does ValidateOnStart change, and why do you want it on every options binding?
  5. What is the difference between liveness and readiness probes, and why does Kubernetes care?
  6. When do you reach for minimal APIs vs MVC controllers?
  7. What does Results.Created set on the response that Results.Ok does not?
  8. Why is RequireAuthorization() on a MapGroup cleaner than per-endpoint?
  9. What is the difference between an authentication scheme, an authentication handler, and an authorization policy?
  10. What is IHttpContextAccessor and when should you NOT use it?
  11. What does PeriodicTimer give you that a Task.Delay loop does not?
  12. How does MapFallbackToFile("index.html") interact with MapGroup("/api") — what does the client see for /some-spa-route?

Worked assessment answers

  1. Lifetime: a request-scoped repository captured by a singleton escapes its intended scope. Use a fresh async scope per operation; a transient is not automatically safe merely because it is transient.
  2. Pipeline: with no authenticated principal, an authenticated-user policy challenges. CORS preflight and public static-file short circuits must occur before the relevant authorization checks.
  3. Options: IOptions caches a value; snapshot caches per scope; monitor supports reload notifications. A provider must support reload for either reload-aware API to see changes.
  4. Validation: ValidateOnStart detects initial configuration failures. It cannot prove that later provider reloads remain valid.
  5. Probes: a DB outage is a readiness failure, not necessarily a process failure. App Service's health/replacement behavior is not Kubernetes liveness.
  6. Endpoint choice: use MVC when its binding/filter conventions help; minimal APIs when explicit handlers and endpoint filters suit the surface. ASP.NET Core 8 minimal validation is explicit.
  7. Creation: Results.Created returns 201 and a Location header. Ok returns 200 without that creation-location contract.
  8. Groups: group authorization avoids missing a child endpoint; endpoint requirements combine with it. AllowAnonymous must be intentional.
  9. Auth vocabulary: a scheme selects a handler; the handler establishes identity; a policy evaluates requirements, including resource ownership where needed.
  10. Context: read request context only during the request. Pass IDs/cancellation explicitly to background operations rather than retaining HttpContext.
  11. Timers: an awaited PeriodicTimer loop coalesces missed ticks; Task.Delay after work instead measures the delay from work completion. Neither preempts code that ignores cancellation.
  12. SPA fallback: an unmatched non-file GET/HEAD can return the shell. A route group does not reserve all unknown API paths, so explicitly return an API 404 instead of HTML.

Integration test design: with a fake repository and authentication handler, check unauthenticated→401, authenticated wrong owner→403 or deliberately indistinguishable 404, invalid DTO→400, valid creation→201+Location, missing API route→404, and response-start timing. These are expected contracts for WebApplicationFactory, not claimed .NET test executions in this review.

High-signal resources

Official docs

Books or courses

  • ASP.NET Core in Action (3rd edition) — Andrew Lock. The book.
  • Read the Microsoft eShop repo as a reference application; inspect its target framework before borrowing code.

Practitioner posts

Weekly milestones

  1. Day 1 — Minimal hosting + DI lifetimes. Build the Notes API skeleton (exercise 1).
  2. Day 2 — Middleware + routing + groups. Do exercise 3 (timing middleware). Read the middleware docs.
  3. Day 3 — Configuration + options + validation. Do exercise 4. Read Andrew Lock on options.
  4. Day 4-5 — Auth + ProblemDetails + HttpClient. Wire JWT, do exercise 5 (Polly), confirm eligible errors use Problem Details (RFC 9457).
  5. 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 thread-safe caches and IHttpClientFactory, short-lived factory-created/typed clients, scoped for DbContext and repositories, transient for stateless helpers. Middleware is in the canonical order with custom timing middleware right after UseExceptionHandler. Request-scoped consumers use IOptionsSnapshot for reload-friendly configuration; singleton/background consumers use IOptionsMonitor or read snapshots inside a created scope. Each binding validates startup values with ValidateOnStart and defines how invalid reloads are handled.

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 refreshes dashboard panels after each five-minute tick (add a separate initial refresh if needed) 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. Treat traffic removal, recovery and instance replacement as separate platform-specific observations. App Service may replace a persistently unhealthy instance; a readiness/liveness split does not promise that no restart will occur.

Previous chapter ← Ch 4 — .NET runtime + async Next chapter → Ch 6 — Modern frontend baseline

Corrected contracts and failure analysis

Registering validators does not execute them. The .NET 8 minimal endpoint explicitly calls ValidateAsync; MVC ApiController validation and newer minimal-API validation features have different activation rules. DI scope lifetime follows the request or created scope, not a type name. A singleton must not capture a scoped repository.

The timing middleware now registers OnStarting before invoking the next delegate; writing headers in finally can fail after the response starts. The measured header is time until headers start, not total streamed-body duration. Final logging can record the latter. Endpoint-group authorization metadata combines with endpoint policies rather than being replaced by the POST policy. Authentication alone does not authorize access to another user's note: tenant and ownership predicates belong in repository queries or resource policies.

Exception middleware cannot rewrite an already-started response. Problem Details is a negotiated response format, not a guarantee that every possible failure becomes JSON. Readiness may depend on a database; liveness normally should not, to avoid a dependency outage causing restart storms.

Boundary exercise with solution

A request sends scope="notes.read notes.write" but RequireClaim("scope", "notes.write") rejects it. Why?

Solution and reasoning

A space-delimited scope claim is one string, not two claim values, so an exact-value RequireClaim test fails. For this question, tokenize the issuer's scope claim; for this chapter's bearer examples, the issuer contract instead names it scp. Use that exact configured claim name, compare complete ordinal tokens, and require authentication. The hybrid bearer handler sets MapInboundClaims=false so the scp policies see unmapped names. A role claim is a different grant model; do not silently substitute it.

Source-backed review notes

  • Resource-based authorization in ASP.NET Core | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “Resource-based authorization requires special attention in ASP.NET Core apps. Attribute evaluation occurs before data binding and before execution of any method that loads a resource. Declarative authorization with an [Authorize] attribute doesn't suffice for resource-based authorization. Instead, the app must invoke a custom authorization method—an approach known as imperative authorization.”
  • Integration tests in ASP.NET Core | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “Infrastructure components, such as the test web host and in-memory test server (TestServer), are provided or managed by the Microsoft.AspNetCore.Mvc.Testing package. Use of this package streamlines test creation and execution.”

Version-specific reference contracts

Documentation snapshots accessed 7 October 2026; mutable service/browser documentation is timestamped evidence, not an immutable product-version pin. Exact supporting passages and hashes accompany the review ledger.

Additional authentication and hosting contracts

Primary documentation checked on 9 October 2026: JwtBearerOptions.MapInboundClaims defaults to true, so every example using raw scp names explicitly disables mapping; BackgroundService exception handling documents the default host-stop policy for unhandled faults; App Service Health Check documents traffic removal and unhealthy-instance replacement, not Kubernetes two-probe semantics.

Review and execution boundary

ASP.NET Core 8 examples, C# 12. Minimal API validation behavior differs from MVC and later ASP.NET Core releases. The 9 October 2026 body review checked all examples and answers plus MDX/source invariants, but did not compile C#, start an ASP.NET host, validate a real token, run WebApplicationFactory, or deploy health probes. Authentication, repositories, certificates and queues remain explicit integration seams.

Reviewed on 7 October 2026 against the official source snapshots cited in this chapter. The whole lesson and original were reviewed; framework contracts are documentation-supported, while .NET execution and deployment are unverified. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.