ASP.NET Core Diagrams

Last updated:

The Host and a Request's Path Through It

C4 · Component

What the host holds, and a request passing from Kestrel to an endpoint.

The parts of an ASP.NET Core host and the path of one request The host, built by WebApplicationBuilder, holds the dependency injection container, configuration, logging, and the Kestrel web server. A client's request reaches Kestrel, which turns it into an HttpContext and passes it through the request pipeline, a chain of middleware added with app.Use calls, to an endpoint such as a minimal API handler, controller action, or hub. The response travels back out through the same middleware in reverse order and leaves through Kestrel. HOST, BUILT BY WEBAPPLICATIONBUILDER DI container creates and disposes services Configuration files, environment, command line Logging providers already attached Kestrel [Web server] builds HttpContext REQUEST PIPELINE (APP.USE...) Middleware added first Middleware added second Middleware added third Endpoint handler, action, method, or hub Client browser, app, or service request, in registration order response, in reverse order

Middleware Around next, and a Short-Circuit

C4 · Dynamic

Code before and after next, and a middleware that skips next.

How middleware wraps the rest of the pipeline, and what a short-circuit skips Top row: three middleware components and an endpoint. The request passes through each middleware's code before its call to next, reaches the endpoint, and the response returns through each middleware's code after next, in reverse order. Bottom row: the second middleware writes the response and returns without calling next. The third middleware and the endpoint never run, and the response returns through the first middleware's code after next. EVERY MIDDLEWARE CALLS NEXT Middleware 1 code before next code after next Middleware 2 code before next code after next Middleware 3 code before next code after next Endpoint writes the response MIDDLEWARE 2 SHORT-CIRCUITS Middleware 1 code before next code after next Middleware 2 writes the response and never calls next Middleware 3 never runs Endpoint never runs request response

Pipeline Branches: UseWhen and Map

Flow

A UseWhen branch rejoins the main pipeline; a Map branch never does.

How UseWhen and Map branch the middleware pipeline Left: with UseWhen, a request for which the predicate is true runs the branch middleware and then continues to Middleware B. A request for which it is false goes straight to Middleware B. Both paths continue down the main pipeline. Right: with Map, a request whose path matches runs the branch, which ends in a terminal Run delegate, and never reaches Middleware B. The matched path segment moves from Path to PathBase. A request whose path doesn't match continues to Middleware B. USEWHEN: THE BRANCH REJOINS Middleware A Middleware B Branch middleware predicate false predicate true Both paths continue to Middleware B. MAP: THE BRANCH NEVER REJOINS Middleware A Middleware B Branch middleware ends in Run path doesn't match path matches /legacy A matched request never reaches Middleware B, and /legacy moves from Path to PathBase.

Where the Endpoint Is Selected and Where It Runs

Flow

Routing picks the endpoint early; it runs at the end, inside its filters.

Endpoint selection and endpoint execution in the middleware pipeline A request passes through the exception handler, then UseRouting, which picks the endpoint but does not run it. Middleware before UseRouting has no endpoint to read. CORS, authentication, authorization, and custom middleware placed after UseRouting can read the selected endpoint and its metadata. The endpoint runs at the end of the pipeline, where filters wrap the handler. no endpoint yet can read the selected endpoint and its metadata runs the endpoint Exception handler UseRouting picks the endpoint UseCors adds CORS headers Authentication sets the user Authorization applies its rules Custom your middleware Endpoint execution Filters Handler The endpoint is chosen at UseRouting but doesn't run until the end of the pipeline.

Exception Handler Re-Execution

Flow

What runs again when the exception handler re-executes at an error path.

How the exception handler re-executes part of the pipeline at an error path A request passes through earlier middleware, the exception handler, and later middleware to an endpoint, which throws. The exception unwinds back to the exception handler, which offers it to each IExceptionHandler. If none handles it, the handler clears the response and the selected endpoint, changes the path to /error, and runs the later middleware a second time. Routing selects the /error endpoint, which writes the response. Middleware registered before the exception handler runs only once. FIRST PASS Earlier middleware runs once Exception handler tries IExceptionHandlers Later middleware first pass Endpoint throws the exception unwinds to the handler SECOND PASS, PATH /ERROR none handled: response and endpoint cleared Later middleware second pass /error endpoint writes the response

How Routing Picks One Endpoint

Flow

The narrowing steps, and the three ways selection can fail.

How the routing middleware narrows the route table to one endpoint Routing first finds the templates that fit the request path. It then filters them by HTTP method, host, and request content type, then drops candidates whose constraints fail, and finally picks the best survivor by Order and then precedence. The chosen endpoint runs at the end of the pipeline. If no template fits the path, or filtering and constraints leave no candidate, no endpoint is selected, the request continues, and a 404 results at the end of the pipeline. If the path fits but no candidate accepts the method or the request body's content type, routing selects a built-in 405 or 415 endpoint. If two candidates tie, routing throws an ambiguous match exception. Match the path fitting templates Filter the request method, host, body type Check constraints drop failing values Pick the best Order, then precedence Endpoint selected runs at the pipeline end 405 or 415 endpoint wrong verb or body type No endpoint 404 at the pipeline end Ambiguous match exception thrown none fit none accept it none left a tie Routing narrows the route table to one endpoint, or ends in one of three failures.

Endpoint Filter Order

C4 · Dynamic

Group filters wrap endpoint filters, and the result executes last.

The order endpoint filters run in around a minimal API handler After parameter binding, which sets a 400 status if a route, query, or header value fails to bind, the request passes through the outer group's filter, the inner group's filter, and the endpoint's own filter, each running its code before next, and reaches the handler, which returns a result. After a binding failure the filters still run, but the handler is skipped. The result passes back through the endpoint filter, the inner group filter, and the outer group filter, each running its code after next. Only then does the framework execute the result, writing the status code, headers, and body. Parameter binding sets 400 if it fails Outer group filter before next after next Inner group filter before next after next Endpoint filter before next after next Handler returns a result Result executes writes the response request and arguments, in order result, in reverse

The MVC Filter Pipeline

Structure

Which filters wrap which stages of a controller action.

How the five MVC filter types wrap a controller action Authorization filters run first. Resource filters then wrap everything that follows, running before model binding and again after the result has executed. Inside them, model binding runs, then action filters wrap the action method, then result filters wrap the execution of the result. Exception filters catch exceptions thrown during controller creation, model binding, action filters, and the action, but not during resource filters, result filters, or result execution. Authorization filters can reject first RESOURCE FILTERS: BEFORE AND AFTER EVERYTHING INSIDE Model binding and validation ACTION FILTERS Action method returns a result RESULT FILTERS Result execution formats and writes the response exception filters catch exceptions from controller creation through the action

Buffered and Streamed Uploads

Flow

The two upload paths, and where each checks the size limits.

How an upload reaches storage on the buffered and streamed paths On the buffered path, the form reader reads the whole multipart body before the handler runs, keeping each file in memory up to 64 KB and in a temporary file beyond that. The server body limit and the form limit of 128 MiB per section are checked as it reads, so an oversized upload is rejected while the framework binds parameters. The handler then receives an IFormFile and copies it to storage. On the streamed path, the handler reads the body itself, section by section or as raw bytes, and writes each chunk to storage as it arrives, with no temporary copy. The server body limit is checked on each read, so an oversized upload fails partway through, inside the handler. Client sends the body BUFFERED: IFORMFILE, [FROMFORM] Form reader reads the whole body before the handler: memory to 64 KB, then a temp file server limit and form limit (128 MiB per section) checked as it reads Handler copies the IFormFile STREAMED: MULTIPARTREADER OR RAW BODY Handler reads the body itself writes each chunk as it arrives, no temp copy server limit checked on each read, so a 413 can arrive mid-upload Storage disk or blob store

SignalR Scale-Out

C4 · Deployment

Two ways to scale out: a Redis backplane, or Azure SignalR Service.

SignalR scale-out with a Redis backplane and with Azure SignalR Service With a Redis backplane, clients reach the app servers through a load balancer that must keep each connection on one server with sticky sessions. Each server holds its own clients' connections and relays messages for other servers' clients through Redis publish and subscribe. With Azure SignalR Service, clients negotiate with the app and then hold their connections to the service. Each app server keeps only a few connections to the service and runs the hub logic, so no sticky sessions are needed. REDIS BACKPLANE Clients each connection's requests must reach one server Load balancer [sticky sessions] Server A holds its own clients Server B holds its own clients Redis pub/sub relays messages AZURE SIGNALR SERVICE Clients negotiate with the app, then connect to the service Azure SignalR Service holds every client connection App server A runs the hub logic App server B runs the hub logic a few connections per server, no sticky sessions client connection server-side message path

IoT Dashboard Pipelines

Flow

Device telemetry to the browser, through an app server or through Functions.

Two pipelines that carry IoT telemetry from a device to a dashboard In the ASP.NET Core backend pipeline, a device sends telemetry to IoT Hub, a background service reads the hub's built-in Event Hubs endpoint and throttles the readings, and it pushes them through IHubContext to the SignalR hub's device groups, which deliver them to subscribed browsers. The browser subscribes to a device by calling a hub method over the same connection. In the serverless pipeline, IoT Hub triggers an Azure Function with an Event Hub trigger, the function's SignalR output binding sends the messages to Azure SignalR Service in Serverless mode, and the service delivers them to browsers. The browser gets its connection details from a negotiate function and subscribes by calling a subscribe function over HTTP, which adds its connection to a group in the service. ASP.NET CORE BACKEND Device sends readings IoT Hub built-in Event Hubs endpoint Background service reads events, throttles per device SignalR hub IHubContext sends to device groups Browser subscribed viewers subscribe through a hub method AZURE FUNCTIONS, SERVERLESS Device sends readings IoT Hub built-in Event Hubs endpoint Azure Function Event Hub trigger, SignalR output Azure SignalR Service Serverless mode Browser subscribed viewers Negotiate and subscribe functions HTTP triggers with SignalR bindings telemetry connection setup and subscriptions

Where 401 and 403 Come From

Flow

Authentication identifies the caller, and authorization decides the response.

How authentication and authorization middleware produce 401 and 403 responses A request passes through the authentication middleware, which runs the default scheme's handler and sets HttpContext.User. It never rejects the request, even when no credentials are present or they are invalid. The authorization middleware then evaluates the matched endpoint's policies. If they pass, the endpoint runs. If the user isn't authenticated, authorization calls Challenge on the scheme, which a bearer handler turns into a 401 and a cookie handler into a login redirect, or a 401 for known API endpoints since .NET 10. If the user is authenticated but a policy fails, authorization calls Forbid, which a bearer handler turns into a 403 and a cookie handler into an access-denied redirect, or a 403 for known API endpoints. Request token, cookie, or none Authentication runs the default scheme, sets HttpContext.User, never rejects Authorization evaluates the endpoint's policies against the user Endpoint runs policies satisfied Challenge bearer: 401 cookie: redirect, or 401 on APIs Forbid bearer: 403 cookie: redirect, or 403 on APIs allowed anonymous signed in, denied

A CORS Preflight

C4 · Dynamic

The browser asks permission before a cross-origin PUT, then sends it.

The requests a browser sends for a cross-origin PUT with a bearer token A page served from dashboard.example.com calls PUT on api.example.com with an Authorization header and a JSON body. Because the method, the header, and the content type aren't allowed for simple requests, the browser first sends an OPTIONS preflight carrying the Origin and the method and headers it intends to use. The API's CORS middleware answers with Access-Control-Allow-Origin, Allow-Methods, Allow-Headers, and Max-Age. Only if those allow the request does the browser send the actual PUT with its Origin header. The API answers, including Access-Control-Allow-Origin, and the browser lets the page read the response. If the preflight answer doesn't allow the request, the browser never sends the PUT. Browser page from dashboard.example.com API api.example.com, CORS middleware 1. OPTIONS /api/orders/42 (preflight) Origin, Access-Control-Request-Method: PUT, Access-Control-Request-Headers: authorization, content-type 2. 204 No Content Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers, -Max-Age 3. PUT /api/orders/42 Origin, Authorization: Bearer ..., Content-Type: application/json 4. 200 OK Access-Control-Allow-Origin: https://dashboard.example.com If step 2 doesn't allow the request, the browser never sends step 3.

Three Limiters, One Burst

Chart

Permits left over time when a client bursts at 55 s and 61 s.

Available permits over two minutes for a fixed window, a sliding window, and a token bucket limiter under the same burst Each limiter allows 100 requests per minute, and a client sends 100 requests at 55 seconds and 100 more at 61 seconds. The fixed window resets at 60 seconds, so both bursts are allowed, 200 requests in six seconds. The sliding window, split into six 10-second segments, has no permits back until the segment used at 55 seconds leaves the window at 110 seconds, so the second burst is rejected. The token bucket holds 100 tokens and adds 10 every 6 seconds, so after the first burst empties it, only the 10 tokens added at 60 seconds are available at 61 seconds, and it refills steadily to 100 by 120 seconds. Fixed window 100 per 60 s window 100 0 100 allowed 100 more allowed Sliding window 100 per 60 s, 6 segments of 10 s 100 0 100 allowed all rejected Token bucket 100 tokens, +10 every 6 s 100 0 100 allowed 10 allowed 0 s 20 s 40 s 60 s 80 s 100 s 120 s permits available

An Integration Test Host

C4 · Deployment

What runs in-process, what a test replaces, and what runs outside.

The pieces of an ASP.NET Core integration test built on WebApplicationFactory Inside the test process, a test method sends requests through an HttpClient created by the factory. The client's handler hands each request straight to TestServer in memory, with no socket or port. TestServer runs the app built from its own Program: middleware, routing, authentication, endpoints, and any hosted services the test keeps. The app resolves services from its DI container, which holds the production registrations followed by the test's ConfigureTestServices registrations. One leaf, the external API client, is replaced by a fake. The DbContext is re-registered with the connection string of a SQL Server container, which runs outside the test process in Docker. Test process Test method sends requests, asserts HttpClient from CreateClient() TestServer in memory, no socket The app its own Program resolves DI container production registrations, then ConfigureTestServices registrations IExternalApiClient replaced by FakeExternalApiClient ApiDbContext re-registered with the container's connection Docker SQL Server container, Testcontainers The app also runs its middleware, routing, authentication, and any hosted services the test keeps. runs unchanged replaced by the test outside the test process

Where a Response Can Be Reused

Flow

The caches a GET passes, and what controls each one.

The caches a GET request passes on its way to an ASP.NET Core API's data A GET request passes four points where an earlier answer can be reused. The client's own cache and a shared cache such as a CDN or proxy sit outside the app; both obey the Cache-Control and Vary headers the app sends and use its ETag to revalidate, a shared cache never stores a private response, and a fresh hit there never reaches the server. Inside the app, the output cache middleware sits in front of the endpoint; its policies are set in code, the client's Cache-Control request header can't override them, and a hit skips the endpoint entirely. Inside the endpoint, a data cache such as HybridCache holds objects rather than responses, and a hit there still runs the endpoint but skips the database. Outside the app: HTTP caching Inside the app Client cache browser or HTTP client Shared cache CDN or proxy Output cache middleware Endpoint with a data cache Database or service Controlled by response Cache-Control and Vary; ETag to revalidate. A fresh hit never reaches the server. Same headers; also obeys s-maxage. Never stores private responses. A fresh hit never reaches the server. Controlled by policies in code. The client’s Cache-Control can't override. A hit skips the endpoint. HybridCache or IMemoryCache holds objects, not responses. A hit runs the endpoint but skips the database.

Hosted Services Across the Host's Lifetime

C4 · Dynamic

When two hosted services start, run, and stop, relative to the server.

The lifetime of two hosted services, A and B, registered in that order in an ASP.NET Core app At startup the host calls StartAsync on service A, then on service B, in registration order, and only then starts the server, which begins accepting requests. Each BackgroundService's ExecuteAsync starts when its StartAsync runs and keeps running while the app serves requests. When a stop signal arrives, the host raises ApplicationStopping and stops hosted services in reverse registration order. The server was registered last, so it stops first: it stops accepting new connections and drains in-flight requests. Only then is B stopped, then A, each seeing its stopping token cancelled. The host waits up to the shutdown timeout, 30 seconds by default, for all of this; work still running when the timeout expires is abandoned. Host Server Service A Service B A.StartAsync B.StartAsync then the server starts and ApplicationStarted fires ApplicationStopping ApplicationStopped accepting requests 1. drains, stops A.ExecuteAsync running 3. stops B.ExecuteAsync running 2. stops stop signal ShutdownTimeout, 30 s by default. Work still running at the end is abandoned. Services start in registration order and stop in reverse. The server is registered last, so it starts last and stops first.

Health Checks Routed to Kubernetes Probes

Flow

How tags send each check to a probe, and what each probe's failure does.

Registered health checks routed by tag to liveness and readiness endpoints and Kubernetes probes Three health checks are registered. The self check carries the live tag and always returns Healthy. The database and external-api checks carry the ready tag. The /health/live endpoint's predicate selects checks tagged live, so it runs only the self check. The /health/ready endpoint's predicate selects checks tagged ready, so it runs the other two. The liveness probe calls /health/live and reads the status code, 200 or 503; when it fails failureThreshold times in a row, the kubelet restarts the container. The readiness probe calls /health/ready; when it fails, the pod is removed from the Service's endpoints and gets no traffic, but it is not restarted and probing continues. A database outage therefore fails readiness on every pod, which leave rotation together, while liveness never runs the database check and so can't cause restarts. Registered checks Endpoints Probes When the probe fails self tag live, always Healthy database tag ready external-api tag ready, Degraded on failure /health/live Predicate: tag live /health/ready Predicate: tag ready livenessProbe reads the status code readinessProbe reads the status code 200 / 503 200 / 503 Restart the container after failureThreshold misses Remove pod from Service no restart, probing continues fails fails A database outage fails /health/ready on every pod, so all of them leave rotation together. /health/live never runs the database check, so the outage can't restart anything.

OpenTelemetry in an ASP.NET Core App

Flow

Telemetry from .NET's APIs, through name-based subscriptions, to a collector.

How telemetry flows from .NET's logging, tracing, and metrics APIs through the OpenTelemetry SDK to a collector and backends Inside the app, telemetry is produced by .NET's own APIs: ILogger for log entries, the Microsoft.AspNetCore ActivitySource for request spans, a custom Orders.Processing ActivitySource for custom spans, the Microsoft.AspNetCore.Hosting meter for request duration, and a custom Orders.Api meter. The OpenTelemetry SDK subscribes to them by name. The LoggerProvider receives log entries. The TracerProvider receives the framework's request spans through the ASP.NET Core instrumentation and Orders.Processing through AddSource with the Orders.* wildcard, and its sampler keeps or drops each trace. The MeterProvider receives the hosting meter through the ASP.NET Core instrumentation and Orders.Api through AddMeter. A Billing.Sync ActivitySource that no AddSource call matches has no subscriber, so StartActivity returns null and nothing is recorded. All three providers feed one OTLP exporter configured by UseOtlpExporter, which sends to an OpenTelemetry Collector. The collector forwards each signal to the backend that stores it: a trace store, Prometheus, and a log store. .NET APIs in the app OpenTelemetry SDK Export Outside the app ILogger [logging] log entries Microsoft.AspNetCore [ActivitySource] request spans Orders.Processing [ActivitySource] custom spans Microsoft.AspNetCore.Hosting [Meter] request duration Orders.Api [Meter] custom metrics Billing.Sync [ActivitySource] not registered LoggerProvider Logging.AddOpenTelemetry TracerProvider ASP.NET Core instrumentation AddSource("Orders.*") sampler keeps or drops each trace MeterProvider ASP.NET Core instrumentation AddMeter("Orders.Api") no subscriber, so StartActivity returns null and nothing is recorded OTLP exporter UseOtlpExporter one endpoint, all three signals Collector OpenTelemetry forwards each signal to its store OTLP Trace store Prometheus Log store subscribed: recorded and exported no subscriber: nothing recorded

Four Ways a Request Reaches the App

C4 · Deployment

The processes and servers a request crosses in each hosting model.

The path of a request in the four ASP.NET Core hosting models With Kestrel, a client's request reaches the app process directly or through an optional reverse proxy, and Kestrel inside the app process passes it to the middleware pipeline. With HTTP.sys, the Windows kernel-mode HTTP.sys driver receives the request and hands it to the HTTP.sys server inside the app process. With IIS in-process, the HTTP.sys driver hands the request to the IIS worker process, w3wp.exe, where the ASP.NET Core Module passes it to IIS HTTP Server and the pipeline, all in the same process. With IIS out-of-process, the request reaches the ASP.NET Core Module in w3wp.exe, which forwards it over loopback HTTP to a separate dotnet process running Kestrel and the pipeline. Kestrel Client Reverse proxy optional app process Kestrel Pipeline HTTP.sys Client HTTP.sys driver Windows kernel mode app process HTTP.sys server Pipeline IIS in-process Client HTTP.sys driver Windows kernel mode w3wp.exe (IIS worker process) ANCM IIS HTTP Server Pipeline IIS out-of-process Client HTTP.sys driver Windows kernel mode w3wp.exe ANCM dotnet process Kestrel Pipeline loopback process boundary kernel mode extra network hop between processes

A Pod Shutting Down in Kubernetes

C4 · Dynamic

Routing updates, the preStop sleep, and the app's drain, inside one grace period.

The timeline of a Kubernetes pod shutdown for an ASP.NET Core app with a 10-second preStop sleep and a 60-second grace period At time zero the pod is deleted and the 60-second termination grace period starts. Two things happen at once. The kubelet runs the preStop hook, a 10-second sleep. Meanwhile the control plane removes the pod from the Service's endpoints, and node proxies and load balancers take a few seconds to stop sending it traffic. During the sleep the app keeps serving normally, so requests that still arrive are handled. At 10 seconds the kubelet sends SIGTERM. The app raises ApplicationStopping, Kestrel stops accepting connections and drains in-flight requests, hosted services stop, and the process exits, all within its 45-second ShutdownTimeout, which ends at 55 seconds. If the container is still running when the grace period ends at 60 seconds, the kubelet kills it with SIGKILL. 0 s 10 s 55 s 60 s kubelet preStop: sleep 10 s SIGTERM SIGKILL if still running Endpoints and load balancers pod removed from the Service; routing catches up over a few seconds, then no new requests arrive App serves normally Kestrel drains in-flight requests hosted services stop process exits ShutdownTimeout, 45 s: the app's own budget for draining and stopping terminationGracePeriodSeconds, 60 s: starts before the preStop hook The sleep keeps the app serving while routing moves away from the pod.

What Running the AppHost Starts

Flow

The AppHost starts each resource and injects its configuration.

What an Aspire AppHost starts when it runs, what configuration each resource receives, and how the running resources connect The AppHost is a development-time orchestrator that is never deployed. When it runs it starts five resources: the Aspire dashboard, the web-frontend project, the catalog-api project, a Redis container named cache, and a PostgreSQL container holding the catalogdb database. It injects configuration into each project as environment variables. The web-frontend receives service discovery entries for catalog-api and the OTLP endpoint. The catalog-api receives connection strings for catalogdb and cache and the OTLP endpoint. At run time the web-frontend calls catalog-api by name, and catalog-api uses cache and catalogdb. Both projects export telemetry over OTLP to the dashboard. AppHost [Project: development-time orchestrator, never deployed] Starts every resource and injects each project's configuration as environment variables Aspire dashboard [Started with the AppHost] Receives logs, traces, and metrics from every project web-frontend [Project + ServiceDefaults] Receives: services__catalog-api__* OTEL_EXPORTER_OTLP_ENDPOINT catalog-api [Project + ServiceDefaults] Receives: ConnectionStrings__catalogdb ConnectionStrings__cache OTEL_EXPORTER_OTLP_ENDPOINT cache [Container: Redis] postgres / catalogdb [Container: PostgreSQL] Database created if it doesn't exist started by the AppHost call at run time telemetry over OTLP

A Multi-Service AppHost

C4 · Container

A gateway, order service, and worker sharing a database, cache, and broker.

The resources in a multi-service Aspire AppHost and how they connect Outside traffic reaches only the api-gateway project, the one resource with external endpoints. The gateway calls order-service by name through service discovery and uses the Redis cache. The order-service uses the cache, the PostgreSQL orders-db database, and publishes to the RabbitMQ messaging broker. The background-worker consumes from messaging and writes to orders-db. It never references order-service. A migrations project runs once, applies the schema to orders-db, and exits. Both order-service and background-worker wait for it to finish before they start. Outside traffic api-gateway [Project] External endpoints order-service [Project] Waits for migrations messaging [Container: RabbitMQ] background-worker [Project] Waits for migrations cache [Container: Redis] orders-db [Container: PostgreSQL] migrations [Project] runs once, then exits by name publish consume applies schema connection string, or broker traffic HTTP call resolved by service discovery

Found this useful? Share it:

Share on LinkedIn