The open–closed principle — the cost when the prediction is wrong
OCP is the hardest principle in SOLID to practice. Following it requires knowing in advance which direction change will come from. And most of the time, we do not know.
Prepare an extension point and the next change is an addition only. Have the change arrive somewhere other than where you prepared and you rebuild the abstraction along with it. This chapter covers when to take that bet.
What the original said — abstraction is the key
Martin's 2000 document defines OCP this way1.
A module should be open for extension but closed for modification.
It then states explicitly where the principle came from.
Of all the principles of object oriented design, this is the most important. It originated from the work of Bertrand Meyer.
Martin calls this the most important of the principles of object-oriented design. As later chapters show, he positions DIP as the primary means of achieving OCP, and explains LSP violations as latent OCP violations. In his system, OCP sits at the center.
So how is it achieved? The original lists several techniques and then sums up.
All of these techniques are based upon abstraction. Indeed, abstraction is the key to the OCP.
The first technique listed is "Dynamic Polymorphism." In the original's example, a LogOn function that branched per modem type is made free of modification by depending on a Modem interface.
"Inherit and you have OCP" is not it
There is a reading of OCP as "just inherit and add behavior." The original does not say that. What it lists is dependence on abstraction, not implementation inheritance.
Commentary articles state that Meyer originally framed it around implementation inheritance. This series has not examined Meyer's original text, however, so that contrast is treated as a secondary source and not pursued here. What can be said with certainty is that the means Martin presented is dependence on abstraction.
Why follow it — confining the change to one place
What OCP reduces is the work of fixing several places for one addition.
Take a library's overdue fines as an example. Say the daily rate varies by membership tier.
type MemberTier = "general" | "student" | "senior";
class FineCalculator {
dailyRate(tier: MemberTier): number {
if (tier === "general") return 50;
if (tier === "student") return 20;
return 10;
}
maxFine(tier: MemberTier): number {
if (tier === "general") return 3000;
if (tier === "student") return 1000;
return 500;
}
gracePeriodDays(tier: MemberTier): number {
if (tier === "general") return 0;
if (tier === "student") return 3;
return 7;
}
}
Adding a "faculty" tier means reaching into all three methods. And it still runs if you forget one — because the final return acts as the default, faculty are automatically treated like seniors. No error appears; only the fee is wrong.
Gather everything per tier into one place and an addition takes one place.
type FinePolicy = {
readonly dailyRate: number;
readonly maxFine: number;
readonly gracePeriodDays: number;
};
const finePolicies: Record<MemberTier, FinePolicy> = {
general: { dailyRate: 50, maxFine: 3000, gracePeriodDays: 0 },
student: { dailyRate: 20, maxFine: 1000, gracePeriodDays: 3 },
senior: { dailyRate: 10, maxFine: 500, gracePeriodDays: 7 },
};
Add "faculty" to MemberTier and the type of Record<MemberTier, FinePolicy> no longer matches, so you get a compile error. The type catches the omission.
What to notice here is that neither an interface nor a class is used. What OCP asks for is a structure in which an addition stays an addition, not any particular language feature.
How it gets misused — paying for an extension that never came
OCP is misused by building the abstraction ahead of an extension that has not arrived. A row of interfaces with only one implementation is the typical sign.
interface FineCalculationStrategy {
calculate(overdueDays: number, tier: MemberTier): number;
}
class StandardFineCalculation implements FineCalculationStrategy {
calculate(overdueDays: number, tier: MemberTier): number {
const policy = finePolicies[tier];
const days = Math.max(0, overdueDays - policy.gracePeriodDays);
return Math.min(days * policy.dailyRate, policy.maxFine);
}
}
This abstraction was carved out on the prediction that "another calculation scheme might come along." A reader sees FineCalculationStrategy, assumes there must be several implementations, goes looking, and learns there is one.
Martin Fowler breaks this kind of investment into four costs from the standpoint of YAGNI (You Aren't Gonna Need It)2.
| Cost | What it is |
|---|---|
| cost of build | the effort spent analyzing, implementing, and testing a feature that goes unused |
| cost of delay | the delay from not directing that effort at a feature you do need |
| cost of carry | for as long as you hold it, it makes the code harder to read and harder to change |
| cost of repair | the rebuild once you find out later that it does not match reality |
The one most easily overlooked in predictive abstraction is cost of carry. The cost at the moment of building is paid once; the cost of holding it is paid daily. Every reader spends time guessing what an unused abstraction is for.
Worse still is when the prediction is half right. Sandi Metz explains in stages how a wrong abstraction rots3. In summary, the progression is: a request arrives that almost fits the abstraction → you add a parameter and a conditional to accommodate it → that repeats → nobody can read it any more.
Her conclusion is this.
Duplication is far cheaper than the wrong abstraction
When the abstraction is wrong, the fastest way forward is back.
"A wrong abstraction costs more than duplication" — that is the price of predictive abstraction. Duplication is visible and easy to remove. A wrong abstraction looks correct, so nobody tries to remove it and it is kept alive by adding conditionals.
When it's OK to break it
Deciding not to apply OCP is sound in the following cases.
When the direction of extension has not been observed. Wait for "it grew," not "it might grow." Abstract after experiencing the same kind of addition twice and the third onward becomes easy — and the shape of the abstraction matches the actual requests.
When the branch exists in only one place. What OCP prevents is having to fix several places for one addition. If there is one branch, there is one place to fix when you add. Interposing an abstraction does not reduce it.
When the type can catch the addition. As with Record<MemberTier, FinePolicy> above, if forgetting to add produces a compile error, no accident happens even though you are not closed for modification. The goal the original held up was precisely "being able to add behavior by addition alone, without changing existing code." This series steps back from that and takes the position that what bites in practice is preventing a forgotten fix, and that having to fix something is acceptable so long as the type guards it.
Fowler limits YAGNI's scope of application this way.
Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify.
In other words, YAGNI is not "do not design." Writing tests, tidying names, reducing dependencies — all out of scope. What is in scope is only "machinery for a feature that does not exist yet."
Where TypeScript stands today — exhaustiveness checking as a third road
The means Martin listed was dependence on abstraction. TypeScript has one more road: discriminated unions and exhaustiveness checking.
type FineRule =
| { readonly kind: "flat"; readonly amount: number }
| { readonly kind: "perDay"; readonly rate: number }
| { readonly kind: "capped"; readonly rate: number; readonly cap: number };
function calculateFine(rule: FineRule, overdueDays: number): number {
switch (rule.kind) {
case "flat":
return rule.amount;
case "perDay":
return rule.rate * overdueDays;
case "capped":
return Math.min(rule.rate * overdueDays, rule.cap);
default: {
// Add a kind to FineRule and rule's type is no longer never, producing a compile error
const exhaustive: never = rule;
return exhaustive;
}
}
}
This style is not closed for modification. Add a kind to FineRule and you fix calculateFine too. But you cannot forget to. The moment you do, compilation fails.
Which to choose is settled by what grows.
| What grows | The style that suits it | Why |
|---|---|---|
| Kinds (a new fee rule) | dependence on abstraction | adding a new implementation is all it takes |
| Operations (calculating a grace period in addition to the fee) | discriminated union | adding one function is all it takes |
Dependence on abstraction is strong against added kinds and weak against added operations, because adding a method to an interface means reaching into every implementation. A discriminated union is the reverse: strong against added operations, and an added kind means reviewing every function.
If you cannot read which axis will grow, starting from a discriminated union is safer. But omissions are only caught when you place an exhaustiveness check like the one above at every branch. Write it as a chain of ifs, or return a default from default, and the type stays silent when a kind is added — the same failure as FineCalculator at the top. Hold to that and the type catches omissions even when branches are scattered. Carve out the abstraction first and, when you are wrong, you need the going back Metz describes.
Summary
- Martin positions OCP as the most important of the principles of object-oriented design and names dependence on abstraction as the means of achieving it: "abstraction is the key to the OCP"
- What OCP reduces is the work of fixing several places for one addition. It does not demand any particular language feature
- The misuse is building an abstraction ahead of an extension that has not arrived. An interface with only one implementation is the sign
- Of Fowler's four costs, cost of carry is paid daily. The cost of building is paid once; the cost of holding continues
- Metz's "Duplication is far cheaper than the wrong abstraction" — a wrong abstraction costs more than duplication
- Wait until the direction of extension is observed. If the type catches a missed addition, no accident happens even when you are open to modification
What to read next
The next chapter covers the Liskov substitution principle. For the dependence on abstraction seen in this chapter to hold, whatever implements the abstraction has to behave as expected. We handle that condition in the form of a contract.
Exercises
The following interface has only one implementation. Keep it as it is, or delete it and go back to a concrete class? Name one piece of additional information you would need to decide
interface RenewalPolicy {
canRenew(currentRenewals: number, hasReservation: boolean): boolean;
}
class StandardRenewalPolicy implements RenewalPolicy {
canRenew(currentRenewals: number, hasReservation: boolean): boolean {
return currentRenewals < 2 && !hasReservation;
}
}
Sample answer
The most valuable additional information is "is there a concrete plan that will require a second implementation?"
The rule "a book with a hold on it cannot be renewed" looks like something that would differ from library to library. But looking like it would differ is not itself a basis. Predictive abstraction is born from exactly that feeling.
The decision splits as follows.
Keeping it: rollout to several branches is already decided, and it has been confirmed that the rules differ per branch. Or you genuinely need to substitute it in tests. In that case the abstraction answers a known request, not a prediction.
Deleting it: no such concrete plan exists. In that case the interface only makes you pay cost of carry every day. Readers spend time hunting for implementations and confirming there is one. You can carve it out when the second arrives, and at that point the shape of the abstraction will match the actual request.
Note that if canRenew's parameters keep growing, that is a different symptom. You may have entered the progression Metz describes, of adding parameters and conditionals to an abstraction that almost fits. In that case, rather than the presence of the interface, it is more effective to revisit what this determination is deciding.