Open for extension, closed for modification
OCP means adding behavior by adding code — a new type or strategy — not by editing tested code. But you can only be closed against the one axis of variation you predict; guess wrong and you over-engineer. The skill is choosing which change to make cheap.
Every new shape your renderer supports means opening the same area() function and adding another case to the same switch. The function compiles, the new shape works — but you just edited a function that five other shapes already depended on, and now every one of them needs re-testing because you touched their code path. The growing switch is a quiet tax: every extension is a modification.
The Open/Closed Principle is the design that removes that tax — add a new behaviour by adding a file, not by editing a working one. The catch, and the part juniors miss, is that you can never be closed against every change, only against the one axis of variation you bet on. This lesson is about making that bet, and about the over-engineering that follows when you bet wrong.
After this lesson you can state OCP precisely — open for extension, closed for modification along a chosen axis; recognise a growing switch/if-chain as the smell it targets; refactor it to polymorphism so a new variant is a new file with existing code untouched; and — the senior move — decide when not to apply OCP, because the wrong abstraction for a variation that never arrives is its own expensive failure mode.
OCP: software entities should be open for extension but closed for modification. “Closed for modification” means you can ship new behaviour without editing existing, tested source. “Open for extension” means the design has a seam where new behaviour plugs in. The two together describe code that grows by addition. The canonical anti-pattern is a conditional that switches on a type tag:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
}
}Add a triangle and you must edit area. Add perimeter and you write a second switch on the same tags. This code is closed for extension (you can’t add a shape without it) and open for modification (you must edit it constantly) — exactly backwards.
The fix is to put the variation behind an interface and let each variant own its code. Invert the dependency: instead of one function knowing every shape, each shape knows how to compute its own area. The switch evaporates into the type system.
interface Shape {
area(): number;
}
// circle.ts — a whole new file
class Circle implements Shape {
constructor(private r: number) {}
area(): number { return Math.PI * this.r ** 2; }
}
// square.ts — another file
class Square implements Shape {
constructor(private side: number) {}
area(): number { return this.side ** 2; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}Now adding a triangle is a new file, triangle.ts, implementing Shape. totalArea is never opened. Nothing that already worked is touched, so nothing that already worked needs re-testing. That is the whole payoff: the blast radius of “new shape” shrinks from “every consumer” to “one new file.”
OCP is a target along ONE axis, not an absolute — you choose which change to make cheap. The shape design above is closed against adding a shape and open against adding an operation: a new operation (perimeter, bounding-box) means editing the Shape interface and every implementing class — the exact pain the switch had, just rotated 90°. This is the expression problem: you can make new-variant cheap or new-operation cheap, rarely both for free.
So OCP is never “closed, full stop.” It is “closed against the axis I predict will vary.” If shapes get added often and operations rarely, the polymorphic design wins. If you mostly add operations to a fixed set of shapes, the switch (or a visitor) is the better bet. Picking the axis is the design decision; the pattern is just bookkeeping once you’ve picked.
Guess the axis wrong and OCP becomes over-engineering — its failure mode is speculative abstraction. An interface, a factory, a registry, a plugin loader: each is real code to read, and each is a bet that a particular variation will arrive. Build a PaymentProviderPlugin framework with dynamic discovery for a product that has had exactly one payment provider for two years and the abstraction earns nothing — you pay the indirection cost on every read and never collect the flexibility.
// Speculative: a plugin system for a variation that hasn't shown up.
interface DiscountStrategy { apply(price: number): number; }
class DiscountRegistry { /* register, resolve, fallback, config... */ }
// ...500 lines of framework, and the app ships exactly one discount: 10% off.This is YAGNI’s domain. The disciplined default is to write the concrete code (price * 0.9) and refactor to OCP the moment the second real variant appears — when the axis of change is observed, not imagined. Two data points beat one guess.
Refactor a growing dispatcher to closed-for-modification. A notification sender starts innocent and accretes a switch that every new channel must reopen:
// Before: every new channel edits this function and re-tests every branch.
function send(channel: string, msg: Message): void {
switch (channel) {
case "email": emailClient.send(msg.to, msg.body); break;
case "sms": smsGateway.text(msg.to, msg.body); break;
case "push": pushService.notify(msg.to, msg.body); break;
// …and "slack", "webhook", "whatsapp" keep landing here
default: throw new Error(`unknown channel: ${channel}`);
}
}The predicted axis of variation is new channels (the team adds one most months). That is the axis to make cheap, so push the variation behind an interface and let each channel be its own file:
// channel.ts
export interface Channel {
readonly name: string;
send(msg: Message): Promise<void>;
}
// channels/email.ts — a new channel is a new file, nothing else opens
export class EmailChannel implements Channel {
readonly name = "email";
async send(msg: Message) { await emailClient.send(msg.to, msg.body); }
}
// sender.ts — closed: this never changes when a channel is added
export class Sender {
constructor(private channels: Map<string, Channel>) {}
async send(channelName: string, msg: Message) {
const ch = this.channels.get(channelName);
if (!ch) throw new Error(`unknown channel: ${channelName}`);
await ch.send(msg);
}
}Adding Slack is now channels/slack.ts plus one registration line — Sender and every existing channel stay untouched and stay green. But notice what we did not abstract: the shape of Message, the retry policy, the rate limiting. Those aren’t varying yet, so wrapping them in strategies now would be the speculative trap from Step 4. We made exactly the one axis we observed varying cheap, and left the rest concrete.
▸Why this works
Why does removing the switch actually reduce re-testing, not just move it around? Because the conditional couples unrelated variants through a shared mutable code path. When email, sms, and push all live in one function, editing the function to add slack can break any of them — a typo in a shared default, a reordered break, a variable now in scope — so a careful reviewer must re-verify all of them. Split into files, each channel’s code is physically isolated: adding SlackChannel cannot, by construction, alter EmailChannel’s bytes. The test surface for “add a channel” collapses from “the whole sender” to “the new file,” and that shrinkage is the cost-of-change win OCP exists to buy.
▸Common mistake
The expensive misread is “OCP means never use a switch — always add an interface.” That turns a context-dependent bet into a reflex, and the reflex over-abstracts. A switch over a closed, stable set (HTTP methods, the days of the week, a fixed set of three plan tiers that hasn’t changed in the product’s life) is fine — it’s closed against the right axis already, because that axis doesn’t move. Introducing a Strategy per case there buys flexibility you’ll never use and scatters logic that was readable in one place. OCP is justified by an observed or strongly predicted axis of variation. No axis of change, no abstraction — the switch stays.
A service has a single payment provider and has had one for two years. A teammate proposes a PaymentProvider interface with a plugin registry 'so we're open for extension.' Through the OCP + cost-of-change lens, what's the right call?
The Open/Closed Principle says to add behaviour by adding code — a new type, strategy, or handler in its own file — rather than by editing a working, tested function like a switch that reopens for every case. The mechanism is polymorphism: push the variation behind an interface so each variant owns its code and the consumer is never touched, which collapses the re-test surface of “new variant” from every consumer to one new file. But OCP is a target along one axis, never absolute: you can only be closed against the variation you predict, and the dual axis stays as expensive as before (the expression problem). The senior skill is choosing which change to make cheap — and its failure mode is betting wrong, building a plugin framework or strategy hierarchy for variation that never arrives. So the default is concrete code first; refactor to OCP when the second real variant proves the axis. No observed axis of change, no abstraction.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.