Design Patterns, Explained Simply · Foundations: Thinking in Patterns
SOLID, Part 1: Single Responsibility and Open/Closed
The S and O of SOLID: give each class one reason to change, and add new behaviour by adding code rather than editing working code.
The problem
An Invoice class calculates totals, applies discounts, formats a PDF, and emails the customer. One month finance changes the tax rule, design changes the PDF layout, and marketing adds a "loyalty" discount. Three teams edit the same file, and a change to the email text breaks the tax calculation tests.
The discount logic is an if/else chain inside total(). Every new promotion means editing it, so every release risks breaking the promotions that already work.
Real-world analogy
A restaurant has a chef, a cashier and a waiter. If the menu changes, only the chef retrains (single responsibility). When the restaurant adds a new card machine, nobody rebuilds the counter: the counter has a standard socket, and the new machine plugs into it (open for extension, closed for modification).
The idea
SOLID is five design principles collected by Robert C. Martin. The first two:
- S: Single Responsibility Principle (SRP). A class should have one reason to change, meaning it answers to one group of people (finance, design, marketing). Split code that changes for different reasons.
- O: Open/Closed Principle (OCP). Code should be open for extension, closed for modification: you add behaviour by adding new code (a new class or function) instead of editing code that already works and is tested.
SRP is about who asks for changes. OCP is usually achieved with an interface plus a list of implementations: exactly the shape of the Strategy pattern.
The code
The invoice is split into a calculator (finance's rules), a formatter (design's layout), and pluggable discount rules (marketing's promotions). Adding the loyalty discount is a new class; nothing existing is edited.
class TenPercentOver100:
def discount(self, subtotal):
return subtotal // 10 if subtotal > 10000 else 0
class LoyaltyMember: # added later: a new class, no edits elsewhere
def __init__(self, is_member):
self.is_member = is_member
def discount(self, subtotal):
return 500 if self.is_member else 0
class InvoiceCalculator: # finance's reason to change
def __init__(self, rules):
self.rules = rules
def total(self, items):
subtotal = sum(price * qty for price, qty in items)
return subtotal - sum(rule.discount(subtotal) for rule in self.rules)
class InvoiceFormatter: # design's reason to change
def render(self, total):
return f"TOTAL: {total // 100}.{total % 100:02d} USD"
items = [(4500, 2), (2500, 1)] # prices in cents
calculator = InvoiceCalculator([TenPercentOver100(), LoyaltyMember(True)])
print(InvoiceFormatter().render(calculator.total(items)))
print(InvoiceFormatter().render(InvoiceCalculator([]).total(items)))
Output
TOTAL: 98.50 USD TOTAL: 115.00 USD
interface DiscountRule {
discount(subtotal: number): number;
}
class TenPercentOver100 implements DiscountRule {
discount(subtotal: number) { return subtotal > 10000 ? Math.floor(subtotal / 10) : 0; }
}
class LoyaltyMember implements DiscountRule { // added later: a new class, no edits elsewhere
constructor(private isMember: boolean) {}
discount(_subtotal: number) { return this.isMember ? 500 : 0; }
}
class InvoiceCalculator { // finance's reason to change
constructor(private rules: DiscountRule[]) {}
total(items: [number, number][]): number {
const subtotal = items.reduce((sum, [price, qty]) => sum + price * qty, 0);
return subtotal - this.rules.reduce((sum, rule) => sum + rule.discount(subtotal), 0);
}
}
class InvoiceFormatter { // design's reason to change
render(total: number): string {
return `TOTAL: ${Math.floor(total / 100)}.${String(total % 100).padStart(2, "0")} USD`;
}
}
const items: [number, number][] = [[4500, 2], [2500, 1]]; // prices in cents
const calculator = new InvoiceCalculator([new TenPercentOver100(), new LoyaltyMember(true)]);
console.log(new InvoiceFormatter().render(calculator.total(items)));
console.log(new InvoiceFormatter().render(new InvoiceCalculator([]).total(items)));
Output
TOTAL: 98.50 USD TOTAL: 115.00 USD
import java.util.List;
public class Main {
public static void main(String[] args) {
List<int[]> items = List.of(new int[]{4500, 2}, new int[]{2500, 1}); // prices in cents
var calculator = new InvoiceCalculator(List.of(new TenPercentOver100(), new LoyaltyMember(true)));
System.out.println(new InvoiceFormatter().render(calculator.total(items)));
System.out.println(new InvoiceFormatter().render(new InvoiceCalculator(List.of()).total(items)));
}
}
interface DiscountRule {
int discount(int subtotal);
}
class TenPercentOver100 implements DiscountRule {
public int discount(int subtotal) { return subtotal > 10000 ? subtotal / 10 : 0; }
}
class LoyaltyMember implements DiscountRule { // added later: a new class, no edits elsewhere
private final boolean isMember;
LoyaltyMember(boolean isMember) { this.isMember = isMember; }
public int discount(int subtotal) { return isMember ? 500 : 0; }
}
class InvoiceCalculator { // finance's reason to change
private final List<DiscountRule> rules;
InvoiceCalculator(List<DiscountRule> rules) { this.rules = rules; }
int total(List<int[]> items) {
int subtotal = items.stream().mapToInt(i -> i[0] * i[1]).sum();
return subtotal - rules.stream().mapToInt(r -> r.discount(subtotal)).sum();
}
}
class InvoiceFormatter { // design's reason to change
String render(int total) {
return String.format("TOTAL: %d.%02d USD", total / 100, total % 100);
}
}
Output
TOTAL: 98.50 USD TOTAL: 115.00 USD
Run this on your computer — see “Get Started” for setup.
<?php
interface DiscountRule {
public function discount(int $subtotal): int;
}
class TenPercentOver100 implements DiscountRule {
public function discount(int $subtotal): int { return $subtotal > 10000 ? intdiv($subtotal, 10) : 0; }
}
class LoyaltyMember implements DiscountRule { // added later: a new class, no edits elsewhere
public function __construct(private bool $isMember) {}
public function discount(int $subtotal): int { return $this->isMember ? 500 : 0; }
}
class InvoiceCalculator { // finance's reason to change
/** @param DiscountRule[] $rules */
public function __construct(private array $rules) {}
public function total(array $items): int {
$subtotal = array_sum(array_map(fn($i) => $i[0] * $i[1], $items));
return $subtotal - array_sum(array_map(fn($r) => $r->discount($subtotal), $this->rules));
}
}
class InvoiceFormatter { // design's reason to change
public function render(int $total): string {
return sprintf("TOTAL: %d.%02d USD", intdiv($total, 100), $total % 100);
}
}
$items = [[4500, 2], [2500, 1]]; // prices in cents
$calculator = new InvoiceCalculator([new TenPercentOver100(), new LoyaltyMember(true)]);
echo (new InvoiceFormatter())->render($calculator->total($items)), "\n";
echo (new InvoiceFormatter())->render((new InvoiceCalculator([]))->total($items)), "\n";
Output
TOTAL: 98.50 USD TOTAL: 115.00 USD
The subtotal is 115.00. The 10% rule takes 11.50, loyalty takes 5.00, so the total is 98.50. A tax-rule change touches only InvoiceCalculator; a layout change touches only InvoiceFormatter; a new promotion is a new class.
Common mistakes
- Reading SRP as "a class does one thing". It is about one reason to change. A class with five small methods that all serve finance's rules is fine.
- Splitting too early. Ten one-method classes for a 50-line script is harder to read than one class. Split when two kinds of change actually collide.
- Making everything "open" up front. You can't predict every extension point. Add one when the second variation appears (the "rule of three" is a good guide).
Key points
- SRP: one reason to change per class, meaning one group of people whose requests change it.
- OCP: add behaviour by adding code, not by editing working code.
- OCP is usually implemented with an interface and a list of implementations (Strategy).
- Apply both when changes actually collide, not speculatively.
- Further reading: SOLID and The Single Responsibility Principle by Robert C. Martin.
Practice questions
What does "one reason to change" mean in the Single Responsibility Principle?
Answer
A class should answer to one source of change, typically one group of people or one concern (finance's tax rules, design's layout). If two different groups would ask for edits to the same class, it has two responsibilities and should be split.
How do you usually make code "open for extension, closed for modification"?
Answer
Introduce an abstraction (an interface, a function type, or a plugin list) at the point that varies. New behaviour becomes a new implementation that you register, and the code that uses the abstraction doesn't change. Strategy, Decorator and Observer are common ways to get there.
Can applying SRP and OCP too aggressively hurt a codebase?
Answer
Yes. Splitting every class and adding extension points nobody uses creates many tiny files and indirection that make the code harder to follow. Apply them when changes actually collide or a second variation appears.
Open in the app to mark lessons complete, take notes and track your progress.