SRP as one reason to change (one actor), spotting violations, splitting by change drivers, and avoiding over-fragmentation.
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.
"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
OrderServicethat 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."
| 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 |
| 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 |
git log on the file; label each change by who asked for it.TaxCalculator, InvoiceEmail, InvoiceRepository.IssueInvoiceHandler calls the pieces in order; it has no business rules of its own.| 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 |
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.
Checks whether the candidate understands SRP beyond the slogan.
Green flags
Red flags
| 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).
| 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 |
| 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.
// 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.
| 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.
git log --format="%ad %s" --date=short -- path/to/File.cs.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>();
}
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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 |
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.
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.
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.
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.
Last updated 31 Oct 2026.