Separation of Concerns at method, class, project and service level; layers versus vertical slices; cross-cutting concerns via middleware and filters; the cost of over-separation.
Separation of Concerns (Dijkstra's term) means each part of a system addresses one concern, so you can reason about and change it without understanding everything else. In an ASP.NET Core app the typical concerns are HTTP handling (routing, model binding, status codes), business rules, persistence, and cross-cutting concerns such as logging, authentication, validation and error handling. Cross-cutting concerns go into middleware, endpoint filters or decorators rather than every handler. Separation can be by technical layer or by feature (vertical slices), and over-separating is its own problem because one change then spans many files.
"Separation of Concerns, Dijkstra's term, means each part of a system addresses one concern, so you can understand, test and change it without holding everything else in your head. It's the root idea behind SRP, layering and dependency inversion. In an ASP.NET Core app the typical concerns are HTTP (routing, binding, status codes), business rules, persistence, and cross-cutting concerns like authentication and authorisation, validation, error handling, logging and tracing. The ASP.NET Core pipeline is designed to keep those apart: middleware for every request, endpoint/MVC filters for specific endpoints, authorisation policies,
IExceptionHandlerwith ProblemDetails for errors, and decorators or pipeline behaviours for services. You can separate by technical layer (Web, Application, Domain, Infrastructure) or by feature (vertical slices), and I choose the axis that matches how the system changes. Over-separation is a real failure mode too: if adding one field means editing a DTO, command, handler, validator, mapper, entity and repository, the separation no longer pays for itself."
| Concern | ASP.NET Core home |
|---|---|
| Routing, model binding, status codes | Minimal API endpoint or controller (thin) |
| Business rules | Domain entities / application services (no HTTP types) |
| Persistence | EF Core DbContext, entity configurations, query classes |
| Authentication | Authentication middleware (JWT, cookies, OIDC) |
| Authorisation | Policies, RequireAuthorization, fallback policy |
| Validation | Endpoint filters, FluentValidation, data annotations, .NET 10 minimal API validation |
| Error handling | UseExceptionHandler + IExceptionHandler (.NET 8+) + ProblemDetails |
| Logging / tracing / metrics | ILogger<T>, OpenTelemetry instrumentation, middleware |
| Rate limiting, CORS, compression | Middleware |
| Caching | Output caching middleware / HybridCache in services |
| Horizontal layers | Vertical slices | |
|---|---|---|
| Organised by | Technical role (Web/Application/Domain/Infrastructure) | Feature (Orders/PlaceOrder, Orders/GetOrder) |
| A feature change touches | Every layer | One folder |
| Strength | Clear technical boundaries; infrastructure swaps | Local change, low ceremony |
| Risk | Ceremony, pass-through layers | Duplication; shared rules need a domain home |
| Typical blend | Vertical slices for use cases + a domain core + infrastructure adapters |
| Benefit | Example |
|---|---|
| Maintainability | A permissions rule changes in one policy, not 70 actions |
| Testability | Business rules tested without HTTP or a database |
| Replaceability | Swap a payment vendor adapter without touching rules |
| Parallel work | Teams change UI and rules without conflicts |
| Security | Cross-cutting checks can't be forgotten per endpoint |
| Under-separated | Right | Over-separated |
|---|---|---|
| Controllers with SQL, rules, try/catch, logging | Thin endpoints; rules in services; cross-cutting in pipeline | 8 files per field; pass-through layers |
One AppService for every feature |
Services per capability | One class per method |
Domain referencing HttpContext / EF types |
Domain free of framework types | Domain duplicated into DTO/command/model copies with mappers everywhere |
In our [claims API], each of [70] controller actions contained its own permission checks, try/catch blocks, logging and inline SQL. A permissions change had to be pasted into every action; one was missed, and a security review found an endpoint returning other customers' claims. I moved authorisation into named policies plus a fallback policy requiring an authenticated user by default, centralised errors in an IExceptionHandler producing ProblemDetails, and moved SQL into query classes. Controllers became a few lines each, focused on HTTP. The next permissions change was a single policy edit, and an architecture test now prevents controllers from referencing the data layer directly.
Checks practical structuring of web applications.
Green flags
IExceptionHandler for cross-cutting concerns.Red flags
Edsger Dijkstra (1974, On the role of scientific thought) described focusing on one aspect of a problem at a time, "the separation of concerns", as the only effective technique for ordering thoughts. David Parnas' information hiding (1972) is the modular-design counterpart: hide each design decision likely to change behind a module boundary.
| Scale | Example |
|---|---|
| Expression/method | Parse → calculate → format as separate steps |
| Class | PriceCalculator separate from PriceRepository |
| Project | Domain project without ASP.NET Core or EF Core references |
| Module | Billing module with its own data and public API inside a modular monolith |
| Service | Billing separate from Catalogue, owned by different teams |
Request → [Exception handler] → [HTTPS/HSTS] → [Routing] → [CORS] → [Authentication] → [Authorization]
→ [Rate limiting] → [Output cache] → Endpoint filters → Endpoint (HTTP concern only) → Service (rules) → DbContext (persistence)
Each concern sits at one place in the pipeline. Order matters (authentication before authorisation; exception handling first so it wraps everything).
// Decorator for timing/logging around any IQuoteService
public sealed class LoggingQuoteService(IQuoteService inner, ILogger<LoggingQuoteService> log) : IQuoteService
{
public decimal ShippingQuote(string postcode)
{
var sw = Stopwatch.StartNew();
try { return inner.ShippingQuote(postcode); }
finally { log.LogInformation("Quote for {Postcode} took {Ms} ms", postcode, sw.ElapsedMilliseconds); }
}
}
Other tools: MediatR-style pipeline behaviours, EF Core interceptors (auditing, soft delete), HttpClient delegating handlers (auth headers, resilience).
Razor views and Blazor components should render and handle UI events; calculations and rules belong in injectable services that can be unit-tested without a UI. Blazor's @code blocks are a common place for logic to creep in.
Remedies: vertical slices, merging pass-through layers, mapping only at real boundaries (API contract ↔ domain).
IExceptionHandler and validation into an endpoint filter (as in the code example).using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>(); // error concern
builder.Services.AddSingleton<QuoteService>(); // business concern
var app = builder.Build();
app.UseExceptionHandler();
// HTTP concern only: bind, call, map to a result.
app.MapGet("/quotes/{postcode}", (string postcode, QuoteService quotes) =>
Results.Ok(quotes.ShippingQuote(postcode)))
.AddEndpointFilter(async (ctx, next) => // validation concern
{
var postcode = ctx.GetArgument<string>(0);
return postcode.Length == 4 && postcode.All(char.IsDigit)
? await next(ctx)
: Results.ValidationProblem(new Dictionary<string, string[]> { ["postcode"] = ["Must be 4 digits"] });
});
app.Run();
public sealed class QuoteService
{
public decimal ShippingQuote(string postcode) =>
postcode.StartsWith('6') ? 19.95m : postcode == "0000" ? throw new DomainException("No delivery") : 9.95m;
}
public sealed class DomainException(string message) : Exception(message);
public sealed class DomainExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
{
if (ex is not DomainException) return false;
http.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
return await problems.TryWriteAsync(new() { HttpContext = http, Exception = ex });
}
}
What happens: pricing logic inside actions or @code blocks.
Why it is wrong: untestable without HTTP/UI; duplicated across endpoints.
Do instead: services or entities the endpoint calls.
What happens: try/catch, logging and permission checks copy-pasted.
Why it is wrong: one missed copy becomes a bug or a security hole.
Do instead: middleware, filters, policies, IExceptionHandler.
What happens: a field change touches ten files. Why it is wrong: navigation cost outweighs isolation. Do instead: vertical slices; merge layers that add nothing.
What happens: entities take HttpContext, or rules use IQueryable from EF directly.
Why it is wrong: delivery and persistence concerns leak into business logic.
Do instead: keep the domain free of ASP.NET Core and EF Core dependencies.
What happens: AppService with orders, users and reports.
Why it is wrong: separation exists only between layers, not between concerns.
Do instead: services per capability or feature.
What happens: authorisation before authentication, or exception handling registered late. Why it is wrong: concerns don't apply as intended. Do instead: follow the documented pipeline order.
What happens: each endpoint must remember [Authorize].
Why it is wrong: a forgotten attribute exposes data.
Do instead: a fallback policy; explicitly mark anonymous endpoints.
What happens: EF entities returned directly from endpoints. Why it is wrong: schema changes break clients; over-posting risks. Do instead: map at the API boundary for public contracts.
What happens: rules interleaved with log statements. Why it is wrong: rules become harder to read; logging policy changes touch business code. Do instead: log at boundaries, use decorators or structured events.
What happens: always three layers regardless of change patterns. Why it is wrong: separation that doesn't follow change patterns adds cost without benefit. Do instead: separate along how the system actually changes.
I treat Separation of Concerns as the reason behind most other principles (SRP, layering, DIP). The senior move is choosing the separation axis that matches how the system changes: by feature for product work, by technical layer where infrastructure varies.
| Change pattern | Separation that helps |
|---|---|
| Features change independently | Vertical slices |
| Infrastructure swaps (vendors, databases) | Ports and adapters |
| Cross-cutting policy changes | Middleware, filters, decorators |
| UI redesigns | Thin views over services |
SRP says a module should have one reason to change, which Robert C. Martin later phrased as "responsible to one actor": one group of people whose requests drive its changes. It is not "a class does one thing" or "one method per class". In .NET I look for classes that change for unrelated reasons, such as an OrderService that holds pricing rules (finance), email templates (marketing) and SQL (DBA), and split along those change drivers. I stop splitting when the pieces would always change together, because that is just scattering one responsibility across files.
Cohesion is how strongly the parts of a module belong together. A highly cohesive class has methods that work on the same data towards one purpose, so a change to that concept happens in one place. Low cohesion shows up as Utils or Manager classes with unrelated methods, fields that only one or two methods use, and classes that change for many different reasons. I improve it by grouping code around domain concepts and features, moving methods to the data they use, and splitting classes whose fields form separate clusters.
Coupling is the degree to which one module depends on another: how much it must know about it, and how likely it is to change when the other changes. Some coupling is unavoidable; the goal is coupling that is explicit, narrow and stable. In code the harmful kinds are reaching into another type's internals, shared mutable global state and passing control flags. In distributed .NET systems the expensive kinds are temporal coupling (synchronous call chains), shared databases and lock-step deployments. I reduce them with narrow interfaces, published contracts, asynchronous messaging where it fits, and each service owning its data.
How to tell it (STAR)
A permissions change had to be pasted into [70] controller actions and one was missed, which exposed data. I moved authorisation into policies and errors into a central handler so controllers only dealt with HTTP. The next permissions change was one policy edit, and a fallback policy now protects every endpoint by default.
Last updated 31 Oct 2026.