From TCP connection to JSON bytes on the wire · startup · middleware · routing · DI · filters · binding · async · results · serialization · error handling
WebApplication.CreateBuilder(args) — CONFIGURE phasebuilder.Build() — FREEZE the containerapp.Use…() — assemble the middleware pipelineUse call appends a delegate to the chain. MapControllers() adds the routing terminal at the end.app.Run() — start Kestrel, block forevervar 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
GET /api/users/5?include=roles HTTP/1.1Accept: 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.
HttpContextHttpContext.Request.Method = "GET" ·
.Path = "/api/users/5" ·
.Query["include"] = "roles" ·
.Headers["Authorization"] = "Bearer eyJ…" ·
.Headers["Origin"] = "https://myapp.com"
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 ⑤.UseExceptionHandler → UseHttpsRedirection → UseCors → UseAuthentication → UseRouting → UseAuthorization → MapControllers
HttpContext and a next delegateawait 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.next()await next(ctx) runs on the way in. Code after it runs on the way back, once everything below has completed.next()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.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.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.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.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.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.Authorization header, or an auth cookieeyJhbGciOiJIUzI1NiJ9.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.ClaimsPrincipal in HttpContext.Usersub (user id), role, email. These become the ClaimsPrincipal that everything downstream reads via HttpContext.User.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 selects an endpoint and stores it in HttpContext.GetEndpoint()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.UseAuthorization depends on routing — it reads the endpoint metadata that UseRouting producedUseRouting must come before UseAuthorization in the pipeline. Without the resolved endpoint, there is no metadata to evaluate.UseAuthorization evaluates authorization requirements from endpoint metadata — policies, roles, and [Authorize] attributesClaimsPrincipal 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.next(). Runs code before and after. Standard form for almost all middleware.next(). Ends the pipeline immediately. Place at the end — anything registered after is unreachable.Map branches on URL prefix; MapWhen on a predicate. Both create an independent sub-pipeline that does not rejoin the main pipeline.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"); });
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>();
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.RouteValuesNotFound() — 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) { … } }
if (!ModelState.IsValid).[FromBody], simple types on GET as [FromRoute]/[FromQuery].
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; } }
DbContext is always Scoped — never Singleton.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.
// 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
[ApiController], binding sources are inferred automatically — you only need the attributes when you want to override the default.[FromBody] · simple types matching a route segment → [FromRoute] · remaining simple types → [FromQuery][FromRoute] > [FromQuery] > [FromBody] — only one [FromBody] parameter is allowed per action; the body stream can only be read once.[Required] · [MaxLength(100)] · [EmailAddress] · [Range(1, 150)] — any failure populates ModelState and [ApiController] auto-returns 400.| 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 |
| 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; } }
await an I/O call, the thread is released back to the thread pool — free to serve other requests while waiting.Result, .Wait()) holds the thread hostage and kills scalability.ActionResult<T> lets you return either a T (auto-wrapped as 200) or any IActionResultT 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
ObjectResult is the base class for all results that carry a body — every helper method below produces a subclass of itOk(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 docsT, 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).Ok(data)OkObjectResultCreatedAtAction(…)CreatedAtActionResultNoContent()NoContentResultRedirect(url)RedirectResultBadRequest(errors)BadRequestObjectResultUnauthorized()UnauthorizedResultForbid()ForbidResultNotFound()NotFoundResultProblem(…)ProblemDetails (RFC 7807)StatusCode(418, body)StatusCodeResultObjectResult reads the Accept header to pick a formatterAccept: 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 => …).Value (your C# object) to bytes using System.Text.Json[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.| 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 |
HttpContext.Response.BodyContent-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 });
try/catch wrapping the entire pipeline — if anything throws anywhere, this catches itIExceptionFilter (⑥) 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.traceId field is generated automatically by ASP.NET for every request — you write no code for ittraceId 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
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.
await next(context) call — e.g. logging elapsed time, appending headers, compressing the body.HTTP/1.1 200 OK · Content-Type: application/json · body: {"id":5,"name":"Alice"}Scoped services cleaned upDbContext connections returned to the pool. Any IDisposable Scoped services have Dispose() called automatically.