The Principle, Stated Precisely
Bertrand Meyer’s original 1988 formulation, sharpened by Robert C. Martin:
A software entity (class, module, function) should be open for extension, but closed for modification.
Two words do all the work, and they sound contradictory until you separate the two axes they describe:
- Open for extension — the behavior of the entity can be extended. When requirements change, you can make the module do new things.
- Closed for modification — extending behavior does not require editing the existing, already-tested, already-shipped source of that entity.
The mechanism that resolves the apparent contradiction is abstraction. You depend on a stable abstraction (an interface / abstract base / pure virtual class), and you add new behavior by writing a new concrete implementation of that abstraction — not by reopening and re-editing a concrete class that already works.
OCP is not “never change code.” Bug fixes and refactors still edit code. OCP targets a specific failure mode: every time a new variant of the same concept appears (a new shape, a new payment method, a new discount rule), you should not have to surgically edit a growing switch/if-else ladder in the middle of working logic.
The smell that OCP fights
The canonical OCP violation has a recognizable shape: a type tag plus a conditional that switches on it. You will see it as an enum + switch, an if (type == "...") chain, or an isinstance cascade. Each time a new variant arrives, a human must open that function, find the conditional, and add a branch — risking the existing branches.
new requirement arrives
│
▼
┌─────────────────────────────┐
│ edit AreaCalculator.area() │ ← reopen tested code
│ add another `case` │ ← every variant touches one hot method
└─────────────────────────────┘
recompile / retest EVERYTHING that method touches
A Concrete Violation: Shape Area
Here is the textbook smell. An AreaCalculator that knows about every shape type:
enum ShapeKind { Rectangle, Circle, Triangle }
interface Shape {
kind: ShapeKind;
width?: number;
height?: number;
radius?: number;
base?: number;
}
class AreaCalculator {
// Every new shape forces an edit HERE.
area(shape: Shape): number {
switch (shape.kind) {
case ShapeKind.Rectangle:
return shape.width! * shape.height!;
case ShapeKind.Circle:
return Math.PI * shape.radius! * shape.radius!;
case ShapeKind.Triangle:
return 0.5 * shape.base! * shape.height!;
default:
throw new Error("Unknown shape");
}
}
}What is wrong, precisely:
- Adding
Hexagoneditsarea()— a method that already correctly computes three other shapes. You retest all four. - The
Shapestruct is a union of all fields —width,radius,baseall live on every instance, mostly null. The type system can no longer tell you a circle has nobase. The Python version makes this explicit: every field isOptional[float], soshape.width * shape.heightisOptional * Optional, whichmypyrejects (hence the# type: ignore). That unsoundness is the smell, not an accident. - The conditional tends to multiply. Once
AreaCalculatorswitches on kind, so willPerimeterCalculator, the renderer, the serializer. Now N switch statements must stay in sync over the same enum. Forget one and you ship a bug.
Interview tell: if your first instinct on “add a new shape” is “I’ll add a
case,” you are signaling that you reach for modification, not extension. Strong candidates reach for a new class.
On the C++ side, note that M_PI from <cmath> is not ISO standard C++ — it’s a POSIX/implementation extension and requires #define _USE_MATH_DEFINES on MSVC. For portable, compilable code we use constexpr double kPi = ... (or std::numbers::pi from <numbers> under C++20). Small detail, but a lesson that claims compilable code should not silently depend on a non-standard macro.
The Refactor: Push Behavior Behind an Abstraction
Invert the dependency. Instead of the calculator knowing each shape, each shape knows its own area. The calculator depends only on the Shape abstraction.
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private readonly w: number, private readonly h: number) {}
area(): number { return this.w * this.h; }
}
class Circle implements Shape {
constructor(private readonly r: number) {}
area(): number { return Math.PI * this.r * this.r; }
}
// NEW behavior = NEW class. No existing file is reopened.
class Triangle implements Shape {
constructor(private readonly base: number, private readonly height: number) {}
area(): number { return 0.5 * this.base * this.height; }
}
class AreaCalculator {
totalArea(shapes: Shape[]): number {
// Closed for modification: never changes when a shape is added.
return shapes.reduce((sum, s) => sum + s.area(), 0);
}
}Now adding Hexagon means writing one new file with one class. AreaCalculator is closed: it compiles, its tests pass, and you never touched it. The new shape is the extension.
AreaCalculator ───depends on──▶ «interface» Shape
△ area()
│
┌────────────┬───────────────┼──────────────┐
Rectangle Circle Triangle Hexagon ← added later,
nothing above edited
The fields problem is fixed for free. Circle now holds only r; Triangle holds only base, height. Invalid states (a circle with a base) are no longer representable, and the Python version is now fully type-sound — no # type: ignore needed. OCP refactors frequently improve type safety as a side effect, because each variant owns exactly its own data.
The Discount Example, and Why It Is the Strategy Pattern
The shape example shows OCP over data variants. The more interview-relevant case is OCP over algorithm variants — and there it becomes the Strategy pattern. They are the same move applied to a behavior instead of a value.
The smell — a pricing function that switches on customer tier:
type Tier = "regular" | "premium" | "vip";
class Checkout {
// Marketing adds a tier => this method is reopened. Again.
price(amount: number, tier: Tier): number {
if (tier === "regular") return amount;
if (tier === "premium") return amount * 0.9;
if (tier === "vip") return amount * 0.8;
throw new Error("Unknown tier");
}
}The OCP-compliant version: a DiscountStrategy abstraction, one class per rule, injected into Checkout.
interface DiscountStrategy {
apply(amount: number): number;
}
class NoDiscount implements DiscountStrategy {
apply(a: number): number { return a; }
}
class PercentageDiscount implements DiscountStrategy {
constructor(private readonly pct: number) {}
apply(a: number): number { return a * (1 - this.pct); }
}
// NEW rule = NEW class. Checkout is untouched.
class CappedDiscount implements DiscountStrategy {
constructor(private readonly pct: number, private readonly cap: number) {}
apply(a: number): number { return a - Math.min(a * this.pct, this.cap); }
}
class Checkout {
// Depends on the abstraction, not on any concrete rule.
price(amount: number, discount: DiscountStrategy): number {
return discount.apply(amount);
}
}Strategy is OCP made concrete for a single varying algorithm. When the thing that varies is one behavior with interchangeable implementations, the OCP-correct structure is the Strategy pattern: an abstraction for the behavior, one class per variant, and a context that holds a reference to the abstraction. If you can explain this equivalence in an interview, you have shown you understand both at the level of why, not just what.
”But you just moved the switch”
The sharpest interview pushback: “You didn’t delete the conditional — someone still has to map tier to a strategy.” True. The decision of which strategy to use lives somewhere, usually in a factory or a registry at the system’s edge (config, DI container, request handler). The point is isolation, not elimination:
// The ONLY place that knows the catalog of tiers.
// Adding a tier touches this map and adds one class — nothing else.
const DISCOUNTS: Record<string, () => DiscountStrategy> = {
regular: () => new NoDiscount(),
premium: () => new PercentageDiscount(0.10),
vip: () => new PercentageDiscount(0.20),
};
function discountFor(tier: string): DiscountStrategy {
const make = DISCOUNTS[tier];
if (!make) throw new Error(`Unknown tier: ${tier}`);
return make();
}The win: the business logic (Checkout.price, AreaCalculator.totalArea) is closed. The conditional collapses to a single lookup at the boundary, decoupled from every algorithm. With a self-registering registry it can disappear entirely — a new tier registers itself into DISCOUNTS at module load, and even the factory map stops changing.
Do not chase 100% OCP everywhere. Every abstraction you add to “close” a class is a real cost: more indirection, more classes, harder-to-follow control flow. Apply OCP where variation is expected (you already added two payment methods; a third is coming). For a conditional that has been stable for years and has no pending variants, a plain switch is fine — and arguably clearer. OCP is a response to observed or anticipated axes of change, not a blanket mandate. In an interview, say this out loud; it signals seniority, not dogma.
How OCP Relates to the Rest of SOLID
OCP does not stand alone — it is the goal, and three other SOLID principles are the mechanism or guardrail.
| Principle | Relationship to OCP |
|---|---|
| SRP (Single Responsibility) | A class with one reason to change has one axis of variation to abstract. SRP makes OCP achievable; a god class has too many switch points to close cleanly. |
| LSP (Liskov Substitution) | OCP is only safe if every new subtype is a true behavioral substitute. If Triangle.area() returned a perimeter, AreaCalculator would break silently. LSP is the correctness contract that lets totalArea trust any Shape. |
| DIP (Dependency Inversion) | The refactor is DIP: Checkout (high-level policy) depends on the DiscountStrategy abstraction, not on concrete PercentageDiscount (low-level detail). DIP is the structural rule that points the dependency arrow at the abstraction, which is precisely what makes the class closed. |
The clean way to say it in an interview:
“OCP is the what — closed to modification, open to extension. DIP is how I get there structurally (depend on abstractions), and LSP is what keeps it correct (subtypes must be substitutable). When the varying thing is a single algorithm, the concrete pattern that falls out is Strategy.”
LSP failure breaks OCP — concretely
Suppose a new Square extends Rectangle overrides setWidth to also set height (the classic LSP violation). Code written against Rectangle that sets width and height independently now produces wrong areas for Square. The AreaCalculator you thought was closed now has a latent bug introduced purely by an extension — because the extension wasn’t a valid substitute. A broken LSP turns “open for extension” into “open for regressions.” That is why the two principles are quoted together.
Interview Signal: What “Getting” OCP Looks Like
What an interviewer is actually probing:
- You name the smell on sight. “Switch on a type tag” / “
isinstancecascade” / “thisif-elsegrows every sprint” — you flag it before being asked. - You reach for a new class, not a new branch. Asked to add a variant, your first sentence is “I’d add a new implementation of the
Xinterface,” not “I’d add a case.” - You can locate where the conditional goes. You don’t pretend the decision vanishes; you put it in a factory/registry at the boundary and explain why isolation beats elimination.
- You connect it to Strategy, DIP, and LSP in one breath, showing the principles are a system, not flashcards.
- You know when not to. You don’t over-abstract a stable conditional. Senior signal: cost-awareness.
The single highest-value sentence you can say: “I’ll depend on an abstraction here so adding the next variant is a new class, not an edit to this tested method — and the only place that grows is the factory at the edge.” That one line demonstrates OCP, DIP, the Strategy equivalence, and the boundary-isolation rebuttal simultaneously.