Skip to content

Low Level DesignSOLID principles

Single responsibility principle

The single responsibility principle (SRP) through three real examples, from a café till to user signup to invoices, with live demos, class diagrams and code in five languages.

Foundation18 min read

The single responsibility principle says a class should have one reason to change. That sentence is famous and mostly misread. A “reason to change” is not a task or a method. It is a person or group who asks for changes. Robert Martin, who named the principle, later restated it to remove the doubt: a module should be responsible to one, and only one, actor.

So the question is never “does this class do one thing?” It is “if I list everyone who could ask me to change this class, how many different groups are on the list?” Say finance, the tax team and the design team all edit the same file. Their changes collide, and one team’s fix breaks another team’s numbers. This page shows that happening in three real systems, from a café till to a billing system. Pick a level below. The rest of the page is shared.

The idea in one picture
Finance Tax team Design Invoice total() tax() render() 3 reasons to change split by who asks Pricing Finance TaxCalculator Tax team InvoiceRenderer Design a change from one team edits one class
Scroll or pinch to zoom, drag to pan
Three groups ask for changes to one class, so their changes collide. Split it so each group's requests land in a class of their own.

You can’t apply SRP by looking at a class alone. You need to know who uses the system and who asks for changes. Three quick tests, in order of how reliable they are:

  1. List the actors. For each method, ask “who would ask me to change this?” Write down a role, not a person: the tax team, the baristas, whoever runs the database. If the list has more than one entry, the class has more than one responsibility.
  2. Describe the class in one sentence without “and”. “Order holds what was bought and what it costs” passes. “Order prices the items and prints the receipt and saves to disk” fails. This is a quick smell, not a proof: some honest descriptions need an “and”.
  3. Look at the history. In a real codebase, git log on the file tells you. If commits come from unrelated tickets, different teams, or keep conflicting with each other, the file is serving more than one actor.
One class, one actor

Facade

+runTheFlow()

PartForActorA

+whatActorAOwns()

PartForActorB

+whatActorBOwns()

SharedData

+fields

Facade

+runTheFlow()

PartForActorA

+whatActorAOwns()

PartForActorB

+whatActorBOwns()

SharedData

+fields

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The usual result has three kinds of parts. Plain data that every actor reads. One class per actor, holding that actor’s rules. And often a small facade that runs the steps in order, so callers don’t have to.

Easy

A café order that prints and saves

  • Spotting reasons to change
  • Splitting by who asks
  • Data plus small helpers

A small café runs its till on a little app. An order holds the drinks and pastries, and works out the total. It prints a receipt, and appends a line to a CSV file, so the owner can do the books. Three people ask for changes to it. The manager sets prices and deals. The baristas want the receipt to show things. And the owner decides where the records live.

The first version puts everything on Order:

Order.javaorder.pyOrder.csorder.goorder.ts
import java.util.ArrayList;
import java.util.List;
// One line on the order: a value, it never changes once added.
record Item(String name, int priceCents, int qty) {}
// Before: one class does the café's pricing, its receipt layout and its storage.
// Three different people ask for changes to those three things, and every one of
// those changes lands in this file.
class Order {
private final List<Item> items = new ArrayList<>();
private final List<String> csvFile; // stands in for orders.csv on disk
Order(List<String> csvFile) { this.csvFile = csvFile; }
void add(Item item) { items.add(item); }
// Reason to change 1: the manager changes how prices work (happy hour, loyalty).
int totalCents() {
int sum = 0;
for (Item i : items) sum += i.priceCents() * i.qty();
return sum;
}
// Reason to change 2: the baristas want the receipt to look different.
// flaw: to test the layout you have to build a whole Order, storage and all.
String receipt() {
StringBuilder sb = new StringBuilder("BEAN THERE CAFE\n");
for (Item i : items) {
sb.append(String.format("%-14s x%d %6.2f%n", i.name(), i.qty(), i.priceCents() * i.qty() / 100.0));
}
return sb.append(String.format("%-17s %6.2f%n", "TOTAL", totalCents() / 100.0)).toString();
}
// Reason to change 3: the owner moves from a CSV file to a database.
// flaw: an edit here ships in the same class as pricing, so pricing gets re-tested too.
void save() {
csvFile.add(items.size() + "," + totalCents());
}
}
class Main {
public static void main(String[] args) {
List<String> csv = new ArrayList<>();
Order order = new Order(csv);
order.add(new Item("Flat white", 450, 2));
order.add(new Item("Croissant", 325, 1));
System.out.print(order.receipt());
order.save();
System.out.println(csv); // [2,1225]
}
}

Nothing here is broken today. The problem shows up the moment requests arrive. A barista asks for the cup size on the receipt. The edit goes into the same class that computes money. So the pricing tests have to run again, and the reviewer has to check that nothing about the total moved. And testing the receipt layout means building a whole order, with storage attached.

Split by actor. Order keeps what the manager owns, the items and the total. The receipt layout goes to ReceiptFormatter, owned by the baristas. Storage goes to OrderStore, owned by whoever runs the shop’s systems.

Easy: one class per person who asks

reads

reads

Order

-items: List<Item>

+add(Item)

+items() : List<Item>

+totalCents() : int

Item

+name

+priceCents

+qty

ReceiptFormatter

+format(Order) : String

OrderStore

+save(Order)

reads

reads

Order

-items: List<Item>

+add(Item)

+items() : List<Item>

+totalCents() : int

Item

+name

+priceCents

+qty

ReceiptFormatter

+format(Order) : String

OrderStore

+save(Order)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Order.javaorder.pyProgram.csorder.goorder.ts
import java.util.ArrayList;
import java.util.List;
// One line on the order: a value, it never changes once added.
record Item(String name, int priceCents, int qty) {}
// Order keeps only what the manager owns: what is on the order and what it costs.
// It knows nothing about paper or files, so pricing rules are tested with two lines of setup.
class Order {
private final List<Item> items = new ArrayList<>();
void add(Item item) { items.add(item); }
// Read-only view: the formatter and the store can look, but only Order changes the items.
List<Item> items() { return List.copyOf(items); }
int totalCents() {
int sum = 0;
for (Item i : items) sum += i.priceCents() * i.qty();
return sum;
}
}
// Owned by the baristas. Changing the layout edits this class and nothing else,
// and it is tested by comparing strings: no storage, no setup.
class ReceiptFormatter {
String format(Order order) {
StringBuilder sb = new StringBuilder("BEAN THERE CAFE\n");
for (Item i : order.items()) {
sb.append(String.format("%-14s x%d %6.2f%n", i.name(), i.qty(), i.priceCents() * i.qty() / 100.0));
}
// The total comes from Order, never recomputed here: one place owns the arithmetic.
return sb.append(String.format("%-17s %6.2f%n", "TOTAL", order.totalCents() / 100.0)).toString();
}
}
// Owned by whoever runs the shop's systems. Moving to a database replaces this class;
// Order and the receipt never notice.
class OrderStore {
private final List<String> csvFile; // stands in for orders.csv on disk
OrderStore(List<String> csvFile) { this.csvFile = csvFile; }
void save(Order order) {
csvFile.add(order.items().size() + "," + order.totalCents());
}
}
class Main {
public static void main(String[] args) {
Order order = new Order();
order.add(new Item("Flat white", 450, 2));
order.add(new Item("Croissant", 325, 1));
// The till wires the three together. That wiring is the only place that knows all three.
System.out.print(new ReceiptFormatter().format(order));
List<String> csv = new ArrayList<>();
new OrderStore(csv).save(order);
System.out.println(csv); // [2,1225]
}
}

Now send each request through both designs. Count the classes edited, and then the people who have to re-check the code that changed:

A change request arrives
One class
  • Orderprices, prints and saves

Split by who asks
  • Orderthe items and the total
  • ReceiptFormatterthe receipt layout
  • OrderStorewhere orders are kept

Pick a change request to see which classes it touches in each design.

  • The split follows people, not verbs. Order still has three methods. It has one responsibility because all three belong to the manager.
  • Data stays with the class that owns its rules. Order keeps the items and the total. The other two only read them. The formatter never recomputes the total. If it did, the receipt and the books could disagree.
  • Small helpers are fine. ReceiptFormatter is a few lines. In Go, it is just a function. A class that does one small thing well is not over-engineering. Ten classes of ten lines each, doing the same job, would be.
  • Something still wires them together. The till’s main code builds the order, prints and saves. That code knows all three classes, and that is its one job.
Interviewer

Here is an Order class with total(), printReceipt() and save(). Does it break SRP?

What they are testing

Whether you answer with actors, not a rule: “It depends who changes those. If the manager, the baristas and IT all ask for changes, yes. If this is a one-person script, the split buys little.” Then whether you can split it in under a minute. The red flag is “yes, because it has three methods”.

Say this out loud

Pricing, the receipt layout and storage change for different people, so I’ll keep pricing on Order and move the other two out. A layout change then can’t touch the money.

Medium

User signup

  • A flow is a responsibility
  • Collaborators behind interfaces
  • Who decides what a failure means

Every product has a signup. A user gives an email and a password. The system checks them, stores a new account, and sends a welcome email. Four groups care about that one method. Product decides what the flow is, and what counts as a valid email. Security owns password rules and hashing. The platform team owns the database. And marketing writes the welcome email.

The first version is one method, top to bottom:

SignupService.javasignup_service.pySignupService.cssignup_service.gosignup-service.ts
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// Before: one method runs the whole signup, and four teams own parts of it.
class SignupService {
private final Map<String, String> usersTable = new HashMap<>(); // stands in for the users table
private final List<String> sentMail = new ArrayList<>(); // stands in for the mail server
boolean mailServerDown = false;
String signUp(String email, String password) {
// Product owns what a valid email is.
email = email.trim().toLowerCase();
if (!email.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) throw new IllegalArgumentException("bad email");
// Security owns password rules and hashing.
if (password.length() < 12) throw new IllegalArgumentException("password too short");
String hash = "demo-hash:" + new StringBuilder(password).reverse(); // NOT a real hash
// The platform team owns storage. Moving to Postgres rewrites lines in the middle of
// a method that also holds the password rules.
if (usersTable.containsKey(email)) throw new IllegalStateException("email taken");
usersTable.put(email, hash);
// Marketing owns the welcome email.
// flaw: if the mail server is down this throws, and the caller sees a failed signup
// for an account that was already saved. Retrying then says "email taken".
if (mailServerDown) throw new RuntimeException("SMTP timeout");
sentMail.add("To " + email + ": Welcome to Acme! Here's how to get started...");
return email;
}
}
class Main {
public static void main(String[] args) {
SignupService service = new SignupService();
System.out.println(service.signUp(" Ada@Example.com ", "correct horse battery"));
service.mailServerDown = true;
try {
service.signUp("grace@example.com", "a long enough password");
} catch (RuntimeException e) {
System.out.println("signup failed: " + e.getMessage()); // ...but the account exists
}
}
}

Four groups now edit one method. A marketing copy change goes through the same review as a hashing change. Moving to Postgres means rewriting lines that sit between the password rules and the email code. And there is a real bug hiding in the order of the lines. Say the mail server times out. The account is already saved, but the caller is told signup failed. When the user tries again, they get “email taken”, for an account they were told doesn’t exist.

Give each group its own class. And keep one class whose only job is the flow: the order of the steps, and what happens when one fails.

Medium: a facade that owns the flow, and one collaborator per team

parses

creates

SignupService

+signUp(email, password) : User

EmailAddress

+value

+parse(raw) : EmailAddress

User

+email: EmailAddress

+passwordHash

PasswordPolicy

+hashIfAcceptable(password) : String

«interface»

UserRepository

+find(EmailAddress) : User

+save(User)

«interface»

WelcomeMailer

+sendWelcome(EmailAddress)

InMemoryUsers

ConsoleMailer

parses

creates

SignupService

+signUp(email, password) : User

EmailAddress

+value

+parse(raw) : EmailAddress

User

+email: EmailAddress

+passwordHash

PasswordPolicy

+hashIfAcceptable(password) : String

«interface»

UserRepository

+find(EmailAddress) : User

+save(User)

«interface»

WelcomeMailer

+sendWelcome(EmailAddress)

InMemoryUsers

ConsoleMailer

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
SignupService.javasignup_service.pySignupService.cssignup_service.gosignup-service.ts
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
// A value that is valid by construction. Every class that holds an EmailAddress can trust it,
// so the "what is a valid email" rule lives here once, owned by product.
record EmailAddress(String value) {
static EmailAddress parse(String raw) {
String v = raw.trim().toLowerCase();
if (!v.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) throw new IllegalArgumentException("bad email");
return new EmailAddress(v);
}
}
// The entity the rest of the system cares about. It never sees a plain-text password.
record User(EmailAddress email, String passwordHash) {}
// Everything about passwords, owned by security. One method here, but adding "check against
// breached passwords" would add a second with the same owner: SRP counts owners, not methods.
class PasswordPolicy {
String hashIfAcceptable(String password) {
if (password.length() < 12) throw new IllegalArgumentException("password too short");
// Demo only. Production code calls a vetted library (bcrypt, scrypt or Argon2id).
return "demo-hash:" + new StringBuilder(password).reverse();
}
}
// Storage, owned by the platform team. An interface so signup can be tested without a
// database, and so moving to Postgres is a new class, not an edit to the signup flow.
interface UserRepository {
Optional<User> find(EmailAddress email);
void save(User user);
}
// The welcome email, owned by marketing. Copy changes happen here and nowhere else.
interface WelcomeMailer {
void sendWelcome(EmailAddress to);
}
// The facade. Its one responsibility is the signup flow itself: the order of the steps and
// what happens when one fails. Product owns that, so product's changes land here.
class SignupService {
private final PasswordPolicy passwords;
private final UserRepository users;
private final WelcomeMailer mailer;
final List<String> retryLater = new ArrayList<>(); // stands in for a retry queue
SignupService(PasswordPolicy passwords, UserRepository users, WelcomeMailer mailer) {
this.passwords = passwords;
this.users = users;
this.mailer = mailer;
}
User signUp(String rawEmail, String password) {
EmailAddress email = EmailAddress.parse(rawEmail);
if (users.find(email).isPresent()) throw new IllegalStateException("email taken");
User user = new User(email, passwords.hashIfAcceptable(password));
users.save(user);
// A decision about the flow, made in the one class that owns the flow: the account
// exists, so a mail failure must not report the signup as failed.
try {
mailer.sendWelcome(email);
} catch (RuntimeException e) {
retryLater.add(email.value());
}
return user;
}
}
class InMemoryUsers implements UserRepository {
private final Map<EmailAddress, User> rows = new HashMap<>();
public Optional<User> find(EmailAddress email) { return Optional.ofNullable(rows.get(email)); }
public void save(User user) { rows.put(user.email(), user); }
}
class ConsoleMailer implements WelcomeMailer {
boolean down = false;
public void sendWelcome(EmailAddress to) {
if (down) throw new RuntimeException("SMTP timeout");
System.out.println("To " + to.value() + ": Welcome to Acme! Here's how to get started...");
}
}
class Main {
public static void main(String[] args) {
ConsoleMailer mailer = new ConsoleMailer();
SignupService service = new SignupService(new PasswordPolicy(), new InMemoryUsers(), mailer);
System.out.println(service.signUp(" Ada@Example.com ", "correct horse battery").email().value());
mailer.down = true;
service.signUp("grace@example.com", "a long enough password"); // succeeds
System.out.println("retry later: " + service.retryLater); // [grace@example.com]
}
}

The flow, including the failure that used to be a bug:

Signup when the mail server is down
WelcomeMailerUserRepositoryPasswordPolicySignupServiceCallerWelcomeMailerUserRepositoryPasswordPolicySignupServiceCallersignUp(" Ada@Example.com ", password)EmailAddress.parsefind(email)nonehashIfAcceptable(password)hashsave(user)sendWelcome(email)SMTP timeoutadd to retry queueUser (signup succeeded)WelcomeMailerUserRepositoryPasswordPolicySignupServiceCallerWelcomeMailerUserRepositoryPasswordPolicySignupServiceCallersignUp(" Ada@Example.com ", password)EmailAddress.parsefind(email)nonehashIfAcceptable(password)hashsave(user)sendWelcome(email)SMTP timeoutadd to retry queueUser (signup succeeded)
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
  • Implementation
  • Facade
  • Coordinating is a responsibility. SignupService still touches everything, and that’s correct. Its one job is the flow, owned by product. A class that only calls others in the right order, and decides what failures mean, is not breaking SRP. It’s the reason the others can stay simple.
  • One owner, any number of methods. PasswordPolicy checks and hashes. Both belong to security, so they live together. Splitting them would make security edit two files for every password change.
  • Value objects hold their own rules. EmailAddress can only be created valid. Every class that holds one can trust it. So “what is a valid email” is written once, not checked again in the repository and the mailer.
  • The flow decides what a failure means. “Signup succeeds even if the welcome email doesn’t send” is a product decision. Before, it was an accident of line order. Now it is one try in the class that owns the flow. In production, the email goes through a queue or an outbox table, so it is retried even if the process dies.
  • Interfaces appear where a team owns the details. UserRepository and WelcomeMailer are interfaces. So the flow can be tested with fakes, and storage can be swapped without editing it. That’s dependency inversion. SRP is what tells you where the seams go.
Interviewer

Your SignupService calls four other classes. Isn’t it doing too much?

What they are testing

Whether you can tell orchestration from implementation: “It decides the order of the steps and what a failure means, and nothing else. Hashing, storage and email copy live elsewhere.” Then whether you mention the mail failure as a flow decision. The red flag is splitting it further into SignupValidator, SignupSaver and SignupNotifier with nothing left to own the flow.

Hard

Invoices three teams change

  • Actors, not tasks
  • Shared code that should stay unshared
  • One breakdown everyone reads

A software company bills customers monthly. Each invoice has lines (the plan, extra seats), a discount the sales team negotiated, store credit the customer already has, and sales tax. Three teams depend on the numbers. Finance decides what the customer pays. The tax team decides what tax is owed, and reports it to the government. And design owns how the invoice looks.

This level is about the subtle failure SRP exists to prevent. It is not that the class is big. It is that a change one team asked for silently changes another team’s numbers.

Invoice computes the total, the tax and the PDF. Both the total and the tax need “subtotal minus discount”, so a developer wrote a private net() helper once and used it in both. Then finance asked to apply store credit. The developer added it to net(), because that is where the total comes from:

Invoice.javainvoice.pyInvoice.csinvoice.goinvoice.ts
import java.util.List;
record Line(String description, int unitCents, int qty) {}
// Before: the invoice prices itself, taxes itself and draws itself.
// Finance, the tax team and the design team all change this one class.
class Invoice {
final List<Line> lines;
final int discountPct; // a discount the seller gives
final int storeCreditCents; // credit the customer already has
final int taxBps; // 800 = 8%
Invoice(List<Line> lines, int discountPct, int storeCreditCents, int taxBps) {
this.lines = lines;
this.discountPct = discountPct;
this.storeCreditCents = storeCreditCents;
this.taxBps = taxBps;
}
// A helper shared by finance's total and the tax team's tax. It looks like good reuse:
// both needed "subtotal minus discount", so it was written once.
private int net() {
int subtotal = 0;
for (Line l : lines) subtotal += l.unitCents() * l.qty();
int afterDiscount = subtotal - subtotal * discountPct / 100;
// Finance asked: "apply store credit". The developer put it in net(), since that
// is where the total comes from. It is the right change for finance...
return afterDiscount - storeCreditCents;
}
// ...and the wrong one for tax. Store credit is a way of paying, not a lower price, so
// it must not shrink the taxable amount. Nobody on the tax team asked for any change,
// and their number is now wrong.
int taxCents() { return net() * taxBps / 10_000; }
int amountDueCents() { return net() + taxCents(); }
// The design team's code lives here too, so a new logo is a change to the class that
// computes tax, and goes through the same review and release.
String render() {
return String.format("INVOICE%n tax %8.2f%n due %8.2f%n", taxCents() / 100.0, amountDueCents() / 100.0);
}
}
class Main {
public static void main(String[] args) {
Invoice inv = new Invoice(
List.of(new Line("Pro plan", 10_000, 1), new Line("Extra seats", 1_500, 4)),
10, 2_000, 800);
System.out.print(inv.render()); // tax 9.92, due 133.92: tax is $1.60 short
}
}

Finance’s number is right. The tax team’s is wrong, and nobody on the tax team changed anything. Store credit is a way of paying, like a gift card, not a lower price, so it must not reduce the taxable amount. Tax should be 8% of $144.00, which is $11.52. The invoice says $9.92. Every invoice with store credit now under-collects tax, and the tax report sent to the government is wrong. Tests written by finance pass. Tests written by the tax team, if they run against fixtures without store credit, pass too.

Robert Martin’s own example of SRP is this exact bug. An Employee class had a regularHours() helper, shared by the CFO’s pay calculation and the COO’s hours report. A change for one broke the other.

Make the invoice plain data. Give each team its own class with its own calculation, even where the calculations look alike today. Compute every number once into a Breakdown, so the PDF and the payment can’t disagree.

Hard: one class per team, one breakdown everyone reads

creates

reads

Billing

+totals(Invoice) : Breakdown

+issue(Invoice) : String

Invoice

+lines: List<Line>

+discountPct

+storeCreditCents

+taxBps

+subtotalCents() : int

Line

+description

+unitCents

+qty

+subtotalCents() : int

TaxCalculator

+taxableCents(Invoice) : int

+taxCents(Invoice) : int

Pricing

+price(Invoice, taxCents) : Breakdown

InvoiceRenderer

+render(Breakdown) : String

Breakdown

+subtotal

+discount

+tax

+credit

+due

creates

reads

Billing

+totals(Invoice) : Breakdown

+issue(Invoice) : String

Invoice

+lines: List<Line>

+discountPct

+storeCreditCents

+taxBps

+subtotalCents() : int

Line

+description

+unitCents

+qty

+subtotalCents() : int

TaxCalculator

+taxableCents(Invoice) : int

+taxCents(Invoice) : int

Pricing

+price(Invoice, taxCents) : Breakdown

InvoiceRenderer

+render(Breakdown) : String

Breakdown

+subtotal

+discount

+tax

+credit

+due

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
TaxCalculator.javabilling.pyTaxCalculator.csbilling.gobilling.ts
import java.util.List;
// The one calculation every team truly shares, by definition: a line costs price times
// quantity. It lives on the value itself, so there is nothing to disagree about.
record Line(String description, int unitCents, int qty) {
int subtotalCents() { return unitCents * qty; }
}
// The invoice is data: what was sold and on what terms. It holds no pricing policy,
// no tax law and no layout, so none of the three teams needs to edit it.
record Invoice(List<Line> lines, int discountPct, int storeCreditCents, int taxBps) {
int subtotalCents() { return lines.stream().mapToInt(Line::subtotalCents).sum(); }
}
// Every number on the invoice, computed once. The PDF, the payment request and the ledger
// all read this, so they can never show different totals.
record Breakdown(int subtotal, int discount, int tax, int credit, int due) {}
// Owned by the tax team. It computes its own taxable amount on purpose, even though today
// it starts the same way as finance's: the two are defined by different people for
// different reasons, and must be free to drift apart.
class TaxCalculator {
int taxableCents(Invoice inv) {
int subtotal = inv.subtotalCents();
// A seller's discount lowers the price, so it lowers the taxable amount.
// Store credit is a way of paying, so it does not appear here at all.
return subtotal - subtotal * inv.discountPct() / 100;
}
int taxCents(Invoice inv) { return taxableCents(inv) * inv.taxBps() / 10_000; }
}
// Owned by finance: what the customer pays today. Finance's "apply store credit" request
// is a change to this class and nothing else.
class Pricing {
Breakdown price(Invoice inv, int taxCents) {
int subtotal = inv.subtotalCents();
int discount = subtotal * inv.discountPct() / 100;
int beforeCredit = subtotal - discount + taxCents;
// Credit can't take the amount below zero; leftover credit stays on the account.
int credit = Math.min(inv.storeCreditCents(), beforeCredit);
return new Breakdown(subtotal, discount, taxCents, credit, beforeCredit - credit);
}
}
// Owned by the design team. It only reads a Breakdown, so a new layout can't change a total.
class InvoiceRenderer {
String render(Breakdown b) {
return String.format("INVOICE%n subtotal %8.2f%n discount %8.2f%n tax %8.2f%n credit %8.2f%n due %8.2f%n",
b.subtotal() / 100.0, -b.discount() / 100.0, b.tax() / 100.0, -b.credit() / 100.0, b.due() / 100.0);
}
}
// The facade callers use. Its job is the order of the steps (tax first, because the price
// depends on it), so callers never wire three classes together and can't get the order wrong.
class Billing {
private final TaxCalculator tax = new TaxCalculator();
private final Pricing pricing = new Pricing();
private final InvoiceRenderer renderer = new InvoiceRenderer();
Breakdown totals(Invoice inv) { return pricing.price(inv, tax.taxCents(inv)); }
String issue(Invoice inv) { return renderer.render(totals(inv)); }
}
class Main {
public static void main(String[] args) {
Invoice inv = new Invoice(
List.of(new Line("Pro plan", 10_000, 1), new Line("Extra seats", 1_500, 4)),
10, 2_000, 800);
System.out.print(new Billing().issue(inv)); // tax 11.52, due 135.52
}
}

Send each team’s request through both designs. In the first, every change is re-checked by every team:

A change request arrives
One Invoice class
  • Invoiceprices, taxes and draws itself

One class per team
  • Billingruns the steps in order
  • Invoicewhat was sold, on what terms
  • Pricingwhat the customer pays
  • TaxCalculatorwhat tax is owed
  • InvoiceRendererhow the invoice looks

Pick a change request to see which classes it touches in each design.

  • Duplication across actors can be correct. TaxCalculator and Pricing both compute “subtotal minus discount”. It looks like a DRY violation, and merging them is what caused the bug. They are the same today by coincidence. Tax law defines one. Finance’s policy defines the other. Keep code shared only when it is the same by definition, like a line’s subtotal, which lives on Line.
  • Compute once, read everywhere. Breakdown holds every number. The renderer, the payment request and the ledger read it instead of recomputing. Two places that compute the same total will eventually disagree, and the customer sees two different amounts.
  • The facade fixes the order. Tax has to be computed before the price, because the price includes it. Billing is the one place that knows this, so callers can’t get it wrong.
  • Data has no owner’s rules. Invoice holds what was sold and on what terms. It has no policy, so no team has a reason to edit it, and adding a field is a small, visible change.
  • Teams map to modules. In a larger system, these classes become packages or services, owned by different teams. SRP at class level and team ownership at service level are the same idea at two scales. That’s why Martin later tied the principle to people, not code.
Interviewer

TaxCalculator and Pricing both compute the discounted subtotal. Shouldn’t you pull that into one helper?

What they are testing

Whether you know the difference between real and accidental duplication, and can say who owns each rule: “Tax law defines the taxable amount. Finance defines the price. They match today. They won’t stay matched.” Then whether you name what is truly shared, and where it lives. The red flag is agreeing to merge them on DRY grounds.

Say this out loud

Finance, tax and design each change this for different reasons, so each gets its own class. I’m deliberately not sharing the discount math between tax and pricing, because a change to one must not move the other.

Split when at least one of these is true:

  • Two different groups already ask for changes to the same class.
  • A change for one feature keeps breaking tests for another.
  • You can’t test one part without setting up an unrelated part (a database to test a format).
  • The class is growing in two directions at once.

Keep it together when there is one actor, or the code is small and young. A 60-line class used by one team is not an SRP problem, even if it does three things. Splitting it now means guessing who the actors will be.

Idea Looks like SRP because The difference
Separation of concerns Both split code into parts Separation of concerns splits by technical topic (UI, logic, storage). SRP splits by who asks for changes. They often agree; when they don’t, SRP is the one that predicts conflicts.
Cohesion High cohesion is what SRP produces Cohesion measures how much a class’s parts use each other. A class can be cohesive and still serve two actors, like Invoice with its shared net().
Interface segregation Both make things smaller ISP splits an interface by what each caller uses. SRP splits a class by who changes it.
DRY Both talk about where code lives DRY removes repeated knowledge. SRP sometimes keeps similar code repeated on purpose, because it is not the same knowledge.

On this site:

  • Logging framework: formatters decide how a record looks and appenders decide where it goes. Different people change each.
  • Parking lot: pricing lives in PricingPolicy, not on the lot or the ticket, because the business changes prices without touching how spots are tracked.
  • Vending machine: making change is its own part, separate from tracking stock and from the machine’s states.

In real systems, you’ll see it in the controller, service and repository layers of most web frameworks, in React components that separate data fetching from display, and in teams that own one microservice each.

Interviewer asksHow do you decide whether a class has more than one responsibility?

List who could ask for changes to it: a team, a role, a stakeholder. One group means one responsibility, whatever the number of methods. Back it up with a symptom: tests that need unrelated setup, or changes for one feature that break another.

Interviewer asksDoesn't SRP just lead to too many tiny classes?

Only if you split by verbs. Split by actor and the number of classes follows the number of groups who change the code, which is usually small. If a split means one change now touches three files, it went too far, and merging them back is also applying SRP.

Interviewer asksTwo classes have the same ten lines. Should you extract them?

Ask whether they are the same knowledge or the same text. If they will always change together, because they come from one rule, extract them. If two different owners could change them separately, keep them apart. Merging them couples those owners, and the next change from one breaks the other.

Interviewer asksYour service class calls five others. Is that an SRP violation?

Not by itself. Coordinating the steps of a flow, in the right order, and deciding what each failure means is one responsibility, usually owned by product. It becomes a violation when the coordinator also contains the details of the steps, like SQL or email templates.

Decision We chose We gave up
What counts as a responsibility An actor: a group that asks for changes A rule you can apply without knowing the business
How to split One class per actor, plus plain data Reading a whole feature in one file
Who runs the steps A small facade that owns the flow and its failures Callers wiring the parts themselves
Code that looks alike across actors Keep it separate, with a comment saying why The satisfaction of removing duplication
Numbers several parts need Compute once into a value everyone reads Each part being fully self-contained
When to split When a second actor shows up Getting the structure “right” up front