Open/Closed Principle

Intermediate · 10 min read · ▶ live playground · ✦ checkpoint

A local courier becomes available for short deliveries. The pricing change is tiny, but adding it means reopening a working calculator, editing its type tag, and testing every older shipping branch again.

The Open/Closed Principle (OCP) says software should be open for extension and closed for modification. Open for extension means you can add a new variation. Closed for modification means the stable code using that variation does not need another branch each time.

“Closed” does not mean source code is frozen forever. It means you choose a change axis—shipping prices in this case—and place a stable boundary around the code that should not know every option.

Before: every option reopens the calculator

A type tag is a field or value such as "standard" or "express" that tells branching code which variant it received. One branch can be harmless. The pressure appears when the calculator owns a growing list and every new kind sends you back into the same method.

class diagram

Before: shipping kinds inside one calculator

Before: shipping kinds inside one calculator
  1. 1ShippingCalculator knows the complete list of kinds and the formula for every one.
  2. 2Checkout delegates pricing, but it still passes the tag that selects a calculator branch.
  3. 3A new courier means modifying this working method and retesting every existing case.

The type tag makes the set of shipping choices part of ShippingCalculator itself:

type ShippingKind = "standard" | "express";
 
class ShippingCalculator {
  // smell: every new shipping kind requires another case in this method.
  public quote(kind: ShippingKind, distanceKm: number): number {
    switch (kind) {
      case "standard":
        return 4 + distanceKm * 0.5;
      case "express":
        return 10 + distanceKm * 0.9;
    }
  }
}
 
class Checkout {
  private shipping = new ShippingCalculator();
 
  public total(
    itemsTotal: number,
    kind: ShippingKind,
    distanceKm: number,
  ): number {
    return itemsTotal + this.shipping.quote(kind, distanceKm);
  }
}

To add "bike-courier", you must edit ShippingKind, reopen quote(), and add another case. If shipping tags also appear in receipts or delivery estimates, the same knowledge spreads into more branches.

The smell is not the switch keyword. It is the fact that a stable pricing flow must enumerate every concrete variation. Every shipping addition requires another calculator edit.

After: extend through one pricing contract

An extension seam is a stable boundary where new behavior can join without changing the code on the other side. A small ShippingPolicy interface becomes that seam. Each concrete class owns one formula, while Checkout asks only for a cost.

class diagram

After: shipping policies behind one seam

After: shipping policies behind one seam
  1. 1ShippingPolicy fixes the one operation Checkout needs while formulas remain free to vary.
  2. 2StandardShipping extends the design by implementing its own cost formula.
  3. 3ExpressShipping honors the same contract without adding a branch to StandardShipping.
  4. 4Checkout keeps a policy reference, so its total flow no longer lists shipping kinds.
  5. 5BikeCourier arrives as new code; the existing policies and Checkout stay unchanged.

The dashed hollow-triangle arrows point to the contract each policy implements. The plain line from Checkout records a stable reference to that abstraction; the concrete choice is supplied from outside.

interface ShippingPolicy {
  cost(distanceKm: number): number;
}
 
class StandardShipping implements ShippingPolicy {
  public cost(distanceKm: number): number {
    return 4 + distanceKm * 0.5;
  }
}
 
class ExpressShipping implements ShippingPolicy {
  public cost(distanceKm: number): number {
    return 10 + distanceKm * 0.9;
  }
}
 
class Checkout {
  private shipping: ShippingPolicy;
 
  public constructor(shipping: ShippingPolicy) {
    this.shipping = shipping;
  }
 
  public total(itemsTotal: number, distanceKm: number): number {
    return itemsTotal + this.shipping.cost(distanceKm);
  }
}
 
class BikeCourier implements ShippingPolicy {
  public cost(distanceKm: number): number {
    if (distanceKm > 8) {
      return 18;
    }
 
    return 3 + distanceKm * 0.35;
  }
}
 
const nearbyOrder = new Checkout(new BikeCourier());
console.log(nearbyOrder.total(50, 6).toFixed(2));

55.10

Adding BikeCourier did not change Checkout, StandardShipping, or ExpressShipping. The new class extends the family through a contract the checkout already understands. Polymorphism chooses the concrete cost() implementation at runtime.

This arrangement is strategy-like, meaning Checkout holds one interchangeable behavior behind a shared contract. A later pattern lesson will name and explore that full design. For OCP, the important move is smaller—the varying formula sits behind a stable operation.

Some code still has to choose new BikeCourier(). A composition boundary, such as application setup, may inspect configuration and create the right policy once. OCP moves repeated behavior branches out of stable clients; it does not remove the need to select an object.

When not to over-apply OCP

You Aren't Gonna Need It (YAGNI) is the reminder not to build flexibility for imagined requirements. If one checkout has two stable shipping cases and the calculation is unlikely to grow, a short local conditional may communicate the rule better than an interface and several classes.

A premature plug-in point is an extension seam created before you know what will vary. Guessing at UniversalShippingPlugin methods for insurance, drones, scheduling, customs, and discounts locks speculation into today's design. You have added a contract to maintain without protecting code from a real change.

Wait for evidence: repeated branches, several meaningful implementations, or a stable client that should not know concrete formulas. Even then, close only the useful boundary. Fixing a bug inside BikeCourier.cost() is a valid modification; OCP does not require replacing a class every time its own rule changes.

Checkpoint

Answer all three to mark this lesson complete

You can now extend one behavior family without teaching a stable caller every new name. Next, Liskov Substitution tests whether each new subtype truly keeps the promise of the type it joins.

+50 XP on completion