Base Controllers and Shared .NET Concerns
A boundary-first way to decide whether shared API behavior belongs in a base controller, middleware, filters, authorization, or explicit application services.

The controller hierarchy in one API began innocently. The first base class exposed the current user ID and converted a domain error into an HTTP response. Then it acquired a logger, a database context, a mediator, tenant lookup, pagination helpers, and a method that reached into HttpContext.RequestServices for anything still missing.
The inheritance removed lines from each controller. It also made the controllers lie about what they needed.
That is the boundary I use for this decision. A base controller is not wrong because inheritance is unfashionable. It is wrong when it turns unrelated request policies and application dependencies into one implicit contract. The useful question is not, “Can these ten repeated lines move upward?” It is, “Which layer owns this behavior, and what must be true before an action runs?”
Start with the request pipeline, not the class hierarchy
Suppose every endpoint needs five things:
- a correlation ID in its logs;
- an authenticated subject and tenant;
- validation of the request body;
- conversion of known application failures into
ProblemDetails; - an order service for the actual use case.
Those concerns do not share one lifecycle.
| Concern | Scope | Natural owner |
|---|---|---|
| Correlation and request logging | Every applicable HTTP request | Middleware |
| Authentication | Establishes HttpContext.User |
Authentication handler |
| Tenant access | Endpoint or resource-specific policy | Authorization policy/handler |
| Request validation | One endpoint contract | Model binding, validation, or endpoint filter |
| Exception-to-HTTP mapping | Failures escaping the endpoint | Exception handler |
| Creating an order | One application use case | Explicit application service |
Putting all six behind protected base-class members does not unify them. It hides the boundaries that ASP.NET Core already gives them.
Middleware also has an explicit order. Exception handling must wrap the components whose failures it maps; authentication must run before authorization; endpoint execution comes after routing. That order is visible in startup code and can be tested as an HTTP pipeline. A controller hierarchy cannot replace it.
The service-locator version is the dangerous one
This helper is short:
protected T Service<T>() where T : notnull =>
HttpContext.RequestServices.GetRequiredService<T>();
It creates four operational problems.
First, constructor signatures stop describing dependencies. A controller with an empty constructor may require six services at runtime. Second, a missing registration fails only when that path executes. Third, tests either construct a real request service provider or mock a framework container instead of supplying the dependency directly. Fourth, any action can start using any registered service without changing the class contract.
Keep use-case dependencies visible:
[ApiController]
[Route("orders")]
public sealed class OrdersController(
PlaceOrderHandler placeOrder,
TimeProvider clock) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<OrderResponse>> Post(
PlaceOrderRequest request,
CancellationToken cancellationToken)
{
var command = request.ToCommand(clock.GetUtcNow());
var result = await placeOrder.Handle(command, cancellationToken);
return CreatedAtAction(
nameof(Get),
new { orderId = result.OrderId },
OrderResponse.From(result));
}
[HttpGet("{orderId:guid}")]
public IActionResult Get(Guid orderId) => throw new NotImplementedException();
}
The constructor now says what this endpoint needs. TimeProvider also makes the time dependency testable instead of burying DateTime.UtcNow in a protected helper.
A narrow base class can still earn its place
There are APIs where every controller shares stable, framework-facing conveniences. A small base type can be reasonable:
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
protected bool TryGetSubjectId(out Guid subjectId)
{
var value = User.FindFirstValue("sub");
return Guid.TryParse(value, out subjectId);
}
}
Even this example deserves scrutiny. If every action begins with TryGetSubjectId, identity may be better represented by a validated request-scoped CurrentActor, established after authentication. If a missing or malformed subject claim means the token is invalid, the authentication boundary should reject it consistently. An action should not discover halfway through a write that its identity shape is unusable.
I keep a base controller only when all of these remain true:
- it contains no business workflow;
- it resolves no arbitrary services;
- its members are meaningful to nearly every derived controller;
- callers can understand failures from the member’s signature;
- it is shallow—one base class, not a hierarchy;
- deleting it would mostly add explicit HTTP plumbing, not change domain behavior.
That last test catches the common drift. If ApiControllerBase.SaveEntity() opens a transaction, publishes an event, or chooses retry behavior, the type is no longer an HTTP convenience. It is an application service with hidden inputs.
Put exception mapping at the exception boundary
Repeated try/catch blocks in controllers are a real smell. A base-class method is still not the best owner because exceptions can escape from Minimal API handlers, model binding, filters, and other pipeline components too.
ASP.NET Core’s IExceptionHandler provides one application-level mapping point:
public sealed class DomainExceptionHandler(
IProblemDetailsService problemDetails,
ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not DomainException domain)
return false;
logger.LogWarning(
"Domain request rejected with code {Code}",
domain.Code);
context.Response.StatusCode = domain.Code switch
{
"order.not_found" => StatusCodes.Status404NotFound,
"order.conflict" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity
};
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = new ProblemDetails
{
Status = context.Response.StatusCode,
Title = "The request could not be completed.",
Extensions = { ["code"] = domain.Code }
}
});
}
}
Register the handler and enable exception handling near the start of the pipeline:
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
Keep the mapping deliberately small. A database exception is not automatically a 409 Conflict, and an unknown exception should not expose internal messages. Known application outcomes should preferably be typed results; the exception handler is the last translation boundary for failures that genuinely travel as exceptions.
There is also a version detail worth making explicit: starting with .NET 10, diagnostics for exceptions handled by IExceptionHandler are suppressed by default. That may be correct when the handler records the expected outcome, but it can surprise an upgrade if dashboards depended on the previous automatic telemetry. Configure diagnostics intentionally rather than discovering the change during an incident.
Authorization is not a CurrentTenantId property
Reading a tenant claim and using it in a query proves only that the token carried a value. It does not prove the caller may access the requested tenant or resource.
Use authorization policies for that decision. A policy can require a valid tenant claim, and a resource-based handler can compare the actor with the resource being loaded. The controller remains responsible for orchestrating the endpoint, not inventing security rules through protected helpers.
This distinction matters for routes such as:
GET /tenants/{tenantId}/invoices/{invoiceId}
There are two independent checks:
- does the authenticated actor belong to
tenantId? - does
invoiceIdbelong to the same tenant?
A base property cannot enforce both. The query must remain tenant-scoped, and authorization must be tested against mismatched route, claim, and resource combinations.
Validation belongs beside the contract it validates
Base controllers often grow helpers such as ValidateAndReturnBadRequest. That makes validation look uniform while distributing the actual rules across action code.
Transport validation—required fields, parsable values, body size—belongs at the HTTP contract. Business validation—whether an order can be cancelled after dispatch—belongs in the use case or domain model. Keeping those separate gives failures stable meanings and avoids running business decisions only from one delivery mechanism.
For controller APIs, [ApiController] already provides automatic model-state responses for binding and validation failures. If the default response shape is not the public contract, configure that behavior centrally. For endpoint-specific preconditions in Minimal APIs, an endpoint filter may be the right scope. Neither requires every controller to inherit the same implementation.
Refactor by responsibility, not by member count
When a large base controller already exists, replacing it in one move creates unnecessary risk. I use a behavior-preserving sequence:
- List every protected member and every derived controller that calls it.
- Classify each member as HTTP policy, identity, authorization, application workflow, or convenience.
- Add characterization tests around current status codes, response bodies, headers, and side effects.
- Move pipeline-wide behavior to middleware or an exception handler.
- Move resource decisions to authorization policies and handlers.
- Move workflows to explicit application services with constructor dependencies.
- Leave only genuinely universal HTTP conveniences, then decide whether the remaining base type still pays for itself.
Do not combine this with a response-contract redesign. First preserve observable behavior; then change the contract intentionally.
Test the boundary that production uses
Unit tests of a protected helper are not enough. Shared API behavior fails where components meet.
The integration suite should exercise at least:
- a request without authentication;
- a token with a missing or malformed subject or tenant claim;
- an actor requesting another tenant’s resource;
- invalid JSON and contract validation failures;
- each known domain error and its exact
ProblemDetailscode; - an unknown exception with no internal detail in the response;
- cancellation after the request starts;
- correlation fields in logs or traces;
- an upgrade test for handled-exception telemetry.
Also inspect the dependency graph in reviews. A controller suddenly gaining IServiceProvider, IHttpContextAccessor, or a database context is an architectural change even if the diff removes more lines than it adds.
The decision
I do not measure a base controller by how much repetition it removes. I measure it by how much truth it preserves.
A useful base class says, “Every HTTP controller in this API has this small, stable framework capability.” A harmful one says, “Any controller may reach any dependency and inherit any behavior without declaring it.” The first can remain boring for years. The second becomes an application architecture that no constructor, test, or runtime error can explain cleanly.
The goal was never to make controllers shorter. It was to make the request boundary explicit.


