The Intro
Part two of our SOLID road trip. Last time we took a class with three jobs and gave it two coworkers. Today we are going after a different kind of monster: the if/else chain that grows a new branch every sprint.
Letter O: the Open/Closed Principle. Sounds like a sign on a shop door. It kind of is.
The What
Textbook time:
Software entities (classes, modules, functions) should be open for extension, but closed for modification.
Open and closed at the same time? Thanks, Schrödinger.
Here's the plain-English version that made it click for me: you should be able to add a new behavior without editing the code that already works. Add a file, not an else.
To feel it, we are going to build a discount calculator for an online store. Business starts simple:
- Students get 10% off.
- That's it. One rule. Life is good.
The How
Version one, straight from the heart:
// src/discount.ts
export interface Customer {
type: 'regular' | 'student'
}
export function discountFor(customer: Customer, subtotal: number): number {
if (customer.type === 'student') {
return subtotal * 0.1
}
return 0
}
// src/main.ts
import { discountFor } from './discount.js'
console.log(discountFor({ type: 'student' }, 100)) // 10
console.log(discountFor({ type: 'regular' }, 100)) // 0
Clean. Tiny. Everyone claps. 👏
Then marketing shows up. Every. Single. Week.
- Week 2: "VIP members get 20%."
- Week 3: "Orders over $200 get 5% extra, but only for regulars."
- Week 4: "Black Friday! 30% for everyone, but just on the 28th."
- Week 5: "Can students AND VIPs stack? No wait, only the biggest one. Actually, let's talk."
Watch what happens to our innocent function:
// src/discount.ts
export interface Customer {
type: 'regular' | 'student' | 'vip'
}
export function discountFor(customer: Customer, subtotal: number, today = new Date()): number {
let discount = 0
if (customer.type === 'student') {
discount = subtotal * 0.1
} else if (customer.type === 'vip') {
discount = subtotal * 0.2
} else if (subtotal > 200) {
discount = subtotal * 0.05
}
const isBlackFriday = today.getMonth() === 10 && today.getDate() === 28
if (isBlackFriday) {
discount = Math.max(discount, subtotal * 0.3)
}
return discount
}
Every new rule means:
- Re-opening a function that was already working.
- Re-reading the whole thing to make sure the new branch doesn't fight with the old ones.
- Re-running every existing test because, let's be honest, you touched everything.
And the function knows about dates now. Why does a discount calculator know what day it is? 🤨
This is a function that is open for modification. Every change goes inside it. The principle says: let's flip that.
Step 1: describe what a rule looks like
All these rules have the same shape: "given a customer and a subtotal, how much do I knock off?". Let's write that down:
// src/discount-rule.ts
import type { Customer } from './customer.js'
export interface DiscountRule {
apply(customer: Customer, subtotal: number): number
}
Step 2: one small class per rule
// src/rules/student-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'
export class StudentDiscount implements DiscountRule {
apply(customer: Customer, subtotal: number): number {
return customer.type === 'student' ? subtotal * 0.1 : 0
}
}
// src/rules/vip-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'
export class VipDiscount implements DiscountRule {
apply(customer: Customer, subtotal: number): number {
return customer.type === 'vip' ? subtotal * 0.2 : 0
}
}
// src/rules/big-order-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'
export class BigOrderDiscount implements DiscountRule {
apply(customer: Customer, subtotal: number): number {
return customer.type === 'regular' && subtotal > 200 ? subtotal * 0.05 : 0
}
}
Each file is boring on purpose. You can read one in five seconds and know exactly what it does.
Step 3: the calculator stops caring about rules
// src/discount-calculator.ts
import type { DiscountRule } from './discount-rule.js'
import type { Customer } from './customer.js'
export class DiscountCalculator {
constructor(private readonly rules: DiscountRule[]) {}
discountFor(customer: Customer, subtotal: number): number {
const discounts = this.rules.map((rule) => rule.apply(customer, subtotal))
return Math.max(0, ...discounts)
}
}
Look at that. No if about students. No if about VIPs. It just asks every rule "what do you think?" and keeps the best answer. The business rule "customers get the biggest discount, no stacking" lives in one line.
Step 4: assemble at the front door
// src/main.ts
import { DiscountCalculator } from './discount-calculator.js'
import { StudentDiscount } from './rules/student-discount.js'
import { VipDiscount } from './rules/vip-discount.js'
import { BigOrderDiscount } from './rules/big-order-discount.js'
const calculator = new DiscountCalculator([
new StudentDiscount(),
new VipDiscount(),
new BigOrderDiscount(),
])
console.log(calculator.discountFor({ type: 'student' }, 100)) // 10
console.log(calculator.discountFor({ type: 'regular' }, 250)) // 12.5
Step 5: the Black Friday test
Here comes the moment of truth. Marketing wants Black Friday. What do we touch?
// src/rules/black-friday-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'
export class BlackFridayDiscount implements DiscountRule {
constructor(private readonly today: () => Date = () => new Date()) {}
apply(_customer: Customer, subtotal: number): number {
const date = this.today()
const isBlackFriday = date.getMonth() === 10 && date.getDate() === 28
return isBlackFriday ? subtotal * 0.3 : 0
}
}
And one line in main.ts:
new BlackFridayDiscount(),
That's the entire change. DiscountCalculator was not opened. StudentDiscount was not opened. Their tests didn't even notice something happened.
Closed for modification, open for extension. We extended the system by adding a file.
The Why
What did we actually gain?
- New rules don't risk old rules. The student discount literally cannot break because you added Black Friday. They live in different files.
-
Each rule is testable alone.
new StudentDiscount().apply({ type: 'student' }, 100)→10. Done. No giant table of cases for one mega-function. -
The date thing got cleaner by accident. Only
BlackFridayDiscountcares about dates, and it takes atoday()function so you can test it without changing your system clock. -
Deleting a rule is a delete, not a surgery. Marketing cancels the VIP program? Remove one line from
main.ts, delete one file. Noelsebranches to re-balance.
Now, the honest caveat: you don't do this on day one with a single rule. The first version with one if was fine. The signal to refactor is the second time you open the same function to add a sibling branch. Once you see the pattern, pull the rules out before week five arrives.
The Goodbye
Two letters down, three to go. Next up is the L, Liskov Substitution, where we find out why a subclass that throws "not supported" is a trap, and what to do instead.
Go count the else if branches in your biggest function. If you need two hands, you know what to do. Happy coding 🤝
Top comments (0)