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.RequestServices to 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

LifetimeCreatedUse it for
SingletonOnce per applicationStateless caches, configuration wrappers, expensive thread-safe clients
ScopedOnce per HTTP requestDbContext, per-request repositories, anything holding request state
TransientEvery time it is requestedLightweight 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.
  • AddSingleton with a captured HttpClient is the classic socket-exhaustion bug; AddHttpClient exists 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.

Minimal APIs Configuration and logging

Last refreshed 2026-09-18.