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

📚 Interview question

What does the Single Responsibility Principle really mean, and how do you apply it in a .NET codebase without creating hundreds of tiny classes?

SRP as one reason to change (one actor), spotting violations, splitting by change drivers, and avoiding over-fragmentation.

Mid-Level Architecture Intermediate cohesion design-principles solid srp

🎯 Key points

  • SRP means one reason to change, or responsible to one actor
  • It is not one method or one operation per class
  • Symptoms: unrelated changes, team conflicts, heavy test setup, vague class names
  • Split along change drivers and keep a thin orchestrator
  • Move cross-cutting concerns to decorators, middleware or pipelines
  • Do not split code that always changes together

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"SRP is usually misquoted as 'a class should do one thing'. Robert C. Martin's own wording is that a module should have one reason to change, later sharpened to 'be responsible to one actor': one group of people (or one external force) whose requests cause it to change. So I look for classes that change for unrelated reasons. An OrderService that holds pricing rules (finance asks for those), email wording (marketing), SQL (the platform team) and audit logging (compliance) has four actors, and a change requested by one can break another. I split along those change drivers, keep a thin orchestrator (an application service or handler) that coordinates the pieces, and move cross-cutting concerns into decorators, middleware or pipeline behaviours. Equally important is knowing when to stop: if pieces always change together, splitting them only scatters one responsibility across files. That's how you avoid hundreds of tiny classes."


1. What a "responsibility" is

Wrong reading Better reading
One method per class One reason to change
One operation One actor (business area, regulation, external system, infrastructure concern)
Small classes are SRP-compliant Size is a hint, not the rule
Applies only to classes Applies to methods, classes, modules and services

2. Symptoms of an SRP violation

Symptom What it tells you
Different teams edit the same class for unrelated features Several actors share one module
A formatting change breaks a calculation Unrelated concerns are entangled
Unit tests for one rule need DB, email and clock set-up Mixed concerns force heavy fixtures
Vague names: Manager, Helper, Processor, Utils No clear responsibility, so everything lands there
Frequent merge conflicts in one file High churn from several directions
Long constructors (8+ dependencies) The class coordinates too many concerns

3. Applying it, step by step

  1. Ask who changes it: git log on the file; label each change by who asked for it.
  2. Group code by actor: tax rules, email content, persistence, auditing.
  3. Extract each group behind a name that states the responsibility: TaxCalculator, InvoiceEmail, InvoiceRepository.
  4. Keep a thin orchestrator: IssueInvoiceHandler calls the pieces in order; it has no business rules of its own.
  5. Move cross-cutting concerns out: logging, validation, caching, retries, transactions → decorators, middleware, endpoint filters, pipeline behaviours.
  6. Test each piece in isolation with focused tests.

4. Avoiding over-fragmentation

Situation Do
Code always changes together Keep it together (high cohesion)
Logic used once, a few lines Private method, not a new class
A 15-line minimal API endpoint doing validate → map → save Fine until its reasons to change diverge
A large but stable class nobody touches Leave it; SRP pays off where change happens
A new class needs a long explanation to justify It's probably indirection, not a responsibility

Production example

Our [3,000-line] InvoiceService held tax rules, PDF layout and email delivery, and was edited by both the [finance and marketing] teams. Merge conflicts were constant, and one tax change broke PDF rendering in production. I ran git log on the file and labelled [the last 60] changes by who requested them: three clear streams. I extracted TaxCalculator (finance), InvoicePdfRenderer and InvoiceMailer (marketing/brand), and kept an IssueInvoiceHandler that only coordinated them, adding focused unit tests for each. Tax changes became small, reviewable diffs that couldn't touch the PDF code; conflicts stopped, and we added CODEOWNERS per component so each team reviewed its own area.

🔍 What the interviewer is checking

Checks whether the candidate understands SRP beyond the slogan.

Green flags

  • Uses "one reason to change" or "one actor".
  • Gives concrete symptoms and a refactoring approach.
  • Warns against over-fragmentation.
  • Mentions cross-cutting concerns.

Red flags

  • "Each class should have one method."
  • Splits everything regardless of change patterns.

📚 Learn in depth

1. Where the definition came from

Wording Source / idea
"A class should have only one reason to change" Robert C. Martin, Agile Software Development (early 2000s)
"A module should be responsible to one, and only one, actor" Martin's later refinement (Clean Architecture)
Root idea Cohesion and Parnas' information hiding: group what changes together, separate what changes independently

The "actor" framing is the useful one: it tells you how to find the boundaries (follow the people and forces that drive change).

2. Responsibilities and their natural homes

Concern found in one class Actor Extract to
Discount and tax rules Finance Domain service or entity methods
Email/PDF wording and layout Marketing / brand Template + renderer/mailer
SQL, mapping, indexes Platform / DBA Repository or EF Core configuration
Audit trail Compliance Decorator, pipeline behaviour, EF SaveChanges interceptor
Retries, timeouts Operations Resilience pipeline
Input validation API contract owners Validators / endpoint filters

3. SRP at every level

Level SRP looks like
Method Does one job at one level of abstraction
Class Serves one actor
Project / module One capability or bounded context (e.g. Billing)
Service One business capability owned by one team

Violations at higher levels are costlier: a "shared" service owned by nobody that every team changes is the service-level equivalent of OrderManager.

4. Cross-cutting concerns without clutter

// Decorator: auditing wraps the handler without changing it
public sealed class AuditingIssueInvoiceHandler(IIssueInvoiceHandler inner, IAuditLog audit) : IIssueInvoiceHandler
{
    public async Task HandleAsync(Order order, CancellationToken ct)
    {
        await inner.HandleAsync(order, ct);
        await audit.RecordAsync("InvoiceIssued", order.Id, ct);
    }
}

Other .NET homes: ASP.NET Core middleware, endpoint filters, MVC filters, MediatR pipeline behaviours, EF Core interceptors, HttpClient delegating handlers.

5. Measuring (as hints)

Signal Tool
Change coupling (files that change together) Git history analysis (e.g. CodeScene, git log scripts)
Churn × size hotspots Same
Many collaborators Constructor parameter count; dependency graphs
Low cohesion Methods that share no fields (LCOM-style metrics in NDepend)

Use them to choose where to look, then apply judgement.

6. Try it yourself

  1. Pick a large service class and run git log --format="%ad %s" --date=short -- path/to/File.cs.
  2. Label each change with the actor who asked for it; count distinct actors.
  3. Sketch the split by actor, plus the orchestrator.
  4. Identify which methods would move together; check they share fields (cohesion).
  5. Move one cross-cutting concern (e.g. logging) into a decorator or pipeline behaviour.
  6. Find a place where code is over-split (files that always change together) and consider merging it.

💻 Code

using Microsoft.EntityFrameworkCore;

// Before: one class, three actors (finance, marketing, persistence).
public sealed class InvoiceServiceBefore(AppDb db, IEmailSender email)
{
    public async Task IssueAsync(Order order, CancellationToken ct)
    {
        decimal tax = order.Country == "AU" ? order.Subtotal * 0.10m : 0m;          // finance rule
        string body = $"Thanks {order.CustomerName}. Your total is {order.Subtotal + tax:C}."; // marketing wording
        db.Invoices.Add(new Invoice { OrderId = order.Id, Total = order.Subtotal + tax });     // persistence
        await db.SaveChangesAsync(ct);
        await email.SendAsync(order.Email, "Your invoice", body, ct);
    }
}

// After: each class has one reason to change; the handler only coordinates.
public sealed class TaxCalculator
{
    public decimal TaxFor(Order order) => order.Country == "AU" ? order.Subtotal * 0.10m : 0m;
}

public sealed class InvoiceEmail(IEmailSender email)
{
    public Task SendAsync(Order order, decimal total, CancellationToken ct) =>
        email.SendAsync(order.Email, "Your invoice",
            $"Thanks {order.CustomerName}. Your total is {total:C}.", ct);
}

public sealed class IssueInvoiceHandler(AppDb db, TaxCalculator tax, InvoiceEmail invoiceEmail)
{
    public async Task HandleAsync(Order order, CancellationToken ct)
    {
        decimal total = order.Subtotal + tax.TaxFor(order);
        db.Invoices.Add(new Invoice { OrderId = order.Id, Total = total });
        await db.SaveChangesAsync(ct);
        await invoiceEmail.SendAsync(order, total, ct);
    }
}

public sealed record Order(int Id, string CustomerName, string Email, string Country, decimal Subtotal);

public sealed class Invoice
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public decimal Total { get; set; }
}

public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body, CancellationToken ct);
}

public sealed class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();
}

⚠ Common mistakes

1. Reading SRP as "a class does one thing"

What happens: every operation becomes its own class. Why it is wrong: logic is scattered across dozens of one-method classes; navigation and reasoning get harder. Do instead: group by reason to change (actor), not by number of operations.

2. Splitting code that always changes together

What happens: OrderValidator, OrderMapper, OrderFactory, OrderBuilder all change for every order feature. Why it is wrong: every change touches many files; cohesion drops. Do instead: keep it together until change drivers diverge.

3. Mixing cross-cutting concerns into business classes

What happens: logging, caching, retries and auditing are interleaved with rules. Why it is wrong: rules are buried; concerns change for different reasons. Do instead: decorators, middleware, filters, pipeline behaviours, interceptors.

4. Vague names that attract responsibilities

What happens: OrderManager, CommonHelper, Utils. Why it is wrong: anything can go there, so everything does. Do instead: name classes by the responsibility they hold.

5. Splitting by technical layer only

What happens: Controllers, Services, Repositories folders, with one giant service per entity. Why it is wrong: the service still mixes all business actors. Do instead: split by capability/feature and actor, not just by layer.

6. Refactoring stable code for purity

What happens: a large class nobody changes is split "because SRP". Why it is wrong: risk and effort with no benefit. Do instead: focus SRP refactoring on hotspots (high churn, many authors).

7. Orchestrators that accumulate rules

What happens: the handler gains if statements about tax and wording over time. Why it is wrong: the god class reappears under a new name. Do instead: keep handlers as coordination; push rules into the components that own them.

8. Ignoring team boundaries

What happens: code owned by one team is edited constantly by another. Why it is wrong: coordination cost and conflicts. Do instead: align module boundaries with ownership (CODEOWNERS).

9. Applying SRP only to classes

What happens: a shared library or service serves many teams with conflicting needs. Why it is wrong: the same problem at a larger, costlier scale. Do instead: apply the actor test to modules and services too.

10. Using metrics as rules

What happens: "no class over 200 lines" enforced mechanically. Why it is wrong: produces arbitrary splits. Do instead: use metrics to find candidates; decide with the actor test.

🏛 Senior insight: trade-offs

SRP is about people and change, not size. I use git history and team boundaries to find responsibilities, and I accept a slightly larger class when splitting would only add indirection.

Signal Interpretation
Different teams edit the same class Mixed actors, split
Unrelated tests need the same heavy setup Mixed concerns
Files always change together Over-split, merge
Large but stable class Possibly fine, leave it

🔗 Follow-ups the interviewer may ask

  1. When: Candidate splits a class by reason to change Why: Moves on to extending behaviour without editing
    How to answer

    The Open/Closed Principle says modules should be open for extension but closed for modification: you add new behaviour by adding code, not by editing tested code. In C# this usually means a strategy interface with one implementation per variant, registered in DI, so adding a new payment method is a new class rather than another case in a growing switch. I apply it where change actually happens repeatedly. A small, stable switch (or a switch expression over a closed set) is fine; building extension points for variants that never arrive is speculative generality.

  2. When: Candidate injects the split collaborators Why: Checks dependency management
    How to answer

    A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.

  3. When: Candidate separates persistence from logic Why: Probes DTO versus domain model boundaries
    How to answer

    A domain model represents business concepts with behaviour and invariants (an Order that can be paid, cancelled, and refuses invalid changes). A DTO (data transfer object) is a flat, behaviour-free contract for moving data across a boundary: an HTTP request or response, a message, a query result. Keep them separate so that the API contract and the domain can change independently, so clients cannot over-post fields they must not set (IsAdmin, Status), and so internal data does not leak in responses. Map with explicit code, source-generated mappers (Mapperly) or EF Core projections straight to DTOs for reads. Sharing one type is acceptable only for simple CRUD with no rules, and even then be careful at public boundaries.

🛠 Real-world problems: how you handle them

Splitting a 3,000-line invoice service
Situation
An [InvoiceService] of [3,000 lines] held tax rules, PDF layout and email delivery, edited by [finance and marketing] teams.
Signals
Frequent merge conflicts; a tax change once broke PDF rendering in production.
Investigation
Git history showed three distinct streams of change, each requested by a different group.
Resolution
Extracted `TaxCalculator`, `InvoicePdfRenderer` and `InvoiceMailer` and kept an `IssueInvoiceHandler` that coordinated them, with focused unit tests for each.
Prevention
Added CODEOWNERS per component and a review guideline on class responsibilities.

How to tell it (STAR)

Our [invoice service] was edited by two teams for unrelated reasons, and a tax change broke PDFs. I used git history to group changes by who asked for them, then split the class along those lines with a thin handler on top. Conflicts stopped and each change became easy to review and test.

↩ Usually asked after

More in SOLID Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me