Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
Read literally, this is impossible: every new feature changes something. The useful meaning is narrower:
For one identified axis of variation, adding a variant should mean adding code, not editing existing branches.
That phrase — one identified axis — is the whole principle. A design can be open to new payment providers and closed to everything else; trying to be open to all change produces abstraction soup.
What "closed" protects
The value of OCP is not that edits are forbidden. It is that:
- stable, tested code is not reopened for every new case;
- adding a variant cannot break existing variants;
- the set of behaviours is visible in one place (a registry, a module list);
- the compiler/tests can catch an incomplete implementation.
OCP is therefore a risk-management principle: keep the blast radius of a new variant to one new file.
The mechanism is replaceable behaviour
Any of these can serve as the extension point:
| Mechanism | Example | Cost |
|---|---|---|
| Polymorphism / interfaces | PaymentProvider with StripeProvider, AdyenProvider |
one type + implementations |
| Strategy object | PricingPolicy selected at runtime |
indirection at the call site |
| Registry / plugin map | Map<string, NotificationChannel> |
bootstrap code, duplicate keys |
| Composition of functions | discounts: Array<(cart) => Money> |
ordering rules, debugging |
| Events / pipeline | subscribers react to OrderPlaced |
async semantics, ordering, failure handling |
| Declarative config | rules loaded from a table or file | runtime validation, weaker typing |
The right question is not "which pattern?" but "what varies independently, and how often?"
Worked example: pricing
The classic violation:
function priceFor(cart: Cart, plan: string): Money {
switch (plan) {
case "regular":
return sum(cart.items);
case "pro":
return sum(cart.items).times(0.9);
case "black-friday":
return sum(cart.items).times(0.7).plus(shipping(cart));
default:
throw new Error(`unknown plan: ${plan}`);
}
}
Every new plan edits this function. It also mixes three concerns: discount, shipping, and plan selection. Add a coupon, a country-specific tax rule, and a partner discount, and the switch becomes unreadable.
A first refactor:
interface PricingPlan {
price(cart: Cart): Money;
}
class RegularPlan implements PricingPlan {
price(cart: Cart): Money {
return sum(cart.items);
}
}
class ProPlan implements PricingPlan {
price(cart: Cart): Money {
return sum(cart.items).times(0.9);
}
}
const PLANS: Record<string, PricingPlan> = {
regular: new RegularPlan(),
pro: new ProPlan(),
"black-friday": new BlackFridayPlan(),
};
function priceFor(cart: Cart, plan: string): Money {
const pricing = PLANS[plan];
if (!pricing) throw new UnknownPlanError(plan);
return pricing.price(cart);
}
Now adding student means adding one class and one map entry. Existing plans are untouched.
But notice what did not change: the switch still exists in the map. OCP does not eliminate dispatch; it moves dispatch to a single, explicit registry so the business logic lives in isolated units.
The second axis: discounts vs plans
If plans and discounts vary independently, do not multiply classes (ProWithBlackFridayPlan). Compose:
interface Discount {
apply(amount: Money, cart: Cart): Money;
}
class PercentageDiscount implements Discount {
constructor(private readonly rate: number) {}
apply(amount: Money): Money {
return amount.times(1 - this.rate);
}
}
class PricingPlan {
constructor(
private readonly base: Money,
private readonly discounts: Discount[],
) {}
price(cart: Cart): Money {
return this.discounts.reduce(
(amount, discount) => discount.apply(amount, cart),
this.base,
);
}
}
This is composition instead of an inheritance lattice. It is also easier to test: each discount is a pure function of amount and cart.
Registries: extension without editing dispatch
For notification channels:
interface NotificationChannel {
readonly key: string;
send(to: Recipient, message: Message): Promise<void>;
}
const channels = new Map<string, NotificationChannel>();
export function registerChannel(channel: NotificationChannel): void {
if (channels.has(channel.key)) {
throw new Error(`duplicate notification channel: ${channel.key}`);
}
channels.set(channel.key, channel);
}
export class Notifier {
async send(channelKey: string, to: Recipient, message: Message): Promise<void> {
const channel = channels.get(channelKey);
if (!channel) throw new UnknownChannelError(channelKey);
await channel.send(to, message);
}
}
// bootstrap.ts
registerChannel(new EmailChannel(emailClient));
registerChannel(new SmsChannel(smsClient));
A new channel is a new file plus one bootstrap line. The Notifier is closed. The cost is explicit: registration is global state, duplicate keys must be handled, and tests must reset the registry (or inject it instead — see Dependency Inversion).
[!NOTE] Prefer injecting the registry (
Mappassed to the constructor) over a module-level singleton. It keeps tests isolated and makes the available channels visible at the composition root.
When OCP makes things worse
- Speculative variants. "We might support crypto one day" is not a reason to build a provider abstraction today.
- One implementation, one interface. If there is genuinely no second case and no test double, the extra type buys nothing.
- Closing the wrong axis. Making
PricingPlanextensible while tax rules are hard-coded means the next tax change still reopens stable code. - Config-driven behaviour without types. A registry loaded from JSON turns compile-time errors into runtime 500s.
- Explosion of tiny classes. One class per combination of flags is a sign you needed composition, not more subclasses.
The rule of three
Wait for the third concrete variant, or for a stable interface forced by an external boundary, before abstracting. Two cases often look like a pattern but are actually one rule with a parameter.
A useful review question
For every abstraction, ask:
"If I add the next variant, which files change?"
- One new file and one registration → OCP is working.
- Ten files, or a chain of
instanceofchecks → the abstraction is in the wrong place. - Zero existing files but the new file must copy 80% of an existing one → the axis is wrong; extract the shared policy instead.
Testing OCP designs
- Each variant has its own focused unit test (no conditional test setup).
- The dispatcher has one test for unknown keys and one for routing.
- A contract test suite runs against every implementation of an interface:
function assertPaymentProviderContract(makeProvider: () => PaymentProvider) {
test("is idempotent for the same key", async () => {
const provider = makeProvider();
const first = await provider.charge({ key: "p1", amount: usd(10) });
const second = await provider.charge({ key: "p1", amount: usd(10) });
expect(second.id).toBe(first.id);
});
}
Run it for Stripe, for the fake, and for the next provider. The contract, not the class, is what is closed.
Exercises
- Find a
switchon a domain type. List the actors who add cases. Refactor one axis into a registry and measure the diff for a new case. - Design a discount engine where percentage, fixed-amount, and buy-one-get-one discounts compose without subclasses per combination.
- Identify an abstraction in your codebase with exactly one implementation. Decide: keep it (external boundary/test seam) or delete it.
- Add a
studentpricing plan in a codebase that uses OCP. Count the files touched; if it is more than two, explain why.
Checklist
- I can name the axis of variation this abstraction protects.
- Adding the next variant touches one new file plus one registration.
- Dispatch is explicit and central, not scattered
instanceofchecks. - Variants are independently testable.
- The abstraction exists because of real variants or an external boundary, not speculation.
- I have not closed an axis that is actually stable — and left volatile rules hard-coded.
Next: Liskov Substitution — an abstraction is only useful if every implementation keeps its promises.