ASP.NET Core — Full Request Lifecycle

From TCP connection to JSON bytes on the wire · startup · middleware · routing · DI · filters · binding · async · results · serialization · error handling

① Boot
📦 Program.cs — Two-Phase Startup runs once · never per-request
1
WebApplication.CreateBuilder(args) — CONFIGURE phase
Register services into the DI container. No request exists yet. This is where you wire up everything the app will ever need.
2
builder.Build() — FREEZE the container
After this point the service container is read-only. You can no longer register services.
3
app.Use…() — assemble the middleware pipeline
Order matters. Each Use call appends a delegate to the chain. MapControllers() adds the routing terminal at the end.
4
app.Run() — start Kestrel, block forever
Kestrel opens a TCP socket and begins the listen loop. Every request after this is handled concurrently on the thread pool.
var builder = WebApplication.CreateBuilder(args);   // ← CONFIGURE phase

builder.Services.AddControllers();               // register MVC + [ApiController]
builder.Services.AddProblemDetails();              // ← required for ⑪ error handling (UseExceptionHandler)
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();                         // ← container FROZEN after here

// Middleware registered in order — order matters!
app.UseExceptionHandler();                        // 0 — outermost, catches any unhandled exception
app.UseHttpsRedirection();                        // 1 — force HTTPS
app.UseCors("MyPolicy");                          // 2 — allow cross-origin browser requests
app.UseAuthentication();                          // 3 — decode token, populate HttpContext.User
app.UseRouting();                                  // 4 — match URL to endpoint, attach metadata
app.UseAuthorization();                           // 5 — check [Authorize] policies
app.MapControllers();                             // terminal — no next() called; pipeline ends here

app.Run();                                        // ← start Kestrel, block
TCP connection arrives → Kestrel parses bytes into HttpContext
② Request
🌐 Incoming HTTP Request new HttpContext created
Client sends: GET /api/users/5?include=roles HTTP/1.1
Accept: application/json — the client wants JSON back.
Authorization: Bearer eyJ… — a JWT (JSON Web Token). A JWT is a compact string with three base64-encoded parts separated by dots: a header (signing algorithm), a payload (claims — key/value pairs like user id, roles, expiry), and a cryptographic signature. At this point it's just raw text; UseAuthentication in ③ will verify the signature and decode the payload.
Origin: https://myapp.com — added automatically by the browser on any cross-domain request. You cannot set or override it from JavaScript. UseCors in ③ reads it to decide whether to allow the request. Same-origin requests carry no Origin header.
Kestrel parses raw bytes → builds HttpContext
HttpContext.Request.Method = "GET"  ·  .Path = "/api/users/5"  ·  .Query["include"] = "roles"  ·  .Headers["Authorization"] = "Bearer eyJ…"  ·  .Headers["Origin"] = "https://myapp.com"
A new DI scope is created for this request
All Scoped services (e.g. DbContext) are created fresh and will be disposed at the end of this request. DI and service lifetimes are explained in ⑤.
HttpContext passed into the middleware pipeline
③ Middleware
⚙️ Middleware Pipeline call chain + continuation
UseExceptionHandlerUseHttpsRedirectionUseCorsUseAuthenticationUseRoutingUseAuthorizationMapControllers
Execution rules
1
Each middleware is a function that receives HttpContext and a next delegate
Calling await next(ctx) passes control to the next middleware in the chain. Code before that call runs on the way in; code after it runs on the way back. This is a call chain with continuations — not two separate phases. Rules 2–4 are all demonstrated in the app.Use() example below.
2
Middleware can execute before and after calling next()
Code before await next(ctx) runs on the way in. Code after it runs on the way back, once everything below has completed.
3
Short-circuit: write a response and return without calling next()
Middleware below it is skipped for that request. Middleware above it still completes — the continuation they're waiting on returns normally, they just see a response that was written earlier in the chain.
4
Response headers and status code cannot be modified after the response has started
Once the response body begins writing, headers are committed. Check ctx.Response.HasStarted before touching headers or status in code that runs after await next(ctx). Attempting to modify them after HasStarted == true throws InvalidOperationException.
5
What you can see depends on where you are in the chain
Middleware registered after UseAuthentication can read HttpContext.User — the credentials have been validated by then. Middleware registered before it cannot — User has no identity attached yet. The same applies to anything else a middleware sets on HttpContext: route values, endpoint metadata, custom items. You only see what has already been set above you.
Deep dives
UseCors — cross-domain requests
1
The browser's Same-Origin Policy blocks JavaScript from reading responses from a different domain
Your React app at https://myapp.com calling an API at https://api.myapp.com — different domains. The browser enforces this, not the server. The server's role is to declare whether cross-domain requests are permitted.
2
Regular cross-domain request (e.g. GET)
The browser attaches Origin: https://myapp.com automatically — this cannot be set or spoofed by JavaScript.

UseCors checks whether that origin is in your allowed list. If so, it adds Access-Control-Allow-Origin: https://myapp.com to the response.

The browser checks for that header. Present → your JavaScript can read the response. Missing → the browser discards the response silently, regardless of status code.
3
Preflight request (before a cross-domain POST, PUT, or DELETE)
Before sending a mutating cross-domain request, the browser first sends an OPTIONS request automatically — no body, no auth token, no user data. It is asking: "will you accept a POST from https://myapp.com?" Your JavaScript never sees this — the browser handles it invisibly.

UseCors recognises the OPTIONS method and, depending on your configuration, may short-circuit immediately with 204 and the appropriate CORS headers. The browser receives that, is satisfied, and sends the real POST.

If it doesn't short-circuit (e.g. due to placement or configuration), the preflight continues down the pipeline — which is typically not what you want.
4
Ordering
CORS is typically placed before authentication and authorization. A preflight OPTIONS request carries no credentials. If an authentication middleware runs first and challenges the request, CORS headers may never be added — and the browser reports a CORS error that obscures the real problem.
UseAuthentication — validating credentials
1
Reads credentials from the request — typically a JWT in the Authorization header, or an auth cookie
A JWT looks like eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1In0.abc123 — three base64-encoded parts: a header (algorithm), a payload (claims), and a cryptographic signature. At this point in ② it was raw text; now it gets validated.
2
Validates the credentials and builds a ClaimsPrincipal in HttpContext.User
For a JWT: verifies the signature using your server's key (proving it wasn't tampered with), checks the expiry claim, then extracts the claims from the payload — key/value pairs like sub (user id), role, email. These become the ClaimsPrincipal that everything downstream reads via HttpContext.User.
3
Does not normally short-circuit — it sets identity, while authorization decides access
If credentials are missing, expired, or invalid, HttpContext.User is left as an anonymous principal with no identity attached — no claims, no name. The request continues. It's UseAuthorization that decides whether an anonymous or insufficiently-privileged user is allowed to proceed. Note: some authentication schemes can challenge or redirect in certain configurations, but the typical behaviour is to set identity and pass through.
UseRouting + UseAuthorization — endpoint selection and access control
1
UseRouting selects an endpoint and stores it in HttpContext.GetEndpoint()
It matches the request's URL path and HTTP method against all registered route templates and resolves a single Endpoint object — which carries not just the handler, but also its metadata: [Authorize] attributes, route constraints, policy names, and anything else attached at registration. Route values (e.g. id=5 from /users/5) are stored in HttpContext.Request.RouteValues. If no route matches, the endpoint is null and a 404 is returned at the terminal.
2
UseAuthorization depends on routing — it reads the endpoint metadata that UseRouting produced
This is why UseRouting must come before UseAuthorization in the pipeline. Without the resolved endpoint, there is no metadata to evaluate.
3
UseAuthorization evaluates authorization requirements from endpoint metadata — policies, roles, and [Authorize] attributes
It checks the ClaimsPrincipal in HttpContext.User against those requirements. No identity (anonymous) when the endpoint requires one → 401. Identity present but missing the required role or policy → 403. No authorization requirements on the endpoint → passes through.
Registration styles
app.Use()
Pass-through. Calls next(). Runs code before and after. Standard form for almost all middleware.
app.Run()
Terminal. Never calls next(). Ends the pipeline immediately. Place at the end — anything registered after is unreachable.
app.Map() / MapWhen()
Map branches on URL prefix; MapWhen on a predicate. Both create an independent sub-pipeline that does not rejoin the main pipeline.
app.UseWhen()
Branches on a predicate. Unlike Map, the branch rejoins the main pipeline after it finishes.
// app.Use() — before/after next(), short-circuit, and HasStarted guard
app.Use(async (ctx, next) =>
{
    if (!ctx.Request.Headers.ContainsKey("X-Api-Key")) {
        ctx.Response.StatusCode = 401;
        await ctx.Response.WriteAsync("Missing API key");
        return; // short-circuit — downstream middleware is skipped
    }
    ctx.Response.Headers.Append("X-Request-Id", Guid.NewGuid().ToString());
    await next(ctx); // ← before this: on the way in | after this: on the way back
    if (!ctx.Response.HasStarted)            // headers committed once body starts writing
        ctx.Response.Headers.Append("X-Timing", "…");
});

// app.Map() — branch on URL prefix, does not rejoin
app.Map("/health", branch => branch.Run(async ctx => await ctx.Response.WriteAsync("OK")));

// app.UseWhen() — branch on predicate, rejoins main pipeline
app.UseWhen(
    ctx => ctx.Request.Path.StartsWithSegments("/api"),
    branch => branch.UseMiddleware<RateLimitMiddleware>()
);

// app.Run() — terminal; must be last, anything registered after is unreachable
app.Run(async ctx => { ctx.Response.StatusCode = 404; await ctx.Response.WriteAsync("Not found"); });
Class-based middleware — the production pattern
Use a class when you need scoped DI services — inline lambdas can't receive them
The framework injects RequestDelegate next and singleton-safe services via the constructor. Scoped services go as extra parameters on InvokeAsync after HttpContext — resolved fresh from the request scope on every call. Register with app.UseMiddleware<T>().
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate                    _next;
    private readonly ILogger<RequestLoggingMiddleware> _log;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> log)
        { _next = next; _log = log; }

    // Scoped services as extra InvokeAsync params — resolved per request from DI
    public async Task InvokeAsync(HttpContext ctx, ITraceIdService trace)
    {
        var sw   = Stopwatch.StartNew();
        var user = ctx.User.Identity?.Name ?? "anonymous";
        _log.LogInformation("→ {Method} {Path} user={User} trace={Trace}",
            ctx.Request.Method, ctx.Request.Path, user, trace.Current);
        await _next(ctx);
        _log.LogInformation("← {Status} in {Ms}ms", ctx.Response.StatusCode, sw.ElapsedMilliseconds);
    }
}

app.UseMiddleware<RequestLoggingMiddleware>();
Router matched → controller + action identified
④ Routing
🗺️ Routing — URL to Action built at startup · matched per-request
GET /api/users/5 matches [HttpGet("{id}")] on UsersController
[Route("api/[controller]")] → "api/users" · {id} captures "5" as a route value · stored in HttpContext.Request.RouteValues
No match → router returns 404, your action code never runs
Different from your code calling NotFound() — the router 404 fires before any controller is instantiated.
[ApiController]                   // ← see note below
[Route("api/[controller]")]        // → "api/users"
public class UsersController : ControllerBase
{
    [HttpGet("{id}")]               // GET /api/users/5  →  id = 5
    public async Task<ActionResult<UserDto>> GetUser(int id) { … }

    [HttpPost]                        // POST /api/users
    public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserRequest req) { … }

    [HttpGet("search")]             // GET /api/users/search?name=alice
    public async Task<ActionResult<List<UserDto>>> Search([FromQuery] string name) { … }
}
[ApiController] — does two important things automatically:
1. Auto-validates ModelState — if model binding fails, it returns a 400 with validation errors before your action even runs. You don't need if (!ModelState.IsValid).
2. Infers binding sources — complex types on POST/PUT are automatically treated as [FromBody], simple types on GET as [FromRoute]/[FromQuery].
Controller instantiated — constructor params resolved from DI
⑤ DI
🔧 Dependency Injection framework resolves · you never call new
ASP.NET creates a new controller instance per request, resolving constructor params from the DI container
You never call new UsersController() yourself — the framework does it and injects whatever you declared in the constructor.
public class UsersController : ControllerBase
{
    private readonly IUserService           _users;
    private readonly IPermissionsService     _permissionsApi;
    private readonly IEmailService           _emailService;
    private readonly ILogger<UsersController> _log;

    // Framework calls this — resolves all four services from the DI container
    public UsersController(
        IUserService           users,
        IPermissionsService    permissionsApi,
        IEmailService          emailService,
        ILogger<UsersController> log)
    {
        _users          = users;
        _permissionsApi = permissionsApi;
        _emailService   = emailService;
        _log            = log;
    }
}
🟢 Singleton
One instance for the entire app lifetime. Shared across all requests and threads. Use for stateless, thread-safe services (config, caches, HTTP clients).
🔵 Scoped
One instance per HTTP request. Created when the request starts, disposed when it ends. DbContext is always Scoped — never Singleton.
🟣 Transient
New instance every time it's resolved from the container. Use for lightweight, stateless services. Avoid for heavy or connection-holding objects.
⚠️ Captive dependency bug: never inject a Scoped service into a Singleton. The Scoped instance gets captured and lives forever — leaking state across requests and causing data corruption bugs that only appear under load.
Filters run — before and after the action method
⑥ Filters
🔒 Filters — logic that wraps every action inspect or modify action arguments and results
Filters are hooks that run at specific points around an action method — before it, after it, or around the result being written
Filters run inside the MVC layer, so they know exactly which controller and action are being called. They can read the action's arguments before it runs, and modify the result after it returns. Middleware runs before MVC even starts — it only sees the HTTP request and response, with no knowledge of controllers or actions.
IExceptionFilter ← catches anything thrown inside; set ExceptionHandled = true to suppress
  IAuthorizationFilter ← [Authorize] lives here; 401/403 if denied, nothing below runs
    IResourceFilter ← before model binding; short-circuit here to serve from cache
      IActionFilter.OnActionExecuting ← inspect/modify arguments, or short-circuit
        ⚡ your action method runs
      IActionFilter.OnActionExecuted ← inspect/modify the returned result
    IResultFilter ← before/after IActionResult executes (response written)
// IActionFilter — the most common filter type
public class LogActionFilter : IActionFilter
{
    private readonly ILogger<LogActionFilter> _log;
    public LogActionFilter(ILogger<LogActionFilter> log) => _log = log; // DI works in filters

    public void OnActionExecuting(ActionExecutingContext ctx)   // ← runs before; can short-circuit by setting ctx.Result
        => _log.LogInformation("▶ {Action} args={Args}",
              ctx.ActionDescriptor.DisplayName, // ← which controller + action is running
              ctx.ActionArguments);             // ← the actual argument values — middleware can't see these

    public void OnActionExecuted(ActionExecutedContext ctx)    // ← runs after; ctx.Result is the returned IActionResult
        => _log.LogInformation("■ {Action} result={Result}",
              ctx.ActionDescriptor.DisplayName,
              ctx.Result);                      // ← the IActionResult before it's written — middleware can't see this either
}

// IExceptionFilter — return a safe response, never expose internals
public class GlobalExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext ctx)
    {
        ctx.Result = new ObjectResult(new ProblemDetails { Status = 500, Title = "An unexpected error occurred." }) { StatusCode = 500 };
        ctx.ExceptionHandled = true;
    }
}

builder.Services.AddControllers(o => o.Filters.Add<GlobalExceptionFilter>()); // register globally
Action method invoked — model binding resolves parameters
⑦ Binding
📋 Model Binding — request data to C# parameters deserialized + validated
Framework maps request data to action parameters using binding source attributes
With [ApiController], binding sources are inferred automatically — you only need the attributes when you want to override the default.
Default inference rules: complex types on POST/PUT → [FromBody] · simple types matching a route segment → [FromRoute] · remaining simple types → [FromQuery]
Explicit attributes always win over inference. [FromRoute] > [FromQuery] > [FromBody] — only one [FromBody] parameter is allowed per action; the body stream can only be read once.
Data annotations on your model class are validated after binding
[Required] · [MaxLength(100)] · [EmailAddress] · [Range(1, 150)] — any failure populates ModelState and [ApiController] auto-returns 400.
Binding source attributes — all six
Attribute Reads from Notes
[FromRoute] URL route segment e.g. {id} in /api/users/{id}
[FromQuery] Query string e.g. ?notify=true&page=2
[FromBody] Request body (JSON) Only one per action — the body stream can only be read once
[FromHeader] HTTP request header e.g. [FromHeader(Name = "X-Api-Version")] string version
[FromForm] Form fields / multipart Use for file uploads (IFormFile) and HTML form posts — mutually exclusive with [FromBody]
[FromServices] DI container Injects a service directly into an action parameter — useful for one-off dependencies you don't want in the constructor
Data annotation attributes
Attribute Validates
[Required]Field must be present and non-null/non-empty
[MaxLength(n)]String or array length ≤ n
[MinLength(n)]String or array length ≥ n
[StringLength(max)]String length between optional min and max; also sets DB column size via EF Core
[Range(min, max)]Numeric value within inclusive bounds — also works with DateTime
[RegularExpression(pattern)]String matches a regex
[EmailAddress]Basic email format check
[Url]Must be a valid absolute URL
[Phone]Basic phone number format
[Compare("OtherProp")]Two fields must match — classic use: password + confirm password
[EnumDataType(typeof(E))]Value must be a valid member of the given enum
[HttpPut("{id}")]
public async Task<ActionResult<UserDto>> UpdateUser(
    [FromRoute]  int               id,     // ← /api/users/5
    [FromBody]   UpdateUserRequest body,   // ← JSON request body
    [FromQuery]  bool              notify  // ← ?notify=true
)
{
    // [ApiController] already rejected the request with 400 if body failed validation.
    // If we get here, binding succeeded and the model is valid.

    var updated = await _users.UpdateAsync(id, body.Name, body.Email);

    if (updated is null)
        return NotFound();               // user with that id doesn't exist

    if (notify)
        await _emailService.SendProfileUpdatedAsync(updated.Email);

    return Ok(updated);                 // 200 with updated UserDto as body
}

public class UpdateUserRequest
{
    [Required]
    [MaxLength(100)]
    public string Name  { get; set; }

    [EmailAddress]
    public string Email { get; set; }
}
Your async code runs — awaiting DB / HTTP calls / services
⑧ Async
⚡ Async / Await — non-blocking execution thread released on every I/O call
When you await an I/O call, the thread is released back to the thread pool — free to serve other requests while waiting
This is why ASP.NET can handle thousands of concurrent requests on a handful of threads. A synchronous blocking call (.Result, .Wait()) holds the thread hostage and kills scalability.
ActionResult<T> lets you return either a T (auto-wrapped as 200) or any IActionResult
The T is what your action returns on the happy path. The IActionResult paths are for everything else — 404, 400, 201, redirects. See ⑨ for the full result type reference.
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(int id)
{
    // thread released back to pool while DB query runs
    var user = await _users.GetByIdAsync(id);

    if (user is null)
        return NotFound();                  // 404 — IActionResult path

    // thread released again while we call an external HTTP API
    var permissions = await _permissionsApi.GetForUserAsync(user.Id);

    var dto = new UserDto { Id = user.Id, Name = user.Name, Permissions = permissions };

    return Ok(dto);                        // 200 — OkObjectResult
}

// ActionResult<T> implicit conversions:
return dto;                              // T  →  auto-wrapped in Ok(dto)
return NotFound();                       // IActionResult path — explicit
return CreatedAtAction(nameof(GetUser), new { id = dto.Id }, dto); // 201

// ❌ Never do this — blocks the thread, can deadlock:
var bad = _users.GetByIdAsync(id).Result;  // .Result / .Wait() = blocking
Action returns an IActionResult → ObjectResult.ExecuteResultAsync()
⑨ Results
🗂️ Action Results — what your action returns status code + body decided
ObjectResult is the base class for all results that carry a body — every helper method below produces a subclass of it
Ok(data)OkObjectResult · NotFound()NotFoundResult · BadRequest(errors)BadRequestObjectResult. They all inherit from ObjectResult, which holds a Value (your C# object) and a StatusCode. It calls the output formatters to serialize Value — that's where JSON serialization happens.
ActionResult<T> — declare the happy-path type so Swagger can generate accurate docs
Swagger scans your code at startup, reads the T, and uses it to document the exact shape of the success response. Without the T, Swagger sees a black box and the response shows as empty. The T also enables implicit conversion — returning dto directly auto-wraps it in Ok(dto).
200Ok(data)OkObjectResult
201CreatedAtAction(…)CreatedAtActionResult
204NoContent()NoContentResult
301/302Redirect(url)RedirectResult
400BadRequest(errors)BadRequestObjectResult
401Unauthorized()UnauthorizedResult
403Forbid()ForbidResult
404NotFound()NotFoundResult
4xx/5xxProblem(…)ProblemDetails (RFC 7807)
anyStatusCode(418, body)StatusCodeResult
ObjectResult calls OutputFormatters → C# object → bytes
⑩ Serialize
🔄 Serialization — C# object to JSON picks format from Accept header · writes bytes
1
ObjectResult reads the Accept header to pick a formatter
Accept: application/json → JSON formatter · Accept: application/xml → XML formatter (if registered) · no match → 406 Not Acceptable by default. You can suppress the 406 and fall back to JSON instead: options.ReturnHttpNotAcceptable = false in AddControllers(o => …).
2
Formatter serializes Value (your C# object) to bytes using System.Text.Json
These come from two places. Attributes ([JsonPropertyName], [JsonIgnore]) are things you write directly on your class properties to control one specific property. JsonSerializerOptions is configured once in Program.cs and applies to every response in the app — for example setting CamelCase automatically lowercases the first letter of every property name at serialization time (UserId"userId", FirstName"firstName") without touching any class. Attributes override the global options for a single property when you need different behaviour.
System.Text.Json property attributes — all of them
Attribute Effect
[JsonPropertyName("name")] Override the serialized key for this property — ignores global naming policy for this one field
[JsonIgnore] Exclude this property from serialization and deserialization entirely
[JsonIgnore(Condition = ...)] Conditionally ignore: WhenWritingNull · WhenWritingDefault · Never · Always — overrides the global DefaultIgnoreCondition for this property
[JsonConverter(typeof(T))] Use a custom JsonConverter<T> for this property — e.g. serialize a DateTime as a Unix timestamp instead of ISO 8601
[JsonPropertyOrder(n)] Control the key order in the output JSON — lower numbers appear first; default is 0; useful for readability
[JsonInclude] Include a public field (not a property) or a non-public property accessor in serialization — opt-in for things the serializer skips by default
[JsonExtensionData] Applied to a Dictionary<string, JsonElement> property — captures any JSON keys that don't map to a known property, so unknown fields are preserved rather than dropped
3
Bytes written to HttpContext.Response.Body
Content-Type: application/json; charset=utf-8 and Content-Length set automatically.
public class UserDto
{
    public int    Id          { get; set; }  // → "id"   (camelCase applied automatically)
    public string Name        { get; set; }  // → "name"
    public List<string> Permissions { get; set; }  // → "permissions"  (populated from _permissionsApi in ⑧)
    [JsonIgnore]                  // never appears in the response — stays server-side only
    public string PasswordHash { get; set; }
}
// → { "id": 5, "name": "Alice", "permissions": ["read", "write"] }

// Configure globally in Program.cs — must be BEFORE builder.Build():
builder.Services.AddControllers()
    .AddJsonOptions(o => {
        o.JsonSerializerOptions.PropertyNamingPolicy   = JsonNamingPolicy.CamelCase;         // Id→"id", FirstName→"firstName"
        o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // omit null fields entirely
    });
What if something throws? → exception handling
⑪ Errors
💥 Exception Handling — the safety net exception caught · safe response guaranteed
UseExceptionHandler is the outermost try/catch wrapping the entire pipeline — if anything throws anywhere, this catches it
IExceptionFilter (⑥) only covers exceptions thrown inside an action method or its filters — it has no visibility into middleware or anything that happens before the router reaches a controller. UseExceptionHandler has no such boundary. It wraps everything, including middleware exceptions and anything IExceptionFilter never saw.
It formats the error as ProblemDetails — a standardised JSON shape for errors defined in RFC 7807
RFC 7807 is just an internet standard that says "HTTP APIs should return errors in this consistent JSON shape." It means any client — browser, mobile app, another service — can always expect errors to look the same, regardless of which endpoint threw.
The traceId field is generated automatically by ASP.NET for every request — you write no code for it
.NET's built-in distributed tracing assigns a unique ID to each request as it enters the app. Every log line produced during that request is automatically tagged with the same ID. When an error occurs, ASP.NET includes the traceId in the ProblemDetails response automatically. When a client reports an error, they give you the traceId and you search your logs for it — finding the exact request, full stack trace, and every service call that happened, without any of that being exposed to the client. The format (00-abc123…-def456…-00) is W3C TraceContext — the standard since .NET 5.
// In Program.cs — AddProblemDetails must be BEFORE builder.Build():
builder.Services.AddProblemDetails();     // registers the ProblemDetails formatter

// UseExceptionHandler must be FIRST in the pipeline (already shown in ① Boot):
app.UseExceptionHandler();               // catches anything that escapes MVC, formats as ProblemDetails
// ... rest of middleware

// ProblemDetails response the client receives:
// HTTP/1.1 500 Internal Server Error
// Content-Type: application/problem+json
//
// {
//   "type":    "https://tools.ietf.org/html/rfc7231#section-6.6.1",
//   "title":   "An error occurred while processing your request.",
//   "status":  500,
//   "traceId": "00-abc123-def456-00"  ← auto-generated · search your logs for this
// }
//
// The real exception — full stack trace, inner exceptions — is only in your logs.
// The client never sees it.

// Exception flow — which layer catches what:
//   action throws
//     → IExceptionFilter (⑥) catches first, if one is registered
//     → if no filter handles it (ExceptionHandled remains false, the default)
//       → exception bubbles up to UseExceptionHandler
💡 Development vs Production: set ASPNETCORE_ENVIRONMENT=Development and ASP.NET shows a full developer exception page with the stack trace in the browser — useful for debugging. In production that env var is absent, so only the safe ProblemDetails shape is returned. Never leak stack traces to clients.
Response bytes unwind back through middleware → Kestrel → TCP
⑫ Response
✅ HTTP Response — back to the client bytes sent · scope disposed
1
Response unwinds through the middleware chain in reverse order
Each middleware runs the code after its await next(context) call — e.g. logging elapsed time, appending headers, compressing the body.
2
Kestrel writes HTTP response bytes over TCP to the client
HTTP/1.1 200 OK · Content-Type: application/json · body: {"id":5,"name":"Alice"}
3
Request DI scope is disposed — all Scoped services cleaned up
DbContext connections returned to the pool. Any IDisposable Scoped services have Dispose() called automatically.
Boot / Startup
Request / Routing
Middleware
DI / Binding
Filters
Action / Async
Results / Errors
Serialization
Response