Open closed principle
In January the delivery fee function was 12 lines: a base fee plus a charge per
kilometre. By October it was 190. Rain surcharge, late night surcharge, Diwali week,
free delivery for members, a small order penalty, and a comment that said
// do not touch, ask Karthik. Karthik left in August.
Every new rule was added by editing the same function. So every new rule was a chance to break the nine before it, and the only test was production.
A delivery fee depends on distance, rain, and membership, and marketing adds a new rule about once a month. How would you structure the code so that next month's rule cannot break this month's?
The idea
Bertrand Meyer wrote it in 1988: software should be open for extension, closed for modification. You should be able to make it do something new without editing what is already there and working.
You buy an air cooler in April. You plug it into the board. You do not open the wall and rewire the house, and the fridge that was already plugged in is in no danger from your new purchase. The board was built with empty sockets on purpose.
Before: every rule edits the same function
type Order = {
subtotal: number
distanceKm: number
isRaining: boolean
isMember: boolean
}
function deliveryFee(order: Order): number {
let fee = 20 + order.distanceKm * 8
if (order.isRaining) fee += 25
if (order.isMember) fee = 0
// next month: late night, festival week, small orders...
return fee
}
const order: Order = {
subtotal: 240,
distanceKm: 3,
isRaining: true,
isMember: false,
}
console.log(deliveryFee(order)) // 69Nothing is wrong with this at three rules. The trouble is the direction it grows in.
Rule ten goes into the same body, shares the same fee variable, and depends on where
you put it relative to the other nine.
After: each rule is its own piece
type Order = {
subtotal: number
distanceKm: number
isRaining: boolean
isMember: boolean
}
interface FeeRule {
apply(fee: number, order: Order): number
}
class DistanceRule implements FeeRule {
apply(fee: number, order: Order): number {
return fee + order.distanceKm * 8
}
}
class RainRule implements FeeRule {
apply(fee: number, order: Order): number {
return order.isRaining ? fee + 25 : fee
}
}
class MemberRule implements FeeRule {
apply(fee: number, order: Order): number {
return order.isMember ? 0 : fee
}
}
class FeeCalculator {
constructor(private readonly rules: FeeRule[]) {}
calculate(order: Order): number {
return this.rules.reduce((fee, rule) => rule.apply(fee, order), 20)
}
}
const order: Order = {
subtotal: 120,
distanceKm: 3,
isRaining: true,
isMember: false,
}
const calculator = new FeeCalculator([
new DistanceRule(),
new RainRule(),
new MemberRule(),
])
console.log(calculator.calculate(order)) // 69FeeCalculator starts at the base fee of 20 and passes it through each rule in turn. It
does not know what the rules are. It knows there is a list of things with an apply
method.
Now marketing wants 15 rupees extra on orders under 150:
class SmallOrderRule implements FeeRule {
apply(fee: number, order: Order): number {
return order.subtotal < 150 ? fee + 15 : fee
}
}
const updated = new FeeCalculator([
new DistanceRule(),
new RainRule(),
new SmallOrderRule(),
new MemberRule(),
])
console.log(updated.calculate(order)) // 84One new class, one new entry in a list. FeeCalculator, DistanceRule, RainRule and
MemberRule were not opened, so they cannot have been broken. The new rule can be tested
alone with three lines.
What “closed” does not mean
Something still changed: the list passed to new FeeCalculator(...). That is fine, and
it is the point. Closed does not mean no line of the codebase is ever edited. It means
the edit happens in a place built for it. A list of rules is a socket. A 190 line function
body is the wall.
And the order of that list matters. MemberRule has to come last or a member would be
charged the small order fee. The refactor did not remove that decision, it moved it to
one visible line instead of leaving it buried in the order of if statements.
Closed also does not mean you cannot fix a bug. If RainRule charges the wrong amount,
you open it and fix it.
The lighter TypeScript version
A rule here is one function with no state. In TypeScript you can say so directly, and skip the classes.
type Order = { distanceKm: number; isRaining: boolean }
type FeeRule = (fee: number, order: Order) => number
const distance: FeeRule = (fee, order) => fee + order.distanceKm * 8
const rain: FeeRule = (fee, order) => (order.isRaining ? fee + 25 : fee)
const rules: FeeRule[] = [distance, rain]
const order: Order = { distanceKm: 3, isRaining: true }
console.log(rules.reduce((fee, rule) => rule(fee, order), 20)) // 69type FeeRule = (fee: number, order: Order) => number is a function type: anything that
takes a number and an order and returns a number. The principle is the same, a list of
interchangeable pieces. Use classes when a rule needs its own data or dependencies, like
a festival calendar. Use functions when it does not.
You cannot be closed against every possible change, and trying gives you plugin systems
for things that never vary. Wait for evidence. The first if is fine. The second is a
note to yourself. The third rule of the same kind is when you extract the interface,
because now you know what shape the variation has. Guessing that shape early is how
codebases end up with a FeeRuleFactoryProvider.
Nearly every low level design question has one place built to test it: vehicle types in a parking lot, pricing rules, notification channels, payment methods. When you see a list of kinds that is sure to grow, say “this will change, so I will put it behind an interface and keep the caller closed”. That one sentence is the open closed principle applied, and it is worth more than the definition.
interface FeeRule { apply(fee: number, order: Order): number }constructor(private readonly rules: FeeRule[])rules.reduce((fee, rule) => rule.apply(fee, order), 20)type FeeRule = (fee: number, order: Order) => numberconst rain: FeeRule = (fee, order) => ...order.isRaining ? fee + 25 : feeCheckpoint
1. After the refactor, a festival surcharge is needed. Which existing code has to be edited?
2. Does "closed for modification" mean you must never edit an existing class?
3. A function has one if for premium users and you do not expect more user tiers. Should you introduce a rule interface now?
The open closed principle says code should be open for extension and closed for modification, so I can add behaviour without editing what already works. The typical violation is a function with an if ladder that grows by one branch per feature, where every new rule risks the old ones. The fix is to put the thing that varies behind an interface, like a fee rule, and have the caller work with a list of them. A new rule is then a new class and one entry in the list, and nothing existing is opened. It does not mean code is frozen, bug fixes still edit code, and I do not build extension points on a guess. I wait until I have seen the same kind of change about three times.
