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.
- Read first
- OverviewObject modeling
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 test
Section titled “The test”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:
- 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.
- 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”.
- Look at the history. In a real codebase,
git logon 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.
- 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.
Worked examples
Section titled “Worked examples”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.
Without the principle
Section titled “Without the principle”The first version puts everything on Order:
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] }}from dataclasses import dataclass
# One line on the order: a value, it never changes once added.@dataclass(frozen=True)class Item: name: str price_cents: int qty: int
# 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: def __init__(self, csv_file: list[str]) -> None: self._items: list[Item] = [] self._csv_file = csv_file # stands in for orders.csv on disk
def add(self, item: Item) -> None: self._items.append(item)
# Reason to change 1: the manager changes how prices work (happy hour, loyalty). def total_cents(self) -> int: return sum(i.price_cents * i.qty for i in self._items)
# 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. def receipt(self) -> str: lines = ["BEAN THERE CAFE"] for i in self._items: lines.append(f"{i.name:<14} x{i.qty} {i.price_cents * i.qty / 100:6.2f}") lines.append(f"{'TOTAL':<17} {self.total_cents() / 100:6.2f}") return "\n".join(lines)
# 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. def save(self) -> None: self._csv_file.append(f"{len(self._items)},{self.total_cents()}")
if __name__ == "__main__": csv: list[str] = [] order = Order(csv) order.add(Item("Flat white", 450, 2)) order.add(Item("Croissant", 325, 1)) print(order.receipt()) order.save() print(csv) # ['2,1225']using System;using System.Collections.Generic;using System.Linq;using System.Text;
// 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 readonly List<Item> _items = new(); private readonly List<string> _csvFile; // stands in for orders.csv on disk
public Order(List<string> csvFile) => _csvFile = csvFile;
public void Add(Item item) => _items.Add(item);
// Reason to change 1: the manager changes how prices work (happy hour, loyalty). public int TotalCents() => _items.Sum(i => i.PriceCents * i.Qty);
// 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. public string Receipt() { var sb = new StringBuilder("BEAN THERE CAFE\n"); foreach (var i in _items) sb.AppendLine($"{i.Name,-14} x{i.Qty} {i.PriceCents * i.Qty / 100.0,6:F2}"); return sb.AppendLine($"{"TOTAL",-17} {TotalCents() / 100.0,6:F2}").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. public void Save() => _csvFile.Add($"{_items.Count},{TotalCents()}");}
static class Program{ static void Main() { var csv = new List<string>(); var order = new Order(csv); order.Add(new Item("Flat white", 450, 2)); order.Add(new Item("Croissant", 325, 1)); Console.Write(order.Receipt()); order.Save(); Console.WriteLine(string.Join(";", csv)); // 2,1225 }}package main
import ( "fmt" "strings")
// Item is one line on the order: a value, it never changes once added.type Item struct { Name string PriceCents int Qty int}
// Order before: one type 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.type Order struct { items []Item csvFile *[]string // stands in for orders.csv on disk}
func (o *Order) Add(item Item) { o.items = append(o.items, item) }
// TotalCents is reason to change 1: the manager changes how prices work.func (o *Order) TotalCents() int { sum := 0 for _, i := range o.items { sum += i.PriceCents * i.Qty } return sum}
// Receipt is 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.func (o *Order) Receipt() string { var b strings.Builder b.WriteString("BEAN THERE CAFE\n") for _, i := range o.items { fmt.Fprintf(&b, "%-14s x%d %6.2f\n", i.Name, i.Qty, float64(i.PriceCents*i.Qty)/100) } fmt.Fprintf(&b, "%-17s %6.2f\n", "TOTAL", float64(o.TotalCents())/100) return b.String()}
// Save is reason to change 3: the owner moves from a CSV file to a database.// flaw: an edit here ships in the same type as pricing, so pricing gets re-tested too.func (o *Order) Save() { *o.csvFile = append(*o.csvFile, fmt.Sprintf("%d,%d", len(o.items), o.TotalCents()))}
func main() { var csv []string order := &Order{csvFile: &csv} order.Add(Item{"Flat white", 450, 2}) order.Add(Item{"Croissant", 325, 1}) fmt.Print(order.Receipt()) order.Save() fmt.Println(csv) // [2,1225]}// One line on the order: a value, it never changes once added.interface Item { readonly name: string; readonly priceCents: number; readonly qty: number;}
// 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 readonly items: Item[] = [];
// csvFile stands in for orders.csv on disk. constructor(private readonly csvFile: string[]) {}
add(item: Item): void { this.items.push(item); }
// Reason to change 1: the manager changes how prices work (happy hour, loyalty). totalCents(): number { return this.items.reduce((sum, i) => sum + i.priceCents * i.qty, 0); }
// 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. receipt(): string { const lines = ["BEAN THERE CAFE"]; for (const i of this.items) { lines.push(`${i.name.padEnd(14)} x${i.qty} ${((i.priceCents * i.qty) / 100).toFixed(2).padStart(6)}`); } lines.push(`${"TOTAL".padEnd(17)} ${(this.totalCents() / 100).toFixed(2).padStart(6)}`); return lines.join("\n"); }
// 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. save(): void { this.csvFile.push(`${this.items.length},${this.totalCents()}`); }}
const csv: string[] = [];const order = new Order(csv);order.add({ name: "Flat white", priceCents: 450, qty: 2 });order.add({ name: "Croissant", priceCents: 325, qty: 1 });console.log(order.receipt());order.save();console.log(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.
With SRP
Section titled “With SRP”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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] }}from dataclasses import dataclass
# One line on the order: a value, it never changes once added.@dataclass(frozen=True)class Item: name: str price_cents: int qty: int
# 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: def __init__(self) -> None: self._items: list[Item] = []
def add(self, item: Item) -> None: self._items.append(item)
# A tuple: the formatter and the store can look, but only Order changes the items. @property def items(self) -> tuple[Item, ...]: return tuple(self._items)
def total_cents(self) -> int: return sum(i.price_cents * i.qty for i in self._items)
# 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: def format(self, order: Order) -> str: lines = ["BEAN THERE CAFE"] for i in order.items: lines.append(f"{i.name:<14} x{i.qty} {i.price_cents * i.qty / 100:6.2f}") # The total comes from Order, never recomputed here: one place owns the arithmetic. lines.append(f"{'TOTAL':<17} {order.total_cents() / 100:6.2f}") return "\n".join(lines)
# Owned by whoever runs the shop's systems. Moving to a database replaces this class;# Order and the receipt never notice.class OrderStore: def __init__(self, csv_file: list[str]) -> None: self._csv_file = csv_file # stands in for orders.csv on disk
def save(self, order: Order) -> None: self._csv_file.append(f"{len(order.items)},{order.total_cents()}")
if __name__ == "__main__": order = Order() order.add(Item("Flat white", 450, 2)) order.add(Item("Croissant", 325, 1))
# The till wires the three together. That wiring is the only place that knows all three. print(ReceiptFormatter().format(order)) csv: list[str] = [] OrderStore(csv).save(order) print(csv) # ['2,1225']using System;using System.Collections.Generic;using System.Linq;using System.Text;
// 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 readonly List<Item> _items = new();
public void Add(Item item) => _items.Add(item);
// Read-only view: the formatter and the store can look, but only Order changes the items. public IReadOnlyList<Item> Items => _items.AsReadOnly();
public int TotalCents() => _items.Sum(i => i.PriceCents * i.Qty);}
// 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{ public string Format(Order order) { var sb = new StringBuilder("BEAN THERE CAFE\n"); foreach (var i in order.Items) sb.AppendLine($"{i.Name,-14} x{i.Qty} {i.PriceCents * i.Qty / 100.0,6:F2}"); // The total comes from Order, never recomputed here: one place owns the arithmetic. return sb.AppendLine($"{"TOTAL",-17} {order.TotalCents() / 100.0,6:F2}").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 readonly List<string> _csvFile; // stands in for orders.csv on disk
public OrderStore(List<string> csvFile) => _csvFile = csvFile;
public void Save(Order order) => _csvFile.Add($"{order.Items.Count},{order.TotalCents()}");}
static class Program{ static void Main() { var 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. Console.Write(new ReceiptFormatter().Format(order)); var csv = new List<string>(); new OrderStore(csv).Save(order); Console.WriteLine(string.Join(";", csv)); // 2,1225 }}package main
import ( "fmt" "strings")
// Item is one line on the order: a value, it never changes once added.type Item struct { Name string PriceCents int Qty int}
// 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.type Order struct { items []Item}
func (o *Order) Add(item Item) { o.items = append(o.items, item) }
// Items returns a copy: the formatter and the store can look, but only Order changes the items.func (o *Order) Items() []Item { return append([]Item(nil), o.items...) }
func (o *Order) TotalCents() int { sum := 0 for _, i := range o.items { sum += i.PriceCents * i.Qty } return sum}
// FormatReceipt is owned by the baristas. Changing the layout edits this function and// nothing else, and it is tested by comparing strings: no storage, no setup.// In Go a plain function is enough; it holds no state, so it needs no type.func FormatReceipt(o *Order) string { var b strings.Builder b.WriteString("BEAN THERE CAFE\n") for _, i := range o.Items() { fmt.Fprintf(&b, "%-14s x%d %6.2f\n", i.Name, i.Qty, float64(i.PriceCents*i.Qty)/100) } // The total comes from Order, never recomputed here: one place owns the arithmetic. fmt.Fprintf(&b, "%-17s %6.2f\n", "TOTAL", float64(o.TotalCents())/100) return b.String()}
// OrderStore is owned by whoever runs the shop's systems. Moving to a database replaces// this type; Order and the receipt never notice.type OrderStore struct { csvFile *[]string // stands in for orders.csv on disk}
func (s OrderStore) Save(o *Order) { *s.csvFile = append(*s.csvFile, fmt.Sprintf("%d,%d", len(o.Items()), o.TotalCents()))}
func main() { order := &Order{} order.Add(Item{"Flat white", 450, 2}) order.Add(Item{"Croissant", 325, 1})
// main wires the three together. That wiring is the only place that knows all three. fmt.Print(FormatReceipt(order)) var csv []string OrderStore{csvFile: &csv}.Save(order) fmt.Println(csv) // [2,1225]}// One line on the order: a value, it never changes once added.interface Item { readonly name: string; readonly priceCents: number; readonly qty: number;}
// 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 readonly lines: Item[] = [];
add(item: Item): void { this.lines.push(item); }
// Read-only view: the formatter and the store can look, but only Order changes the items. get items(): readonly Item[] { return this.lines; }
totalCents(): number { return this.lines.reduce((sum, i) => sum + i.priceCents * i.qty, 0); }}
// 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 { format(order: Order): string { const lines = ["BEAN THERE CAFE"]; for (const i of order.items) { lines.push(`${i.name.padEnd(14)} x${i.qty} ${((i.priceCents * i.qty) / 100).toFixed(2).padStart(6)}`); } // The total comes from Order, never recomputed here: one place owns the arithmetic. lines.push(`${"TOTAL".padEnd(17)} ${(order.totalCents() / 100).toFixed(2).padStart(6)}`); return lines.join("\n"); }}
// Owned by whoever runs the shop's systems. Moving to a database replaces this class;// Order and the receipt never notice.class OrderStore { // csvFile stands in for orders.csv on disk. constructor(private readonly csvFile: string[]) {}
save(order: Order): void { this.csvFile.push(`${order.items.length},${order.totalCents()}`); }}
const order = new Order();order.add({ name: "Flat white", priceCents: 450, qty: 2 });order.add({ name: "Croissant", priceCents: 325, qty: 1 });
// The till wires the three together. That wiring is the only place that knows all three.console.log(new ReceiptFormatter().format(order));const csv: string[] = [];new OrderStore(csv).save(order);console.log(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:
- Orderprices, prints and saves
- 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.
What to notice
Section titled “What to notice”- The split follows people, not verbs.
Orderstill has three methods. It has one responsibility because all three belong to the manager. - Data stays with the class that owns its rules.
Orderkeeps 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.
ReceiptFormatteris 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.
Here is an
Orderclass withtotal(),printReceipt()andsave(). Does it break SRP?
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”.
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.
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.
Without the principle
Section titled “Without the principle”The first version is one method, top to bottom:
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 } }}import re
EMAIL = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
# Before: one method runs the whole signup, and four teams own parts of it.class SignupService: def __init__(self) -> None: self._users_table: dict[str, str] = {} # stands in for the users table self.sent_mail: list[str] = [] # stands in for the mail server self.mail_server_down = False
def sign_up(self, email: str, password: str) -> str: # Product owns what a valid email is. email = email.strip().lower() if not EMAIL.fullmatch(email): raise ValueError("bad email")
# Security owns password rules and hashing. if len(password) < 12: raise ValueError("password too short") password_hash = "demo-hash:" + password[::-1] # 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 email in self._users_table: raise RuntimeError("email taken") self._users_table[email] = password_hash
# Marketing owns the welcome email. # flaw: if the mail server is down this raises, and the caller sees a failed signup # for an account that was already saved. Retrying then says "email taken". if self.mail_server_down: raise RuntimeError("SMTP timeout") self.sent_mail.append(f"To {email}: Welcome to Acme! Here's how to get started...") return email
if __name__ == "__main__": service = SignupService() print(service.sign_up(" Ada@Example.com ", "correct horse battery")) service.mail_server_down = True try: service.sign_up("grace@example.com", "a long enough password") except RuntimeError as e: print("signup failed:", e) # ...but the account existsusing System;using System.Collections.Generic;using System.Text.RegularExpressions;
// Before: one method runs the whole signup, and four teams own parts of it.class SignupService{ private readonly Dictionary<string, string> _usersTable = new(); // stands in for the users table private readonly List<string> _sentMail = new(); // stands in for the mail server public bool MailServerDown { get; set; }
public string SignUp(string email, string password) { // Product owns what a valid email is. email = email.Trim().ToLowerInvariant(); if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) throw new ArgumentException("bad email");
// Security owns password rules and hashing. if (password.Length < 12) throw new ArgumentException("password too short"); var chars = password.ToCharArray(); Array.Reverse(chars); var hash = "demo-hash:" + new string(chars); // 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 InvalidOperationException("email taken"); _usersTable[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 TimeoutException("SMTP timeout"); _sentMail.Add($"To {email}: Welcome to Acme! Here's how to get started..."); return email; }}
static class Program{ static void Main() { var service = new SignupService(); Console.WriteLine(service.SignUp(" Ada@Example.com ", "correct horse battery")); service.MailServerDown = true; try { service.SignUp("grace@example.com", "a long enough password"); } catch (TimeoutException e) { Console.WriteLine("signup failed: " + e.Message); // ...but the account exists } }}package main
import ( "errors" "fmt" "regexp" "strings")
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
// SignupService before: one method runs the whole signup, and four teams own parts of it.type SignupService struct { usersTable map[string]string // stands in for the users table sentMail []string // stands in for the mail server MailServerDown bool}
func NewSignupService() *SignupService { return &SignupService{usersTable: map[string]string{}}}
func (s *SignupService) SignUp(email, password string) (string, error) { // Product owns what a valid email is. email = strings.ToLower(strings.TrimSpace(email)) if !emailRe.MatchString(email) { return "", errors.New("bad email") }
// Security owns password rules and hashing. if len(password) < 12 { return "", errors.New("password too short") } hash := "demo-hash:" + reverse(password) // NOT a real hash
// The platform team owns storage. Moving to Postgres rewrites lines in the middle of // a function that also holds the password rules. if _, taken := s.usersTable[email]; taken { return "", errors.New("email taken") } s.usersTable[email] = hash
// Marketing owns the welcome email. // flaw: if the mail server is down this fails, and the caller sees a failed signup // for an account that was already saved. Retrying then says "email taken". if s.MailServerDown { return "", errors.New("SMTP timeout") } s.sentMail = append(s.sentMail, "To "+email+": Welcome to Acme! Here's how to get started...") return email, nil}
func reverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r)}
func main() { s := NewSignupService() email, _ := s.SignUp(" Ada@Example.com ", "correct horse battery") fmt.Println(email) s.MailServerDown = true if _, err := s.SignUp("grace@example.com", "a long enough password"); err != nil { fmt.Println("signup failed:", err) // ...but the account exists }}const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// Before: one method runs the whole signup, and four teams own parts of it.class SignupService { private readonly usersTable = new Map<string, string>(); // stands in for the users table private readonly sentMail: string[] = []; // stands in for the mail server mailServerDown = false;
signUp(email: string, password: string): string { // Product owns what a valid email is. email = email.trim().toLowerCase(); if (!EMAIL.test(email)) throw new Error("bad email");
// Security owns password rules and hashing. if (password.length < 12) throw new Error("password too short"); const hash = "demo-hash:" + [...password].reverse().join(""); // 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 (this.usersTable.has(email)) throw new Error("email taken"); this.usersTable.set(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 (this.mailServerDown) throw new Error("SMTP timeout"); this.sentMail.push(`To ${email}: Welcome to Acme! Here's how to get started...`); return email; }}
const service = new SignupService();console.log(service.signUp(" Ada@Example.com ", "correct horse battery"));service.mailServerDown = true;try { service.signUp("grace@example.com", "a long enough password");} catch (e) { console.log("signup failed:", (e as Error).message); // ...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.
With SRP
Section titled “With SRP”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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] }}import refrom dataclasses import dataclassfrom typing import Protocol
EMAIL = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
# 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.@dataclass(frozen=True)class EmailAddress: value: str
@staticmethod def parse(raw: str) -> "EmailAddress": v = raw.strip().lower() if not EMAIL.fullmatch(v): raise ValueError("bad email") return EmailAddress(v)
# The entity the rest of the system cares about. It never sees a plain-text password.@dataclass(frozen=True)class User: email: EmailAddress password_hash: str
# 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: def hash_if_acceptable(self, password: str) -> str: if len(password) < 12: raise ValueError("password too short") # Demo only. Production code calls a vetted library (bcrypt, scrypt or Argon2id). return "demo-hash:" + password[::-1]
# Storage, owned by the platform team. A Protocol so signup can be tested without a# database, and so moving to Postgres is a new class, not an edit to the signup flow.class UserRepository(Protocol): def find(self, email: EmailAddress) -> User | None: ... def save(self, user: User) -> None: ...
# The welcome email, owned by marketing. Copy changes happen here and nowhere else.class WelcomeMailer(Protocol): def send_welcome(self, to: EmailAddress) -> None: ...
# 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: def __init__(self, passwords: PasswordPolicy, users: UserRepository, mailer: WelcomeMailer) -> None: self._passwords = passwords self._users = users self._mailer = mailer self.retry_later: list[str] = [] # stands in for a retry queue
def sign_up(self, raw_email: str, password: str) -> User: email = EmailAddress.parse(raw_email) if self._users.find(email) is not None: raise RuntimeError("email taken") user = User(email, self._passwords.hash_if_acceptable(password)) self._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: self._mailer.send_welcome(email) except RuntimeError: self.retry_later.append(email.value) return user
class InMemoryUsers: def __init__(self) -> None: self._rows: dict[EmailAddress, User] = {}
def find(self, email: EmailAddress) -> User | None: return self._rows.get(email)
def save(self, user: User) -> None: self._rows[user.email] = user
class ConsoleMailer: def __init__(self) -> None: self.down = False
def send_welcome(self, to: EmailAddress) -> None: if self.down: raise RuntimeError("SMTP timeout") print(f"To {to.value}: Welcome to Acme! Here's how to get started...")
if __name__ == "__main__": mailer = ConsoleMailer() service = SignupService(PasswordPolicy(), InMemoryUsers(), mailer) print(service.sign_up(" Ada@Example.com ", "correct horse battery").email.value) mailer.down = True service.sign_up("grace@example.com", "a long enough password") # succeeds print("retry later:", service.retry_later) # ['grace@example.com']using System;using System.Collections.Generic;using System.Text.RegularExpressions;
// 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){ public static EmailAddress Parse(string raw) { var v = raw.Trim().ToLowerInvariant(); if (!Regex.IsMatch(v, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) throw new ArgumentException("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{ public string HashIfAcceptable(string password) { if (password.Length < 12) throw new ArgumentException("password too short"); // Demo only. Production code calls a vetted library (bcrypt, scrypt or Argon2id). var chars = password.ToCharArray(); Array.Reverse(chars); return "demo-hash:" + new string(chars); }}
// 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 IUserRepository{ User? Find(EmailAddress email); void Save(User user);}
// The welcome email, owned by marketing. Copy changes happen here and nowhere else.interface IWelcomeMailer{ 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 readonly PasswordPolicy _passwords; private readonly IUserRepository _users; private readonly IWelcomeMailer _mailer; public List<string> RetryLater { get; } = new(); // stands in for a retry queue
public SignupService(PasswordPolicy passwords, IUserRepository users, IWelcomeMailer mailer) { _passwords = passwords; _users = users; _mailer = mailer; }
public User SignUp(string rawEmail, string password) { var email = EmailAddress.Parse(rawEmail); if (_users.Find(email) is not null) throw new InvalidOperationException("email taken"); var 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 (TimeoutException) { RetryLater.Add(email.Value); } return user; }}
class InMemoryUsers : IUserRepository{ private readonly Dictionary<EmailAddress, User> _rows = new(); public User? Find(EmailAddress email) => _rows.GetValueOrDefault(email); public void Save(User user) => _rows[user.Email] = user;}
class ConsoleMailer : IWelcomeMailer{ public bool Down { get; set; } public void SendWelcome(EmailAddress to) { if (Down) throw new TimeoutException("SMTP timeout"); Console.WriteLine($"To {to.Value}: Welcome to Acme! Here's how to get started..."); }}
static class Program{ static void Main() { var mailer = new ConsoleMailer(); var service = new SignupService(new PasswordPolicy(), new InMemoryUsers(), mailer); Console.WriteLine(service.SignUp(" Ada@Example.com ", "correct horse battery").Email.Value); mailer.Down = true; service.SignUp("grace@example.com", "a long enough password"); // succeeds Console.WriteLine("retry later: " + string.Join(", ", service.RetryLater)); }}package main
import ( "errors" "fmt" "regexp" "strings")
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
// EmailAddress is valid by construction: the only way to get one is ParseEmail. Every type// that holds one can trust it, so the "what is a valid email" rule lives here once.type EmailAddress struct{ value string }
func ParseEmail(raw string) (EmailAddress, error) { v := strings.ToLower(strings.TrimSpace(raw)) if !emailRe.MatchString(v) { return EmailAddress{}, errors.New("bad email") } return EmailAddress{v}, nil}
func (e EmailAddress) String() string { return e.value }
// User is the entity the rest of the system cares about. It never sees a plain-text password.type User struct { Email EmailAddress PasswordHash string}
// PasswordPolicy is everything about passwords, owned by security. One method here, but a// breached-password check would add a second with the same owner: SRP counts owners.type PasswordPolicy struct{}
func (PasswordPolicy) HashIfAcceptable(password string) (string, error) { if len(password) < 12 { return "", errors.New("password too short") } // Demo only. Production code calls a vetted library (bcrypt, scrypt or Argon2id). return "demo-hash:" + reverse(password), nil}
// UserRepository is storage, owned by the platform team. Small and declared here, where it// is used, so signup is tested without a database and Postgres is a new type, not an edit.type UserRepository interface { Find(email EmailAddress) (User, bool) Save(user User)}
// WelcomeMailer is the welcome email, owned by marketing. Copy changes happen there only.type WelcomeMailer interface { SendWelcome(to EmailAddress) error}
// SignupService is the facade. Its one responsibility is the signup flow itself: the order// of the steps and what happens when one fails. Product owns that.type SignupService struct { Passwords PasswordPolicy Users UserRepository Mailer WelcomeMailer RetryLater []string // stands in for a retry queue}
func (s *SignupService) SignUp(rawEmail, password string) (User, error) { email, err := ParseEmail(rawEmail) if err != nil { return User{}, err } if _, taken := s.Users.Find(email); taken { return User{}, errors.New("email taken") } hash, err := s.Passwords.HashIfAcceptable(password) if err != nil { return User{}, err } user := User{email, hash} s.Users.Save(user) // A decision about the flow, made in the one type that owns the flow: the account // exists, so a mail failure must not report the signup as failed. if err := s.Mailer.SendWelcome(email); err != nil { s.RetryLater = append(s.RetryLater, email.String()) } return user, nil}
type InMemoryUsers map[EmailAddress]User
func (m InMemoryUsers) Find(e EmailAddress) (User, bool) { u, ok := m[e]; return u, ok }func (m InMemoryUsers) Save(u User) { m[u.Email] = u }
type ConsoleMailer struct{ Down bool }
func (c *ConsoleMailer) SendWelcome(to EmailAddress) error { if c.Down { return errors.New("SMTP timeout") } fmt.Println("To " + to.String() + ": Welcome to Acme! Here's how to get started...") return nil}
func reverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r)}
func main() { mailer := &ConsoleMailer{} s := &SignupService{Users: InMemoryUsers{}, Mailer: mailer} u, _ := s.SignUp(" Ada@Example.com ", "correct horse battery") fmt.Println(u.Email) mailer.Down = true s.SignUp("grace@example.com", "a long enough password") // succeeds fmt.Println("retry later:", s.RetryLater) // [grace@example.com]}const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// A value that is valid by construction: the constructor is private, so parse() is the only// way in. Every class that holds an EmailAddress can trust it, and the rule lives here once.class EmailAddress { private constructor(readonly value: string) {}
static parse(raw: string): EmailAddress { const v = raw.trim().toLowerCase(); if (!EMAIL.test(v)) throw new Error("bad email"); return new EmailAddress(v); }}
// The entity the rest of the system cares about. It never sees a plain-text password.interface User { readonly email: EmailAddress; readonly passwordHash: string;}
// 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 { hashIfAcceptable(password: string): string { if (password.length < 12) throw new Error("password too short"); // Demo only. Production code calls a vetted library (bcrypt, scrypt or Argon2id). return "demo-hash:" + [...password].reverse().join(""); }}
// 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 { find(email: EmailAddress): User | undefined; save(user: User): void;}
// The welcome email, owned by marketing. Copy changes happen here and nowhere else.interface WelcomeMailer { sendWelcome(to: EmailAddress): void;}
// 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 { readonly retryLater: string[] = []; // stands in for a retry queue
constructor( private readonly passwords: PasswordPolicy, private readonly users: UserRepository, private readonly mailer: WelcomeMailer, ) {}
signUp(rawEmail: string, password: string): User { const email = EmailAddress.parse(rawEmail); if (this.users.find(email)) throw new Error("email taken"); const user: User = { email, passwordHash: this.passwords.hashIfAcceptable(password) }; this.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 { this.mailer.sendWelcome(email); } catch { this.retryLater.push(email.value); } return user; }}
class InMemoryUsers implements UserRepository { // Keyed by the string: two EmailAddress objects with the same value are different keys in a Map. private readonly rows = new Map<string, User>(); find(email: EmailAddress) { return this.rows.get(email.value); } save(user: User) { this.rows.set(user.email.value, user); }}
class ConsoleMailer implements WelcomeMailer { down = false; sendWelcome(to: EmailAddress) { if (this.down) throw new Error("SMTP timeout"); console.log(`To ${to.value}: Welcome to Acme! Here's how to get started...`); }}
const mailer = new ConsoleMailer();const service = new SignupService(new PasswordPolicy(), new InMemoryUsers(), mailer);console.log(service.signUp(" Ada@Example.com ", "correct horse battery").email.value);mailer.down = true;service.signUp("grace@example.com", "a long enough password"); // succeedsconsole.log("retry later:", service.retryLater); // [ 'grace@example.com' ]The flow, including the failure that used to be a bug:
- Color key
- Interface
- Implementation
- Facade
What to notice
Section titled “What to notice”- Coordinating is a responsibility.
SignupServicestill 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.
PasswordPolicychecks 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.
EmailAddresscan 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
tryin 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.
UserRepositoryandWelcomeMailerare 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.
Your
SignupServicecalls four other classes. Isn’t it doing too much?
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.
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.
Without the principle
Section titled “Without the principle”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:
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 }}from dataclasses import dataclass
@dataclass(frozen=True)class Line: description: str unit_cents: int qty: int
# 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: def __init__(self, lines: list[Line], discount_pct: int, store_credit_cents: int, tax_bps: int) -> None: self.lines = lines self.discount_pct = discount_pct # a discount the seller gives self.store_credit_cents = store_credit_cents # credit the customer already has self.tax_bps = tax_bps # 800 = 8%
# 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. def _net(self) -> int: subtotal = sum(l.unit_cents * l.qty for l in self.lines) after_discount = subtotal - subtotal * self.discount_pct // 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 after_discount - self.store_credit_cents
# ...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. def tax_cents(self) -> int: return self._net() * self.tax_bps // 10_000
def amount_due_cents(self) -> int: return self._net() + self.tax_cents()
# 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. def render(self) -> str: return f"INVOICE\n tax {self.tax_cents() / 100:8.2f}\n due {self.amount_due_cents() / 100:8.2f}"
if __name__ == "__main__": inv = Invoice([Line("Pro plan", 10_000, 1), Line("Extra seats", 1_500, 4)], 10, 2_000, 800) print(inv.render()) # tax 9.92, due 133.92: tax is $1.60 shortusing System;using System.Collections.Generic;using System.Linq;
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{ public List<Line> Lines { get; } public int DiscountPct { get; } // a discount the seller gives public int StoreCreditCents { get; } // credit the customer already has public int TaxBps { get; } // 800 = 8%
public Invoice(List<Line> lines, int discountPct, int storeCreditCents, int taxBps) { Lines = lines; DiscountPct = discountPct; StoreCreditCents = storeCreditCents; 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() { var subtotal = Lines.Sum(l => l.UnitCents * l.Qty); var 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. public int TaxCents() => Net() * TaxBps / 10_000;
public int AmountDueCents() => 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. public string Render() => $"INVOICE\n tax {TaxCents() / 100.0,8:F2}\n due {AmountDueCents() / 100.0,8:F2}\n";}
static class Program{ static void Main() { var inv = new Invoice( new() { new Line("Pro plan", 10_000, 1), new Line("Extra seats", 1_500, 4) }, 10, 2_000, 800); Console.Write(inv.Render()); // tax 9.92, due 133.92: tax is $1.60 short }}package main
import "fmt"
type Line struct { Description string UnitCents int Qty int}
// Invoice before: it prices itself, taxes itself and draws itself.// Finance, the tax team and the design team all change this one type.type Invoice struct { Lines []Line DiscountPct int // a discount the seller gives StoreCreditCents int // credit the customer already has TaxBps int // 800 = 8%}
// net is 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.func (inv Invoice) net() int { subtotal := 0 for _, l := range inv.Lines { subtotal += l.UnitCents * l.Qty } afterDiscount := subtotal - subtotal*inv.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 - inv.StoreCreditCents}
// TaxCents is 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.func (inv Invoice) TaxCents() int { return inv.net() * inv.TaxBps / 10_000 }
func (inv Invoice) AmountDueCents() int { return inv.net() + inv.TaxCents() }
// Render is the design team's code, living here too, so a new logo is a change to the type// that computes tax, and goes through the same review and release.func (inv Invoice) Render() string { return fmt.Sprintf("INVOICE\n tax %8.2f\n due %8.2f\n", float64(inv.TaxCents())/100, float64(inv.AmountDueCents())/100)}
func main() { inv := Invoice{ Lines: []Line{{"Pro plan", 10_000, 1}, {"Extra seats", 1_500, 4}}, DiscountPct: 10, StoreCreditCents: 2_000, TaxBps: 800, } fmt.Print(inv.Render()) // tax 9.92, due 133.92: tax is $1.60 short}interface Line { readonly description: string; readonly unitCents: number; readonly qty: number;}
// 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 { constructor( readonly lines: readonly Line[], readonly discountPct: number, // a discount the seller gives readonly storeCreditCents: number, // credit the customer already has readonly taxBps: number, // 800 = 8% ) {}
// 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 net(): number { const subtotal = this.lines.reduce((s, l) => s + l.unitCents * l.qty, 0); const afterDiscount = subtotal - Math.floor((subtotal * this.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 - this.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. taxCents(): number { return Math.floor((this.net() * this.taxBps) / 10_000); }
amountDueCents(): number { return this.net() + this.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. render(): string { const f = (c: number) => (c / 100).toFixed(2).padStart(8); return `INVOICE\n tax ${f(this.taxCents())}\n due ${f(this.amountDueCents())}`; }}
const inv = new Invoice( [ { description: "Pro plan", unitCents: 10_000, qty: 1 }, { description: "Extra seats", unitCents: 1_500, qty: 4 }, ], 10, 2_000, 800,);console.log(inv.render()); // tax 9.92, due 133.92: tax is $1.60 shortFinance’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.
With SRP
Section titled “With SRP”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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 }}from dataclasses import dataclass
# 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.@dataclass(frozen=True)class Line: description: str unit_cents: int qty: int
@property def subtotal_cents(self) -> int: return self.unit_cents * self.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.@dataclass(frozen=True)class Invoice: lines: tuple[Line, ...] discount_pct: int store_credit_cents: int tax_bps: int # 800 = 8%
@property def subtotal_cents(self) -> int: return sum(l.subtotal_cents for l in self.lines)
# 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.@dataclass(frozen=True)class Breakdown: subtotal: int discount: int tax: int credit: int due: int
# 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: def taxable_cents(self, inv: Invoice) -> int: subtotal = inv.subtotal_cents # 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.discount_pct // 100
def tax_cents(self, inv: Invoice) -> int: return self.taxable_cents(inv) * inv.tax_bps // 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: def price(self, inv: Invoice, tax_cents: int) -> Breakdown: subtotal = inv.subtotal_cents discount = subtotal * inv.discount_pct // 100 before_credit = subtotal - discount + tax_cents # Credit can't take the amount below zero; leftover credit stays on the account. credit = min(inv.store_credit_cents, before_credit) return Breakdown(subtotal, discount, tax_cents, credit, before_credit - credit)
# Owned by the design team. It only reads a Breakdown, so a new layout can't change a total.class InvoiceRenderer: def render(self, b: Breakdown) -> str: rows = [("subtotal", b.subtotal), ("discount", -b.discount), ("tax", b.tax), ("credit", -b.credit), ("due", b.due)] return "INVOICE\n" + "\n".join(f" {name:<8} {cents / 100:8.2f}" for name, cents in rows)
# 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: def __init__(self) -> None: self._tax = TaxCalculator() self._pricing = Pricing() self._renderer = InvoiceRenderer()
def totals(self, inv: Invoice) -> Breakdown: return self._pricing.price(inv, self._tax.tax_cents(inv))
def issue(self, inv: Invoice) -> str: return self._renderer.render(self.totals(inv))
if __name__ == "__main__": inv = Invoice((Line("Pro plan", 10_000, 1), Line("Extra seats", 1_500, 4)), 10, 2_000, 800) print(Billing().issue(inv)) # tax 11.52, due 135.52using System;using System.Collections.Generic;using System.Linq;
// 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){ public int SubtotalCents => 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(IReadOnlyList<Line> Lines, int DiscountPct, int StoreCreditCents, int TaxBps){ public int SubtotalCents => Lines.Sum(l => l.SubtotalCents);}
// 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{ public int TaxableCents(Invoice inv) { var 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; }
public int TaxCents(Invoice inv) => 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{ public Breakdown Price(Invoice inv, int taxCents) { var subtotal = inv.SubtotalCents; var discount = subtotal * inv.DiscountPct / 100; var beforeCredit = subtotal - discount + taxCents; // Credit can't take the amount below zero; leftover credit stays on the account. var 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{ public string Render(Breakdown b) => "INVOICE\n" + $" subtotal {b.Subtotal / 100.0,8:F2}\n" + $" discount {-b.Discount / 100.0,8:F2}\n" + $" tax {b.Tax / 100.0,8:F2}\n" + $" credit {-b.Credit / 100.0,8:F2}\n" + $" due {b.Due / 100.0,8:F2}\n";}
// 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 readonly TaxCalculator _tax = new(); private readonly Pricing _pricing = new(); private readonly InvoiceRenderer _renderer = new();
public Breakdown Totals(Invoice inv) => _pricing.Price(inv, _tax.TaxCents(inv));
public string Issue(Invoice inv) => _renderer.Render(Totals(inv));}
static class Program{ static void Main() { var inv = new Invoice( new[] { new Line("Pro plan", 10_000, 1), new Line("Extra seats", 1_500, 4) }, 10, 2_000, 800); Console.Write(new Billing().Issue(inv)); // tax 11.52, due 135.52 }}package main
import "fmt"
// Line holds 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.type Line struct { Description string UnitCents int Qty int}
func (l Line) SubtotalCents() int { return l.UnitCents * l.Qty }
// 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.type Invoice struct { Lines []Line DiscountPct int StoreCreditCents int TaxBps int // 800 = 8%}
func (inv Invoice) SubtotalCents() int { sum := 0 for _, l := range inv.Lines { sum += l.SubtotalCents() } return sum}
// Breakdown is 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.type Breakdown struct{ Subtotal, Discount, Tax, Credit, Due int }
// TaxCalculator is 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.type TaxCalculator struct{}
func (TaxCalculator) TaxableCents(inv Invoice) 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}
func (t TaxCalculator) TaxCents(inv Invoice) int { return t.TaxableCents(inv) * inv.TaxBps / 10_000 }
// Pricing is owned by finance: what the customer pays today. Finance's "apply store// credit" request is a change to this type and nothing else.type Pricing struct{}
func (Pricing) Price(inv Invoice, taxCents int) Breakdown { subtotal := inv.SubtotalCents() discount := subtotal * inv.DiscountPct / 100 beforeCredit := subtotal - discount + taxCents // Credit can't take the amount below zero; leftover credit stays on the account. credit := min(inv.StoreCreditCents, beforeCredit) return Breakdown{subtotal, discount, taxCents, credit, beforeCredit - credit}}
// RenderInvoice is owned by the design team. It only reads a Breakdown, so a new layout// can't change a total.func RenderInvoice(b Breakdown) string { f := func(c int) float64 { return float64(c) / 100 } return fmt.Sprintf("INVOICE\n subtotal %8.2f\n discount %8.2f\n tax %8.2f\n credit %8.2f\n due %8.2f\n", f(b.Subtotal), f(-b.Discount), f(b.Tax), f(-b.Credit), f(b.Due))}
// Billing is the facade callers use. Its job is the order of the steps (tax first, because// the price depends on it), so callers never wire the parts together in the wrong order.type Billing struct { Tax TaxCalculator Pricing Pricing}
func (b Billing) Totals(inv Invoice) Breakdown { return b.Pricing.Price(inv, b.Tax.TaxCents(inv))}
func (b Billing) Issue(inv Invoice) string { return RenderInvoice(b.Totals(inv)) }
func main() { inv := Invoice{ Lines: []Line{{"Pro plan", 10_000, 1}, {"Extra seats", 1_500, 4}}, DiscountPct: 10, StoreCreditCents: 2_000, TaxBps: 800, } fmt.Print(Billing{}.Issue(inv)) // tax 11.52, due 135.52}// The one calculation every team truly shares, by definition: a line costs price times// quantity. A plain function next to the type, so there is nothing to disagree about.interface Line { readonly description: string; readonly unitCents: number; readonly qty: number;}const lineSubtotal = (l: Line) => l.unitCents * l.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.interface Invoice { readonly lines: readonly Line[]; readonly discountPct: number; readonly storeCreditCents: number; readonly taxBps: number; // 800 = 8%}const invoiceSubtotal = (inv: Invoice) => inv.lines.reduce((s, l) => s + lineSubtotal(l), 0);
// 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.interface Breakdown { readonly subtotal: number; readonly discount: number; readonly tax: number; readonly credit: number; readonly due: number;}
// 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 { taxableCents(inv: Invoice): number { const subtotal = invoiceSubtotal(inv); // 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 - Math.floor((subtotal * inv.discountPct) / 100); }
taxCents(inv: Invoice): number { return Math.floor((this.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 { price(inv: Invoice, taxCents: number): Breakdown { const subtotal = invoiceSubtotal(inv); const discount = Math.floor((subtotal * inv.discountPct) / 100); const beforeCredit = subtotal - discount + taxCents; // Credit can't take the amount below zero; leftover credit stays on the account. const credit = Math.min(inv.storeCreditCents, beforeCredit); return { subtotal, discount, tax: taxCents, credit, due: beforeCredit - credit }; }}
// Owned by the design team. It only reads a Breakdown, so a new layout can't change a total.class InvoiceRenderer { render(b: Breakdown): string { const f = (c: number) => (c / 100).toFixed(2).padStart(8); return [ "INVOICE", ` subtotal ${f(b.subtotal)}`, ` discount ${f(-b.discount)}`, ` tax ${f(b.tax)}`, ` credit ${f(-b.credit)}`, ` due ${f(b.due)}`, ].join("\n"); }}
// 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 readonly tax = new TaxCalculator(); private readonly pricing = new Pricing(); private readonly renderer = new InvoiceRenderer();
totals(inv: Invoice): Breakdown { return this.pricing.price(inv, this.tax.taxCents(inv)); }
issue(inv: Invoice): string { return this.renderer.render(this.totals(inv)); }}
const inv: Invoice = { lines: [ { description: "Pro plan", unitCents: 10_000, qty: 1 }, { description: "Extra seats", unitCents: 1_500, qty: 4 }, ], discountPct: 10, storeCreditCents: 2_000, taxBps: 800,};console.log(new Billing().issue(inv)); // tax 11.52, due 135.52Send each team’s request through both designs. In the first, every change is re-checked by every team:
- Invoiceprices, taxes and draws itself
- 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.
What to notice
Section titled “What to notice”- Duplication across actors can be correct.
TaxCalculatorandPricingboth 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 onLine. - Compute once, read everywhere.
Breakdownholds 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.
Billingis the one place that knows this, so callers can’t get it wrong. - Data has no owner’s rules.
Invoiceholds 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.
TaxCalculatorandPricingboth compute the discounted subtotal. Shouldn’t you pull that into one helper?
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- SOLID principles overview, for how SRP fits with the other four
- Open/closed principle, which builds on the seams SRP creates
- Dependency inversion, for the interfaces in the signup example
- Object modeling, for finding the classes in the first place
- Robert C. Martin, Clean Architecture (2017), chapter 7, “SRP: The Single Responsibility
Principle”, which restates the principle in terms of actors and walks through the
Employeeexample - Robert C. Martin, The Single Responsibility Principle (2014)
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.