📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is Separation of Concerns, and how does it show up in an ASP.NET Core application?

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.

Mid-Level Architecture Intermediate architecture aspnet-core design-principles separation-of-concerns

🎯 Key points

  • Each part of the system addresses one concern so it can be understood and changed alone
  • Applies at every scale: method, class, project, service
  • In ASP.NET Core: HTTP, business rules, persistence and cross-cutting concerns separate
  • Cross-cutting concerns belong in middleware, filters, decorators or pipeline behaviours
  • Layers separate by technical concern; vertical slices separate by feature
  • Over-separation scatters one change across many places

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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, IExceptionHandler with 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."


1. Concerns in a web API and where they belong

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

2. Two axes of separation

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

3. Why it pays off

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

4. Balancing it

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

Production example

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.

🔍 What the interviewer is checking

Checks practical structuring of web applications.

Green flags

  • Names concrete concerns and where each belongs in ASP.NET Core.
  • Uses middleware, filters or IExceptionHandler for cross-cutting concerns.
  • Compares layers with vertical slices.
  • Recognises over-separation.

Red flags

  • Fat controllers with SQL and business rules.
  • Separation only described as "three layers" without reasons.

📚 Learn in depth

1. Origin

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.

2. Scales of separation

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

3. The ASP.NET Core pipeline as a SoC tool

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).

4. Cross-cutting concerns for services (beyond HTTP)

// 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).

5. Presentation vs logic

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.

6. Signs you've gone too far

  • Adding a field touches 6+ files with no added behaviour.
  • Layers that only map one object to an identical one.
  • Developers can't find where a behaviour lives.
  • Every feature needs a command, handler, validator, mapper, DTO and response class even when trivial.

Remedies: vertical slices, merging pass-through layers, mapping only at real boundaries (API contract ↔ domain).

7. Try it yourself

  1. Pick a controller action and list every concern inside it.
  2. Move error handling into IExceptionHandler and validation into an endpoint filter (as in the code example).
  3. Add a fallback authorisation policy and confirm endpoints are protected by default.
  4. Count lines left in the action and in its tests.
  5. Reorganise one feature into a vertical slice and compare a typical change's diff size.

💻 Code

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 });
    }
}

⚠ Common mistakes

1. Business rules in controllers or Razor pages

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.

2. Repeating cross-cutting code in every action

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.

3. Over-separation

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.

4. Domain referencing framework types

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.

5. One service handling unrelated features

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.

6. Wrong middleware order

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.

7. No default authorisation

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.

8. Mixing persistence models with API contracts

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.

9. Logging inside business rules everywhere

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.

10. Choosing the separation axis by habit

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate splits classes by concern Why: Connects SoC to SRP
    How to answer

    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.

  2. When: Candidate mentions feature folders Why: Probes cohesion
    How to answer

    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.

  3. When: Candidate keeps the domain free of framework references Why: Probes coupling between layers
    How to answer

    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.

🛠 Real-world problems: how you handle them

Missed permission check in a fat controller
Situation
A [claims API] had permission checks, logging, error handling and SQL inside each of [70] actions.
Signals
A security review found one action returning other customers' claims after a permissions change.
Investigation
The new rule had been added manually to each action and one was missed.
Resolution
Moved authorisation into policies, errors into `IExceptionHandler`, SQL into query classes.
Prevention
Architecture tests ensure controllers do not reference the data layer; endpoints require an authorisation policy by default.

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.

↩ Usually asked after

More in Additional Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me