Web API
How to build HTTP APIs on ASP.NET Core: controllers or Minimal APIs, where parameters come from and how they are validated, how to design resources and status codes, return errors, version and describe an API, and handle load. This is the core of any .NET backend interview.
Before this topic: Hosting and the pipeline, Dependency injection.
How do controllers differ from Minimal APIs, and when do you choose each?
Section titled “How do controllers differ from Minimal APIs, and when do you choose each?”What it is
- Controller — a class deriving from
ControllerBasewith the[ApiController]attribute. Each public action method serves one endpoint, and the route comes from[Route]and[HttpGet]attributes. - Minimal API — a delegate mapped to a route in
Program.cs:app.MapGet("/products/{id}", (int id) => ...).
How to use
- Controllers take two lines:
builder.Services.AddControllers()andapp.MapControllers(). - In Minimal APIs you define endpoints with
MapGet,MapPost,MapPut,MapDelete. A shared prefix goes into a group:var orders = app.MapGroup("/api/orders");. - Return responses via
TypedResults.Ok(...)orTypedResults.NotFound(): the result type is visible in the signature.
Common mistake
Piling every Minimal API handler into a thousand-line Program.cs. Group endpoints by resource into extension methods such as MapOrders(this IEndpointRouteBuilder app).
Takeaway: a controller is a class of actions in the MVC pipeline, a Minimal API is a lambda on a route; start new APIs with Minimal APIs.
How it works
- Both approaches register endpoints in the same routing system:
MapControllers()builds them from attributes,MapGetfrom delegates. - A controller endpoint runs MVC: controller activation through DI, filters, model binding, validation, the action and result formatting.
- A Minimal API endpoint is a single
RequestDelegate: parameters are read by code built at startup, and the response is serialized to JSON.
When to choose what
| Need | Approach |
|---|---|
| New service, microservice, Native AOT | Minimal API |
Custom IModelBinder, IModelValidator, application parts, OData |
controllers |
XML or another format chosen by Accept |
controllers (formatters) |
Pitfalls
- Without structure, a Minimal API turns into a huge
Program.cs. A route group sets a shared prefix, filters, authorization and metadata for a set of endpoints. Results.Ok(...)returnsIResultand tells the API description nothing about the response type.TypedResultsandResults<Ok<T>, NotFound>list every possible response right in the signature.- MVC filters do not apply to Minimal APIs: they have their own endpoint filters.
Rule of thumb
New API — Minimal APIs with groups per resource and TypedResults. Controllers — when you cannot do without MVC features or when they are the standard in existing code.
Takeaway: both approaches produce the same endpoints; controllers add the MVC pipeline, Minimal APIs add less code and overhead.
Under the hood
- A Minimal API handler becomes a
RequestDelegateonce, at startup. NormallyRequestDelegateFactorydoes this with expression trees; withPublishAotthe Request Delegate Generator does it at compile time. - A controller action goes through
ControllerActionInvokeron every request: controller activation, the filter chain,ModelStateDictionary, formatter selection. TypedResultstypes implementIEndpointMetadataProvider, so response codes and types land in endpoint metadata without attributes.
Cost
- MVC overhead is per-request allocations of contexts and filters. It shows up in benchmarks but is usually smaller than a single database round trip.
- Native AOT works only with Minimal APIs and requires source-generated JSON (
JsonSerializerContext): trimming cuts reflection away at publish time.
Trade-offs
- Controllers give you one consistent style out of the box: filters, conventions,
[ApiController]. In Minimal APIs the same discipline comes from groups, extension methods and endpoint filters — and it has to be written down as team rules. - The approaches mix in one app: routing is shared, and
AddEndpointFilterworks onMapControllers()too. - Rewriting controllers for speed rarely pays off: the gain is microseconds per request.
How to check in production
Compare your own endpoint with identical logic, not the approaches in general: BenchmarkDotNet plus the http.server.request.duration metric before and after the rewrite.
Takeaway: Minimal APIs mean less infrastructure per request and a path to Native AOT, controllers mean a rich, extensible MVC pipeline; choose by features, not by fashion.
using Microsoft.AspNetCore.Http.HttpResults;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();var app = builder.Build();
// Minimal API: a group with a shared prefix, every response variant in the signaturevar products = app.MapGroup("/api/v2/products").WithTags("Products");products.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id) => id == 1 ? TypedResults.Ok(new ProductDto(1, "Laptop")) : TypedResults.NotFound());
app.MapControllers(); // controller routes come from attributesapp.Run();
// The same endpoint on a controller[ApiController][Route("api/v1/products")]public class ProductsController : ControllerBase{ [HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => id == 1 ? new ProductDto(1, "Laptop") : NotFound();}
public record ProductDto(int Id, string Name);- How do you move Minimal API endpoints out of
Program.cs? — Extension methods onIEndpointRouteBuilderand aMapGroupper resource. - Why
Results<Ok<T>, NotFound>when there isIResult? — Every response variant is visible in the signature, the API description and unit tests. - Do MVC filters work in Minimal APIs? — No, they use endpoint filters (
AddEndpointFilter, .NET 7+). - Can controllers be published with Native AOT? — No, MVC is not compatible with Native AOT; only Minimal APIs are supported.
- How does
ControllerBasediffer fromController? —Controlleradds view support;ControllerBaseis enough for an API.
How do model binding and validation work?
Section titled “How do model binding and validation work?”What it is
- Model binding — the framework pulls values out of the request and passes them to method parameters.
- Validation — checking the populated model against rules, usually attributes from
System.ComponentModel.DataAnnotations.
Where values come from
| Attribute | Source |
|---|---|
[FromRoute] |
path segment: /orders/{id} |
[FromQuery] |
query string: ?page=2 |
[FromHeader] |
request header |
[FromBody] |
JSON request body |
How to use
- Put attributes on DTO properties:
[Required],[StringLength(50)],[Range(1, 100)]. - In a controller with
[ApiController], an invalid model produces a 400 immediately; no need to checkModelState.IsValid. - In Minimal APIs on .NET 10, call
builder.Services.AddValidation().
Common mistake
Expecting [Required] on an int to catch a missing field. A value type without a value gets 0, and the attribute treats it as present. You need int? with [Required], or [Range].
Takeaway: binding fills parameters from the request, attributes validate them, and a failure becomes a 400 response.
How it works
| Parameter | [ApiController] |
Minimal API |
|---|---|---|
| Name matches a route segment | [FromRoute] |
from the route |
| Other simple type | [FromQuery] |
from the query string |
| Type registered in DI | [FromServices] |
from DI |
| Complex type | [FromBody] |
from the JSON body |
IFormFile |
[FromForm] |
from the form |
Validation
- In controllers, validation runs right after binding and errors collect in
ModelState.[ApiController]adds a filter that returns a 400 withValidationProblemDetails— a “field → messages” dictionary. - Without
[ApiController]you checkModelState.IsValidby hand in every action. - In Minimal APIs since .NET 10,
AddValidation()checks DataAnnotations andIValidatableObjecton parameters and nested objects and also returns a 400. To turn validation off for an endpoint, useDisableValidation().
Pitfalls
- A parse failure (
abcfor anint) in a Minimal API produces a 400 before the handler, without per-field errors. - In MVC with the nullable context enabled, a non-nullable reference property (
string Name) is treated as[Required]. - An action has at most one parameter bound from the body: the body is read once.
Rule of thumb
DataAnnotations are for the shape of data: required fields, lengths, ranges. Cross-field rules go to IValidatableObject or FluentValidation, business checks to the service.
Takeaway: attributes or inference rules set each parameter’s source, and [ApiController] and AddValidation() turn validation errors into a standard 400.
Under the hood
- The MVC order: resource filters → model binding (an
IModelBinderper parameter, values from value providers) → validation (IObjectModelValidatorwalks the graph) → action filters. - The automatic 400 is the
ModelStateInvalidFilteraction filter with a very early order (-2000), so even your own action filters never see the request. - Minimal APIs build binding code once per endpoint. Simple types are parsed via
TryParseorIParsable<T>, custom ones via a staticBindAsync. - .NET 10 validation is built by a source generator, and it only sees types in its own assembly. If endpoints live in another assembly, call
AddValidation()there.
Cost
- The MVC validator walks the whole model graph, collections included: a large JSON array in the body costs noticeable CPU.
[FromBody]reads the whole body into memory; the limit is Kestrel’sMaxRequestBodySize, about 28.6 MB by default.
Trade-offs
- DataAnnotations are declarative and show up in the API description, but conditional rules are awkward with them. FluentValidation is more flexible, but its authors no longer recommend the automatic MVC integration — call the validator explicitly or from a filter.
- By default, shape errors are 400. Business rule violations are often returned as 422 — make that decision part of the API contract.
How to check in production
Automatic 400s never reach the action and are easy to miss. Log them through InvalidModelStateResponseFactory and watch the share of 400s in http.server.request.duration by http.route.
Takeaway: binding and validation are separate stages before the handler; attributes describe the shape of data, and domain rules live in service code.
using System.ComponentModel.DataAnnotations;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers();builder.Services.AddValidation(); // .NET 10: validation in Minimal APIsvar app = builder.Build();
// customerId comes from the route, requestId from a header, order from the JSON bodyapp.MapPost("/api/customers/{customerId:int}/orders", (int customerId, [FromHeader(Name = "X-Request-Id")] string? requestId, CreateOrderRequest order) => TypedResults.Created($"/api/customers/{customerId}/orders/1", order));// body { "sku": "", "quantity": 0 } → 400, the handler is not called
app.MapControllers();app.Run();
[ApiController] // invalid parameters → automatic 400[Route("api/products")]public class ProductsController : ControllerBase{ [HttpGet] // ?page=2&name=phone public IActionResult Search([FromQuery, Range(1, 1000)] int page = 1, [FromQuery] string? name = null) => Ok(new { page, name });}
public record CreateOrderRequest([Required, StringLength(50)] string Sku, [Range(1, 100)] int Quantity);- How do you bind several query parameters into one object in a Minimal API? —
[AsParameters](.NET 7+): the object’s properties bind as separate parameters. - Why doesn’t
[Required]work on anint? — A value type always has the value0; useint?. - How do you turn off the automatic 400 of
[ApiController]? —ApiBehaviorOptions.SuppressModelStateInvalidFilter = true. - What happens in MVC when the JSON lacks a non-nullable
stringproperty? — A validation error: with the nullable context it is treated as[Required]. - Where do you check that a product exists? — In the service or handler: it is a business rule, answered with 404 or 409, not 400.
How do you design a REST API: resources, methods, status codes, idempotency, pagination?
Section titled “How do you design a REST API: resources, methods, status codes, idempotency, pagination?”What it is
REST models an API as a set of resources. A resource has an address, and operations on it are expressed with standard HTTP methods.
How to use
| Request | Action and response |
|---|---|
GET /orders |
list orders, 200 |
GET /orders/42 |
one order, 200 or 404 |
POST /orders |
create an order, 201 plus a Location header |
PUT /orders/42 |
replace it entirely, 200 or 204 |
DELETE /orders/42 |
delete, 204 |
Client errors are 4xx: 400 (invalid request), 401 (not authenticated), 403 (not allowed), 404 (no such resource), 409 (conflict). Server errors are 5xx.
Common mistake
Verbs in URLs (/getOrders, /createOrder) and a 200 response with { "error": "..." } inside. The action is already expressed by the method, and the error by the status code.
Takeaway: the URL is the resource, the method is the action, the status code is the outcome.
How it works
| Method | Safe | Idempotent |
|---|---|---|
| GET, HEAD | yes | yes |
| PUT, DELETE | no | yes |
| POST | no | no |
| PATCH | no | not guaranteed |
- A safe method does not change server state.
- An idempotent one leaves the server in the same state whether the request runs once or many times. A repeated DELETE breaks nothing, even if it returns 404.
When to choose what
- Full replacement — PUT; partial update — PATCH (JSON Merge Patch or JSON Patch).
- An action outside CRUD, such as cancelling an order, becomes a sub-resource:
POST /orders/42/cancellation. - A long-running operation — 202 Accepted plus a URL where the client checks its status.
Pagination
?offset=40&limit=20is simple and gives page numbers, but slows down on far pages and skips rows when new ones are inserted.- Keyset:
?after=40&limit=20, whereafteris the key of the last row. Stable and fast on an index, but it cannot jump to an arbitrary page.
Pitfalls
- 401 means “who are you?”, 403 means “I know who you are, but no”.
- Without a server-side maximum, a request with
limit=1000000takes the database down.
Rule of thumb
Resource — a noun, action — a method, outcome — a code. Every list has a server-side limit and a pointer to the next page.
Takeaway: HTTP semantics are a contract: safe and idempotent requests can be retried, and pagination protects the server from huge responses.
Under the hood
- The network is unreliable: if a response is lost, the client does not know whether the request was applied. Retrying a PUT is safe, retrying a POST creates a duplicate.
Idempotency-Keyis a widespread practice (an IETF draft, not an RFC). The client sends a unique key, the server stores the result of the first processing and returns it for a retry with the same key without executing again.- An ETag is a version of the resource representation.
GETreturns anETag, and aPUTwithIf-Matchsucceeds only if the version is unchanged, otherwise 412 Precondition Failed. This is optimistic concurrency over HTTP; in EF Core the counterpart is a concurrency token.
Cost
- Idempotency keys need a store with a TTL, an atomic write of the key together with the operation, and an answer for “key still in progress”, usually 409.
OFFSET Nmakes the database read and discard N rows: time grows with the page number. AWHERE id > @after ORDER BY idquery with a limit walks the index in constant time.COUNT(*)for “total pages” is expensive on large tables and is often dropped.
Trade-offs
- Make the cursor opaque, for example base64 of the sort key, so the implementation can change without changing the contract.
- HATEOAS — links to possible actions in the response — is the full REST model, but in practice teams settle for a next-page link and a machine-readable API description.
- Strict REST is not dogma: a search with complex filters can be
POST /orders/search.
How to check in production
Look for duplicate entities caused by retries and track the share of 409 and 412 per http.route. Slow far pages show up in the query plan and the database slow query log.
Takeaway: a robust API is built for retries and concurrency: idempotency for retries, ETags for concurrent edits, keyset pagination for large collections.
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();var db = new List<Order>(); // stands in for a databasevar orders = app.MapGroup("/api/orders");
// GET /api/orders?after=40&limit=20 — keyset pagination by Idorders.MapGet("/", (int? after, int? limit) =>{ int size = Math.Clamp(limit ?? 20, 1, 100); // server-side maximum var items = db.Where(o => o.Id > (after ?? 0)).OrderBy(o => o.Id).Take(size).ToList(); return TypedResults.Ok(new Page<Order>(items, items.Count == size ? items[^1].Id : null));});
// POST creates a resource: 201 plus a Location headerorders.MapPost("/", (NewOrder dto) =>{ var order = new Order(db.Count + 1, dto.Sku, dto.Quantity); db.Add(order); return TypedResults.Created($"/api/orders/{order.Id}", order);});
// DELETE is idempotent: a repeated call also returns 204orders.MapDelete("/{id:int}", (int id) =>{ db.RemoveAll(o => o.Id == id); return TypedResults.NoContent();});
app.Run();
record Order(int Id, string Sku, int Quantity);record NewOrder(string Sku, int Quantity);record Page<T>(List<T> Items, int? NextAfter);- How does PUT differ from PATCH? — PUT replaces the whole resource and is idempotent; PATCH changes part of it, and idempotency is not guaranteed.
- What should a repeated DELETE return? — 204 or 404, both are fine; server state does not change, and that is idempotency.
- How do you make POST safe to retry? — An
Idempotency-Keyheader and the stored result of the first processing. - Why is OFFSET pagination slow? — The database reads and discards every skipped row; keyset walks the index.
- When do you return 202 Accepted? — The operation is accepted but will run later; the response points to a status URL.
How do you handle errors in an API: ProblemDetails, UseExceptionHandler and IExceptionHandler?
Section titled “How do you handle errors in an API: ProblemDetails, UseExceptionHandler and IExceptionHandler?”What it is
ProblemDetails is a standard error response body (RFC 9457, formerly RFC 7807) with the application/problem+json content type:
type— a link that identifies the kind of error;title— a short summary;status— the HTTP code;detail— specifics of this occurrence.
Why
The client parses every error with one piece of code instead of guessing the format of each endpoint.
How to use
builder.Services.AddProblemDetails();app.UseExceptionHandler();— an exception becomes a 500 with ProblemDetails.app.UseStatusCodePages();— empty 4xx and 5xx responses get a ProblemDetails body.- Return expected errors explicitly:
TypedResults.NotFound(),TypedResults.Problem(...).
Common mistake
Sending exception.ToString() to the client. A stack trace reveals how the service is built and helps an attacker; the details belong in the log.
Takeaway: every error in ProblemDetails format, no stack trace, details in the logs.
How it works
UseExceptionHandlersits early in the pipeline and wraps the rest of the middleware intry/catch.- On an exception it clears the response, if sending has not started, and sets status 500.
- It then calls the registered
IExceptionHandlerimplementations in order until one returnstrue. - If none handles it, it writes ProblemDetails through
IProblemDetailsServiceor re-executes the request on a path like/error.
When to choose what
| Situation | Tool |
|---|---|
| Expected outcome: no resource, conflict | TypedResults.NotFound(), TypedResults.Conflict() |
| Your exception → status code and body | IExceptionHandler (.NET 8+) |
| Simple “type → code” mapping | ExceptionHandlerOptions.StatusCodeSelector (.NET 9+) |
| Shared fields in every response | ProblemDetailsOptions.CustomizeProblemDetails |
Pitfalls
- An
IExceptionHandlerwithoutapp.UseExceptionHandler()is never called. app.UseExceptionHandler()with no arguments and noAddProblemDetails()fails at startup withInvalidOperationException.- If the response has already started, for example while streaming, the status cannot change: the connection is simply aborted.
- In Development an unhandled exception shows the Developer Exception Page with the stack; it must never run in production.
Rule of thumb
Exceptions for the unexpected, results for the expected. One error format across the API and a traceId in every response.
Takeaway: AddProblemDetails, UseExceptionHandler and IExceptionHandler give a single error format without leaking details.
Under the hood
DefaultProblemDetailsWriterwrites the response only if theAcceptheader allowsapplication/jsonorapplication/problem+json. OtherwiseTryWriteAsyncreturnsfalse, and you need a fallback.- The writer adds a
traceIdfromActivity.Current: the W3C trace id, the same one that appears in logs and distributed traces. - Controllers with
[ApiController]turn bodiless results with status 400 and above, such asNotFound(), into ProblemDetails themselves viaProblemDetailsFactory.
Cost
- An exception means capturing the stack and unwinding frames — orders of magnitude more expensive than
return. A stream of exceptions on ordinary paths (validation, “not found”) is visible in a CPU profile. - So domain outcomes are expressed as result types:
Results<Ok<T>, NotFound, Conflict>or your ownResult.
Trade-offs
- Since .NET 10 the middleware writes no logs or metrics for exceptions handled by an
IExceptionHandler. Log errors that matter for alerts in the handler itself, or restore the old behavior withSuppressDiagnosticsCallback = _ => false. - A stable
type, such as a URI in your docs, is part of the contract: clients branch on it, whiletitleanddetailmay change. - Exception details in the response are acceptable only in Development.
How to check in production
- The
aspnetcore.diagnostics.exceptionsmetric (.NET 8+) counts exceptions in the middleware by type; since .NET 10, those handled by anIExceptionHandlerare excluded by default. http.server.request.durationwith theerror.typeattribute shows whichhttp.routevalues have a growing share of 5xx.- The
traceIdfrom the client’s response leads to the error in logs and traces.
Takeaway: ProblemDetails is the error contract: a stable type for the client, a traceId for the operator, and no internals leaking out.
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddProblemDetails();builder.Services.AddExceptionHandler<NotFoundExceptionHandler>(); // .NET 8+var app = builder.Build();
app.UseExceptionHandler(); // exception → 500 and ProblemDetails without a stack traceapp.UseStatusCodePages(); // empty 4xx and 5xx → ProblemDetails
app.MapGet("/api/orders/{id:int}", (int id) => id == 1 ? TypedResults.Ok(new { Id = 1 }) : throw new KeyNotFoundException($"Order {id} not found"));// /api/orders/2 → 404, body: type, title, status, detail, traceId
app.MapGet("/api/crash", () => { throw new InvalidOperationException("Connection failed: password=secret"); });// → 500, the exception text is not in the response
app.Run();
sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct) { if (exception is not KeyNotFoundException) return false; // not ours — next in the chain context.Response.StatusCode = StatusCodes.Status404NotFound; return await problemDetails.TryWriteAsync(new() { HttpContext = context, Exception = exception, ProblemDetails = { Title = "Resource not found", Detail = exception.Message } }); }}- Why
IExceptionHandlerrather than your own middleware withtry/catch? — It is built intoUseExceptionHandler: response clearing, a handler chain, ProblemDetails and DI out of the box. - What happens if the exception occurs after the response has started? — The status and headers cannot change; the middleware rethrows and the connection is aborted.
- Why
UseStatusCodePages? — Empty 4xx and 5xx responses, such as a routing 404, get a ProblemDetails body. - Why not express expected errors as exceptions? — Exceptions are expensive and hide control flow; prefer
Results<...>or your own Result type. - What changed in .NET 10 for handled exceptions? — They are no longer logged by default;
SuppressDiagnosticsCallbackbrings that back.
How do filters differ from middleware, and what kinds are there?
Section titled “How do filters differ from middleware, and what kinds are there?”What it is
A filter is a class or attribute that ASP.NET Core calls around a controller action. It can run code before the action, after it, or prevent the action from running at all.
Why
To remove repeated code from actions: timing, header checks, auditing, a uniform response format.
How to use
- Implement an interface, for example
IAsyncActionFilterwithOnActionExecutionAsync. - Register the filter globally (
options.Filters.Add<T>()inAddControllers) or as an attribute on a controller or action. - A filter with DI dependencies needs
[ServiceFilter(typeof(...))]or[TypeFilter(typeof(...))].
Common mistake
Putting logic that must apply to every request, such as CORS, into a filter. The filter is not called for static files, Minimal APIs or requests that never reach an action.
Takeaway: middleware is for the whole pipeline, a filter is for logic around an action.
How it works
The order inside MVC:
- Authorization — run first and can return 401 or 403 immediately.
- Resource — wrap everything else and run before model binding.
- Model binding and validation.
- Action — before and after the action method, see arguments and the result.
- Exception — catch exceptions from controller creation, binding, action filters and the action.
- Result — before and after the
IActionResultexecutes.
When to choose what
| Task | Where |
|---|---|
| Compression, CORS, HTTP logging | middleware |
| Argument checks, action auditing | action filter or endpoint filter |
| One controller’s exceptions → a special response | exception filter |
| Global error handling | UseExceptionHandler |
Pitfalls
- Global filters wrap controller filters, which wrap action filters. Change the order with
IOrderedFilter.Order. - Result filters do not run if an authorization or resource filter short-circuited the pipeline. That case is covered by
IAlwaysRunResultFilter. - A filter attribute instance is reused across requests: a stateful field in it is a race, and you cannot pass dependencies into the attribute’s constructor.
Rule of thumb
Anything that does not depend on the selected action goes into middleware. Anything that works with arguments, ModelState or the action result goes into a filter.
Takeaway: middleware is the application-wide pipeline, filters are a pipeline around the action that knows its context.
Under the hood
- A controller endpoint runs
ControllerActionInvoker. The filter list for an action is built once and cached, and per request the invoker walks it as a state machine. - An endpoint filter receives an
EndpointFilterInvocationContextwith the handler arguments:GetArgument<T>(index). Code beforenextruns in registration order, code afternextin reverse. AddEndpointFilterFactoryreceives the handler’sMethodInfowhen the endpoint is built and can skip adding the filter if the signature does not fit.
Cost
- Each filter is an extra call per request, and
TypeFilteror a transient filter viaServiceFilteralso allocates an object. Dozens of global filters are noticeable on hot endpoints. - A resource filter runs before model binding: it can reject a request without reading the body and save work.
Trade-offs
- An exception filter knows the controller and action but misses errors from middleware, resource and result filters, and response serialization. The global safety net is
UseExceptionHandler. - In a mixed app, shared logic (auditing, client version checks) is written as an endpoint filter and attached to both
MapGroupandMapControllers(). - Filters are invisible in
Program.cs: many attributes on controllers hide behavior. Global registration in one place is easier to read.
How to check in production
At the Debug log level for Microsoft.AspNetCore.Mvc, MVC logs the execution plan for each filter type. The time spent in individual filters shows up in traces if you wrap them in an Activity.
Takeaway: filters give you the action context at the price of MVC coupling; keep cross-cutting logic in middleware and global errors in UseExceptionHandler.
using System.Diagnostics;using Microsoft.AspNetCore.Mvc.Filters;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers(o => o.Filters.Add<TimingFilter>()); // global action filtervar app = builder.Build();
app.MapControllers();
// Minimal API: the endpoint filter sees the handler argumentsapp.MapGet("/api/items/{id:int}", (int id) => TypedResults.Ok(id)) .AddEndpointFilter(async (context, next) => { int id = context.GetArgument<int>(0); if (id <= 0) return TypedResults.Problem("Id must be positive", statusCode: 400); // handler not called return await next(context); });
app.Run();
// Action filter: after model binding, around the action methodpublic class TimingFilter(ILogger<TimingFilter> logger) : IAsyncActionFilter{ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var sw = Stopwatch.StartNew(); ActionExecutedContext executed = await next(); // the action itself runs here logger.LogInformation("{Action}: {Ms} ms, failed: {Failed}", context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds, executed.Exception is not null); }}- In what order do MVC filters run? — Authorization → resource → model binding → action → exception → result.
- Will an exception filter catch a response serialization error? — No: it does not see exceptions from result filters or result execution.
- How do you inject a dependency into a filter attribute? —
[ServiceFilter]resolves the filter from DI,[TypeFilter]creates it viaObjectFactory. - How do you short-circuit the pipeline in a filter? — Set
context.Resultand do not callnext. - Do Minimal APIs have filters? — Yes,
AddEndpointFilterandIEndpointFiltersince .NET 7; they can be attached to a route group.
How do you version an API and describe it with OpenAPI?
Section titled “How do you version an API and describe it with OpenAPI?”What it is
- API version — a contract number. The client picks it in the request, and the server responds by that version’s rules.
- OpenAPI (formerly Swagger) — a JSON or YAML document listing paths, parameters, request bodies, responses and status codes.
Why
You cannot update an old mobile app instantly. While it is still in use, /v1 must keep working as before, and new clients move to /v2.
How to use
- Add the
Microsoft.AspNetCore.OpenApipackage and callbuilder.Services.AddOpenApi()andapp.MapOpenApi(). The document appears at/openapi/v1.json. - There is no built-in UI: a documentation page such as Swagger UI or Scalar is added separately.
- The simplest versioning is a route group per version:
app.MapGroup("/api/v1").
Common mistake
Changing the response format inside an existing version: renaming a field or making it required. Old clients stop parsing the response.
Takeaway: a version shields existing clients from breaking changes, and OpenAPI describes the contract of each version.
How it works
| Where the version goes | Example |
|---|---|
| Path segment | /api/v2/orders |
| Query parameter | /api/orders?api-version=2.0 |
| Header | X-Api-Version: 2.0 |
| Media type | Accept: application/json; v=2.0 |
- Asp.Versioning (
Asp.Versioning.Httpfor Minimal APIs,Asp.Versioning.Mvcfor controllers) reads the version from the request, selects the endpoint and, with theReportApiVersionsoption, writes supported versions to theapi-supported-versionsheader. Microsoft.AspNetCore.OpenApibuilds the document from endpoint metadata: the route, parameters,TypedResults,Produces,WithSummary, and since .NET 10 XML comments as well.
When to choose what
- A path segment is explicit, easy to route on a gateway and visible in logs — it is the most common choice.
- Query and header keep the resource URL unchanged, but clients easily forget the version, so you need a default version.
Pitfalls
- Without configuration, all versions land in one document. You need a document per version:
AddOpenApi("v2")andWithGroupName("v2")on endpoints; Asp.Versioning 10 with theAsp.Versioning.OpenApipackage does this for you. WithOpenApi()is deprecated in .NET 10 (warning ASPDEPR002): adjust the description with document and operation transformers.- Exposing the document in production widens the attack surface, so the template serves it only in Development.
Rule of thumb
Bump the version only for a breaking change. Backward-compatible additions — a new optional field, a new endpoint — go into the current version.
Takeaway: pass the version in the path, query or a header via Asp.Versioning, and describe it with built-in OpenAPI — one document per version.
Under the hood
- Asp.Versioning adds version metadata to endpoints and its own matcher policy: endpoints with the same template differ by the requested version, and an unsupported version gets an error in ProblemDetails format.
Microsoft.AspNetCore.OpenApireads metadata from API Explorer. Since .NET 10 it runs on Microsoft.OpenApi 2.0: OpenAPI 3.1 with JSON Schema 2020-12 schemas, and YAML viaMapOpenApi("/openapi/{documentName}.yaml").- The
Microsoft.Extensions.ApiDescription.Serverpackage withOpenApiGenerateDocumentsOnBuildgenerates the document at build time, without a deployed service.
Cost
- Every live version means code, tests and monitoring. Teams usually support the current and previous versions and retire the rest on a schedule.
- The document is built on request to
/openapi/...; in production cache it (CacheOutput()) or serve a prebuilt file.
Trade-offs
- Code-first (the document from code) suits a single .NET team. Contract-first (YAML first, then code) suits a contract negotiated by several teams; the generated document is then diffed against the reference in CI.
- Announce deprecation ahead of time: Asp.Versioning marks versions as deprecated and publishes a retirement policy that clients see in headers.
- One version for the whole API is simpler for clients, per-resource versions mean less duplicated code.
How to check in production
- Count requests per version, for example by
http.routewith the path segment: without that data you cannot retire an old version. - In CI, diff the OpenAPI document against the previous release with a tool like oasdiff and fail the build on breaking changes.
Takeaway: versioning is a lifecycle policy for contracts, and OpenAPI lets you enforce it automatically instead of in code review.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi(); // document: GET /openapi/v1.json
// v1: existing clients keep the old contractvar v1 = app.MapGroup("/api/v1/orders").WithTags("Orders");v1.MapGet("/{id:int}", (int id) => TypedResults.Ok(new OrderV1(id, 100m))) .WithSummary("Get order (v1)");
// v2: a breaking change — the total became an object with a currencyvar v2 = app.MapGroup("/api/v2/orders").WithTags("Orders");v2.MapGet("/{id:int}", Results<Ok<OrderV2>, NotFound> (int id) => id > 0 ? TypedResults.Ok(new OrderV2(id, new Money(100m, "EUR"))) : TypedResults.NotFound()) .WithSummary("Get order (v2)") .ProducesProblem(StatusCodes.Status500InternalServerError);
app.Run();
record OrderV1(int Id, decimal Total);record OrderV2(int Id, Money Total);record Money(decimal Amount, string Currency);The WithTags, WithSummary and ProducesProblem metadata and the TypedResults types end up in the OpenAPI document without attributes.
- Do you need a new version if you add a field to a response? — No, it is backward compatible as long as clients ignore unknown fields.
- How does built-in OpenAPI differ from Swashbuckle? — A Microsoft package, in the templates since .NET 9, transformers instead of filters, no built-in UI.
- How do you get the document without running the service? —
Microsoft.Extensions.ApiDescription.ServerandOpenApiGenerateDocumentsOnBuildgenerate it at build time. - How do you tell clients a version is being retired? — Mark it deprecated in Asp.Versioning, send
DeprecationandSunset, and publish the shutdown date. - Where is the version easiest to handle on an API gateway? — In the path segment: prefix routing without header parsing.
How do you make an API fast and resilient under load, and how do you measure it?
Section titled “How do you make an API fast and resilient under load, and how do you measure it?”What it is
API speed is the response time of a single request. Resilience is the ability to handle many requests and survive spikes.
How to use
- Async all the way. Database and network calls only through
await:ToListAsync,SaveChangesAsync,HttpClient.GetAsync. - Less data. Lists by page with a maximum page size; only the needed columns from the database via
Selectinto a DTO. - Compression.
AddResponseCompression()shrinks JSON several times over. - Protection.
AddRateLimiter()limits requests per client,AddRequestTimeouts()cancels requests that run too long.
Common mistake
Calling .Result or .Wait() on a task in a handler. A thread pool thread blocks, and under load the service stalls while the CPU sits idle.
Takeaway: async everywhere, less data in responses, limits at the entrance.
How it works
| Technique | What it gives |
|---|---|
| Rate limiting (.NET 7+) | rejects excess requests before processing |
| Request timeouts (.NET 8+) | cancels RequestAborted on timeout, otherwise a 504 response |
| Response compression | less traffic at the cost of CPU (Brotli, Gzip) |
IAsyncEnumerable<T> as the response |
the JSON array goes out as it is read, without a buffer |
When to choose what
- Fixed window, sliding window and token bucket limit requests per period; the concurrency limiter limits simultaneous ones. Attach a policy with
RequireRateLimitingand partition it by user or API key. - A timeout — for endpoints with unpredictably long work: reports, external calls.
- Compression in the app — only if no proxy in front of it compresses already.
Pitfalls
RejectionStatusCodedefaults to 503, while 429 Too Many Requests is the right answer for the client.- A timeout only cancels the token: code that does not pass the
CancellationTokento the database keeps running. Timeouts do not fire under a debugger. EnableForHttpsfor compression is off by default because of the CRIME and BREACH attacks: do not compress responses that mix secrets with user-controlled data.
Rule of thumb
First measure where the time goes: database, network, serialization. Optimize the bottleneck, and always put limits and timeouts in place.
Takeaway: async and less data make it faster, limits and timeouts protect it, and metrics show which of these you actually need.
Under the hood
- When handlers wait on the database,
http.server.active_requestsand the thread pool queue grow, and p99 climbs into seconds before the CPU gets busy. - A buffered response of tens of megabytes means objects on the LOH and Gen 2 collections. Streaming
IAsyncEnumerable<T>with EF Core’sAsAsyncEnumerable()keeps one element in memory, not the whole list. - Synchronous I/O on the request body has been disallowed since ASP.NET Core 3.0 (
AllowSynchronousIO = false): it would block a thread pool thread. - HTTP/2 multiplexes requests over one connection, HTTP/3 removes head-of-line blocking on packet loss. In Kestrel, HTTP/3 is enabled explicitly (
Http1AndHttp2AndHttp3) and requires TLS.
Cost
- An in-memory rate limiter counts requests for one instance: with five pods the effective limit is five times higher. A shared limit belongs on the gateway or in a distributed store.
- An IP partition behind a proxy sees the proxy’s address until forwarded headers are configured, and it is vulnerable to address spoofing.
- Streaming commits the 200 status until the end: a failure midway yields truncated JSON, not ProblemDetails.
Trade-offs
- Caching is a separate layer: output caching for whole responses and
HybridCachefor data are covered in the caching topic. - Rejecting early is cheaper: a 429 in milliseconds beats a timeout after 30 seconds that the client will retry.
How to check in production
- A load test (k6, NBomber, bombardier) with RPS ramping up to degradation: look at p95, p99 and the error rate, not the average.
dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting,System.Runtime— request duration, active requests, thread pool queue, allocations.aspnetcore.rate_limiting.requestsshows limiter rejections,kestrel.queued_connectionsshows whether connection acceptance is the bottleneck.
Takeaway: resilience is layers of protection plus measurement on tail latency, not a single optimization.
using System.Runtime.CompilerServices;using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddRequestTimeouts();builder.Services.AddRateLimiter(o =>{ o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // 503 by default o.AddPolicy("per-ip", ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromSeconds(1) }));});var app = builder.Build();app.UseRateLimiter();app.UseRequestTimeouts();
// After 2 s RequestAborted is cancelled; an unhandled cancellation → 504app.MapGet("/api/report", async (CancellationToken ct) =>{ await Task.Delay(TimeSpan.FromSeconds(5), ct); return TypedResults.Ok("done");}).WithRequestTimeout(TimeSpan.FromSeconds(2)).RequireRateLimiting("per-ip");
// Streaming: items go to the client as they are ready, no buffer for the whole responseapp.MapGet("/api/events", (CancellationToken ct) => ReadEventsAsync(ct)).RequireRateLimiting("per-ip");
app.Run();
static async IAsyncEnumerable<EventDto> ReadEventsAsync([EnumeratorCancellation] CancellationToken ct){ for (int i = 0; i < 1_000; i++) { await Task.Delay(10, ct); // simulates reading from a database yield return new EventDto(i, DateTimeOffset.UtcNow); }}
record EventDto(int Id, DateTimeOffset At);- What status does the rate limiter return by default? — 503; usually you set 429 and a
Retry-Afterheader. - Will a request timeout stop a database query? — Only if the
RequestAbortedtoken is passed into the call; the timeout itself just cancels the token. - Why doesn’t an in-memory limiter work across several instances? — Each counts its own requests; a shared limit lives on the gateway or in a distributed store.
- Why stream
IAsyncEnumerable<T>? — The response goes out as it is read and the whole list is never in memory; but the 200 status is committed until the end. - Why leave compression to the proxy? — Less CPU in the app, and server compression modules outperform the ASP.NET Core middleware.