Back to the path
Level 2 intermediate 25 min #solid#ocp#open-closed-principle#polymorphism#strategy-pattern#lld#interfaces

Open/Closed Principle: Extend Without Editing

The Open/Closed Principle says software entities should be open for extension but closed for modification. This lesson takes the classic shape-area and discount-pricing smells, refactors them with polymorphism and interfaces across TypeScript, Python, and C++, shows exactly where OCP becomes the Strategy pattern, and connects it to LSP and DIP — plus the interview signals that prove you actually understand it.

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.

Key idea

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:

  1. Adding Hexagon edits area() — a method that already correctly computes three other shapes. You retest all four.
  2. The Shape struct is a union of all fieldswidth, radius, base all live on every instance, mostly null. The type system can no longer tell you a circle has no base. The Python version makes this explicit: every field is Optional[float], so shape.width * shape.height is Optional * Optional, which mypy rejects (hence the # type: ignore). That unsoundness is the smell, not an accident.
  3. The conditional tends to multiply. Once AreaCalculator switches on kind, so will PerimeterCalculator, 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.

Tip

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
Tip

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);
}
}
Key idea

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.

Common pitfall

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.

PrincipleRelationship 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:

  1. You name the smell on sight. “Switch on a type tag” / “isinstance cascade” / “this if-else grows every sprint” — you flag it before being asked.
  2. 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 X interface,” not “I’d add a case.”
  3. 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.
  4. You connect it to Strategy, DIP, and LSP in one breath, showing the principles are a system, not flashcards.
  5. You know when not to. You don’t over-abstract a stable conditional. Senior signal: cost-awareness.
Key idea

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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. Which code structure is the most reliable on-sight indicator of an Open/Closed Principle violation?

2. After refactoring the tier-based pricing into a DiscountStrategy interface, an interviewer says: "You didn't remove the conditional — something still maps a tier string to a strategy." What is the strongest response?

3. Which statements about how OCP relates to the other SOLID principles are correct? (Select all that apply.) (select all)

4. In the tri-language refactor, why did moving from a single Shape struct (with optional width/radius/base) to one class per shape improve type safety as a side effect?

Design problem 5

You are given a NotificationService whose send(channel: string, message: Message) method is a 60-line if/else chain: if channel == "email" ... else if channel == "sms" ... else if channel == "push" .... Product wants to add Slack and WhatsApp this quarter, and the chain already has subtle bugs from people editing it. Redesign this to satisfy OCP. Describe the abstraction, the concrete types, where channel-selection lives, and how you keep it correct. Provide a short code sketch in any one language.