S and O: one reason to change, new code instead of edits
Think of a restaurant. The chef cooks, the waiter takes orders, the cashier handles payments. When the menu changes, only the chef’s work changes; when the card machine is replaced, only the cashier’s. If one person did all three jobs, every change would land on them, and a new card machine could somehow ruin dinner.
The single responsibility principleSingle responsibility principleThe S in SOLID: a class should have one reason to change, meaning one group of people whose requests change it.Open in glossary → says the same about code: a class should have one reason to change. Not “one method”, but one job, owned by one group of people who might ask you to change it.
SRP is about reasons to change, which in practice means stakeholders (Robert C. Martin later phrased it as “one actor”). Code that changes for different people at different rates should live in different modules, so a tax change can’t break email wording. It’s cohesion and coupling with a practical test: list who would ask for a change to this class.
OCP, the open/closed principleOpen/closed principleCode should be open for extension but closed for modification: add new behavior without editing what already works.Open in glossary →: extend behavior by adding code, not by editing working code. You can’t be closed against every change; you choose the axis of expected variation (discount rules, payment providers) and put an abstraction there. Guessing the axis wrong produces abstractions nobody uses.
Single responsibility
Before: One class, four reasons to change
final class OrderService {
func placeOrder(_ order: Order) async throws {
guard !order.items.isEmpty else { throw OrderError.empty } // business rules
let total = order.items.reduce(0) { $0 + $1.priceCents * $1.quantity } // pricing
let db = try await Database.connect("postgres://prod/shop") // storage
try await db.insert("orders", id: order.id, totalCents: total)
try await SMTPClient(host: "smtp.shop.com") // email
.send(to: order.email, body: "Thanks! Your total is \(total) cents.")
}
}class OrderService {
fun placeOrder(order: Order) {
require(order.items.isNotEmpty()) { "Empty order" } // business rules
val total = order.items.sumOf { it.priceCents * it.quantity } // pricing
val db = Database.connect("jdbc:postgresql://prod/shop") // storage
db.insert("orders", order.id, total)
SmtpClient("smtp.shop.com") // email
.send(order.email, "Thanks! Your total is $total cents.")
}
}class OrderService {
void placeOrder(Order order) {
if (order.items().isEmpty()) throw new IllegalArgumentException("Empty order"); // business rules
int total = order.items().stream() // pricing
.mapToInt(i -> i.priceCents() * i.quantity()).sum();
var db = Database.connect("jdbc:postgresql://prod/shop"); // storage
db.insert("orders", order.id(), total);
new SmtpClient("smtp.shop.com") // email
.send(order.email(), "Thanks! Your total is " + total + " cents.");
}
}class OrderService {
async placeOrder(order: Order): Promise<void> {
if (order.items.length === 0) throw new Error("Empty order"); // business rules
const total = order.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0); // pricing
const db = await Database.connect("postgres://prod/shop"); // storage
await db.insert("orders", order.id, total);
await new SmtpClient("smtp.shop.com") // email
.send(order.email, `Thanks! Your total is ${total} cents.`);
}
}After: One job each; OrderService only coordinates
final class OrderService {
private let pricing: PricingPolicy
private let orders: OrderRepository
private let notifier: OrderNotifier
init(pricing: PricingPolicy, orders: OrderRepository, notifier: OrderNotifier) {
self.pricing = pricing
self.orders = orders
self.notifier = notifier
}
func placeOrder(_ order: Order) async throws {
guard !order.items.isEmpty else { throw OrderError.empty }
let total = pricing.totalCents(for: order)
try await orders.save(order, totalCents: total)
try await notifier.orderPlaced(order, totalCents: total)
}
}class OrderService(
private val pricing: PricingPolicy,
private val orders: OrderRepository,
private val notifier: OrderNotifier,
) {
fun placeOrder(order: Order) {
require(order.items.isNotEmpty()) { "Empty order" }
val total = pricing.totalCents(order)
orders.save(order, total)
notifier.orderPlaced(order, total)
}
}class OrderService {
private final PricingPolicy pricing;
private final OrderRepository orders;
private final OrderNotifier notifier;
OrderService(PricingPolicy pricing, OrderRepository orders, OrderNotifier notifier) {
this.pricing = pricing;
this.orders = orders;
this.notifier = notifier;
}
void placeOrder(Order order) {
if (order.items().isEmpty()) throw new IllegalArgumentException("Empty order");
int total = pricing.totalCents(order);
orders.save(order, total);
notifier.orderPlaced(order, total);
}
}class OrderService {
constructor(
private readonly pricing: PricingPolicy,
private readonly orders: OrderRepository,
private readonly notifier: OrderNotifier,
) {}
async placeOrder(order: Order): Promise<void> {
if (order.items.length === 0) throw new Error("Empty order");
const total = this.pricing.totalCents(order);
await this.orders.save(order, total);
await this.notifier.orderPlaced(order, total);
}
}Ask of the first version: who could ask me to change this class? Product managers (the rules), finance (the pricing), the database team (storage), and marketing (the email). Four groups, four reasons to change, and each change risks breaking the other three.
After the split, a new email template touches only OrderNotifier. And because OrderService receives its helpers instead of creating them, a test can give it fakes and check the order logic without a database or an email server.
Open/closed
The next request: discounts. First 5 EUR off orders with three or more items, then a student discount, then a weekend sale. Instead of growing an if chain inside PricingPolicy, make each discount a rule and let the policy apply whatever rules it’s given:
protocol DiscountRule {
func discountCents(for order: Order) -> Int
}
struct BulkDiscount: DiscountRule {
func discountCents(for order: Order) -> Int { order.items.count >= 3 ? 500 : 0 }
}
struct PricingPolicy {
let rules: [any DiscountRule]
func totalCents(for order: Order) -> Int {
let subtotal = order.items.reduce(0) { $0 + $1.priceCents * $1.quantity }
let discount = rules.reduce(0) { $0 + $1.discountCents(for: order) }
return max(0, subtotal - discount)
}
}fun interface DiscountRule {
fun discountCents(order: Order): Int
}
val bulkDiscount = DiscountRule { order -> if (order.items.size >= 3) 500 else 0 }
class PricingPolicy(private val rules: List<DiscountRule>) {
fun totalCents(order: Order): Int {
val subtotal = order.items.sumOf { it.priceCents * it.quantity }
val discount = rules.sumOf { it.discountCents(order) }
return maxOf(0, subtotal - discount)
}
}@FunctionalInterface
interface DiscountRule {
int discountCents(Order order);
}
DiscountRule bulkDiscount = order -> order.items().size() >= 3 ? 500 : 0;
class PricingPolicy {
private final List<DiscountRule> rules;
PricingPolicy(List<DiscountRule> rules) {
this.rules = List.copyOf(rules);
}
int totalCents(Order order) {
int subtotal = order.items().stream().mapToInt(i -> i.priceCents() * i.quantity()).sum();
int discount = rules.stream().mapToInt(r -> r.discountCents(order)).sum();
return Math.max(0, subtotal - discount);
}
}type DiscountRule = (order: Order) => number;
const bulkDiscount: DiscountRule = (order) => (order.items.length >= 3 ? 500 : 0);
class PricingPolicy {
constructor(private readonly rules: readonly DiscountRule[]) {}
totalCents(order: Order): number {
const subtotal = order.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
const discount = this.rules.reduce((sum, rule) => sum + rule(order), 0);
return Math.max(0, subtotal - discount);
}
}The student discount is now a new rule added to the list, and PricingPolicy, which already works and is tested, doesn’t change. That’s what “open for extension, closed for modification” means. Swapping one algorithm for another like this has its own pattern, Strategy.
Don’t split too far
SRP doesn’t mean “one method per class”. A PricingPolicy with subtotal, discount and total methods has one reason to change (the pricing rules), so it stays one class. Splitting it into SubtotalCalculator, DiscountCalculator and TotalCalculator only means three files to open for every pricing change.
A good test: if two pieces of code always change together, they belong together. If they change for different people, at different times, separate them.
Where SRP and OCP go wrong
- SRP taken to the method level produces anemic, scattered code: a dozen one-method classes whose behavior you can only follow with a debugger. Cohesion matters as much as separation; a class that does one job may well have ten methods.
- OCP everywhere is speculative generality. A
when/switchover a closed set you own (three order states) is fine, and a sealed type makes the compiler list every place to update. Introduce the extension point the second or third time the same axis changes, not on day one. - The expression problem decides the shape: interfaces make new variants cheap and new operations expensive; sealed types with exhaustive
switchare the opposite. Pick by which one you expect to grow.
Deep diveWhere SOLID came fromInterview favorite
Robert C. Martin collected the principles in essays around 2000, and Michael Feathers coined the acronym. The open/closed principle is older: Bertrand Meyer described it in 1988, originally in terms of inheritance (extend a class without editing it). The modern reading, through interfaces and composition, is Martin’s. Liskov’s principle comes from Barbara Liskov’s 1987 keynote on data abstraction, formalized with Jeannette Wing in 1994.
Interviewers rarely want the history. They want an example of each principle from code you’ve written, and a case where applying one would have made things worse.
- SRP: one reason to change = one stakeholder. Split code that changes for different people at different rates; keep together what always changes together.
- OCP: choose the axis of expected variation and put an abstraction there (a rule list, a strategy, a plugin). Don’t abstract axes that never changed.
- Interfaces make new variants cheap; sealed types + exhaustive
switchmake new operations cheap and variant edits compiler-checked.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Lightweight extension point | Closure (Order) -> Int or protocol | fun interface / lambda | @FunctionalInterface + lambda | Function type |
| Closed set, checked by compiler | enum + switch | sealed interface + when | sealed interface + switch (21+) | Discriminated union + never check |
struct PricingPolicy {
let rules: [(Order) -> Int]
func totalCents(_ o: Order) -> Int { max(0, o.subtotalCents - rules.reduce(0) { $0 + $1(o) }) }
}class PricingPolicy(private val rules: List<(Order) -> Int>) {
fun totalCents(o: Order) = maxOf(0, o.subtotalCents - rules.sumOf { it(o) })
}record PricingPolicy(List<ToIntFunction<Order>> rules) {
int totalCents(Order o) { return Math.max(0, o.subtotalCents() - rules.stream().mapToInt(r -> r.applyAsInt(o)).sum()); }
}const totalCents = (rules: ((o: Order) => number)[]) => (o: Order) =>
Math.max(0, o.subtotalCents - rules.reduce((s, r) => s + r(o), 0));Check this part
What does "a class should have one reason to change" mean?
Marketing adds a student discount. With discount rules, what changes?
An order has three fixed states that you own and rarely change. Should you replace the
switchwith a State interface for OCP?What is the practical test for SRP?