Controllers, dependency injection and middleware
Controller-based APIs, the three service lifetimes and the middleware order that decides whether authentication runs before routing.
Controllers
A controller is a class whose public methods are actions. [ApiController] turns on automatic model validation, inference of binding sources and Problem Details responses for invalid input.
[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController : ControllerBase
{
private readonly IOrderStore _store;
private readonly ILogger<OrdersController> _log;
public OrdersController(IOrderStore store, ILogger<OrdersController> log)
{
_store = store;
_log = log;
}
[HttpGet("{id:int}")]
[ProducesResponseType<Order>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Order>> Get(int id, CancellationToken ct)
{
var order = await _store.FindAsync(id, ct);
if (order is null)
{
_log.LogInformation("Order {OrderId} not found", id);
return NotFound();
}
return order;
}
[HttpPost]
public async Task<ActionResult<Order>> Create(CreateOrder request, CancellationToken ct)
{
if (!ModelState.IsValid) return ValidationProblem(ModelState);
var created = await _store.AddAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}
}ActionResult<T>lets one action return either a value or a status code without throwing.- Constructor injection is the only pattern you need; do not reach into
HttpContext.RequestServicesto resolve dependencies. [ApiController]alone already returns a 400 with validation details for a malformed body — no manual check required, though an explicit check documents intent.
Service lifetimes
| Lifetime | Created | Use it for |
|---|---|---|
Singleton | Once per application | Stateless caches, configuration wrappers, expensive thread-safe clients |
Scoped | Once per HTTP request | DbContext, per-request repositories, anything holding request state |
Transient | Every time it is requested | Lightweight stateless helpers that must not be shared |
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderStore, EfOrderStore>();
builder.Services.AddTransient<IOrderValidator, OrderValidator>();
// register an implementation against several abstractions
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderReader>(sp => sp.GetRequiredService<OrderRepository>());
builder.Services.AddScoped<IOrderWriter>(sp => sp.GetRequiredService<OrderRepository>());
// typed HTTP client: owns the handler pool and sets the base address
builder.Services.AddHttpClient<IPricingClient, PricingClient>(c =>
{
c.BaseAddress = new Uri("https://pricing.internal/");
c.Timeout = TimeSpan.FromSeconds(5);
});- A singleton must never depend on a scoped service. The container validates this at startup when you build in Development.
AddSingletonwith a capturedHttpClientis the classic socket-exhaustion bug;AddHttpClientexists to prevent it.- Registering the same service twice means the last registration wins for a single resolve, but
IEnumerable<T>receives all of them.
The pipeline
Middleware is a chain of delegates. Each component decides whether to call the next one and can act on the request on the way in and the response on the way out. Order is behaviour.
var app = builder.Build();
app.UseExceptionHandler(); // outermost: catches everything below
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting(); // selects the endpoint
app.UseCors();
app.UseAuthentication(); // must come before authorization
app.UseAuthorization();
app.UseResponseCompression();
app.MapControllers();
app.Run();
// a custom component, written as a class so it can take DI dependencies
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _log;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> log)
{
_next = next;
_log = log;
}
public async Task InvokeAsync(HttpContext context)
{
var started = Stopwatch.GetTimestamp();
try
{
await _next(context);
}
finally
{
var ms = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
_log.LogInformation("Handled {Path} in {Elapsed} ms with {Status}",
context.Request.Path, ms, context.Response.StatusCode);
}
}
}⚠️
Calling
UseAuthorization before UseAuthentication produces a request with no user identity, so every authenticated endpoint returns 401 and no exception tells you why. Read the pipeline top to bottom when authorization misbehaves.FAQ
Why does my DbContext throw a concurrency exception?
Almost always an
async call not awaited, or the same DbContext instance used from two threads. A DbContext is not thread-safe; give each request its own scope and await every operation.Where does exception handling belong?
At the very start of the pipeline. Any handler registered later cannot catch an exception thrown by an earlier component, so
UseExceptionHandler must be the outermost.Related
Minimal APIs Configuration and logging
Last refreshed 2026-09-18.