The Open/Closed Principle says software entities should be open for extension and closed for modification. Put plainly: when a new requirement arrives, you should be able to meet it by adding code, not by editing code that already works and is already tested.

Difficulty: ★★★☆☆, because the definition sounds like a contradiction until you see it in code. Popularity: ★★★★★, because every plugin system, middleware chain and framework hook is built on it.

The everyday picture is a power strip. A desk lamp hard-wired into the wall has to be rewired by an electrician when you want to plug in a laptop charger instead. A strip with a row of standard sockets accepts the lamp, the charger or a monitor without anyone touching its wiring. The standard socket is the abstraction; each device is an extension.

The problem: a validator that every team edits

Checkout validates an order before charging. The first version handles stock and a minimum order value:

type Cart = {
  items: { sku: string; quantity: number; price: number }[];
  paymentMethod: "card" | "cod" | "wallet";
  province: string;
};

const stock: Record<string, number> = { MUG: 10, CAP: 0, JACKET: 3 };

export function validateCart(cart: Cart): string[] {
  const errors: string[] = [];
  const subtotal = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);

  for (const item of cart.items) {
    if ((stock[item.sku] ?? 0) < item.quantity) errors.push(`${item.sku} is out of stock`);
  }
  if (subtotal < 50_000) errors.push("Minimum order is 50,000đ");

  return errors;
}

Then requests arrive from different teams:

  • Finance: cash on delivery is not allowed above 5,000,000đ.
  • Operations: we cannot deliver jackets to two island provinces this month.
  • Marketing: limit flash-sale items to 2 per order.

Each one is another if in validateCart. After a year the function is 200 lines, three teams edit it every sprint, and a bad merge in the COD rule once blocked every card payment. The function is open for modification, which is exactly what the principle warns against.

The refactor: a stable core and pluggable rules

Find what varies (the individual rules) and what stays the same (run every rule, collect the errors). Give the varying part an interface:

type CartContext = Cart & { subtotal: number };

export interface CartRule {
  readonly name: string;
  check(cart: CartContext): string | null; // an error message, or null when the rule passes
}

export class CartValidator {
  constructor(private readonly rules: readonly CartRule[]) {}

  validate(cart: Cart): string[] {
    const ctx: CartContext = { ...cart, subtotal: cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
    return this.rules.map((rule) => rule.check(ctx)).filter((error): error is string => error !== null);
  }
}

CartValidator is now closed. It does not know any rule by name, and it will not change when the next rule arrives. Each rule is a small object:

export const inStock: CartRule = {
  name: "in-stock",
  check: (cart) => {
    const missing = cart.items.filter((i) => (stock[i.sku] ?? 0) < i.quantity).map((i) => i.sku);
    return missing.length ? `Out of stock: ${missing.join(", ")}` : null;
  },
};

export const minimumOrder = (amount: number): CartRule => ({
  name: "minimum-order",
  check: (cart) => (cart.subtotal < amount ? `Minimum order is ${amount.toLocaleString("vi-VN")}đ` : null),
});

export const codLimit = (limit: number): CartRule => ({
  name: "cod-limit",
  check: (cart) =>
    cart.paymentMethod === "cod" && cart.subtotal > limit ? "Cash on delivery is not available for this amount" : null,
});

export const blockedDelivery = (skus: string[], provinces: string[]): CartRule => ({
  name: "blocked-delivery",
  check: (cart) =>
    provinces.includes(cart.province) && cart.items.some((i) => skus.includes(i.sku))
      ? `Some items cannot be delivered to ${cart.province} right now`
      : null,
});

Rules that take parameters are factories (minimumOrder(50_000)), so the same rule can be reused with different numbers, or configured from a database. The composition happens in one place:

const validator = new CartValidator([
  inStock,
  minimumOrder(50_000),
  codLimit(5_000_000),
  blockedDelivery(["JACKET"], ["Kiên Giang", "Quảng Ninh"]),
]);

console.log(
  validator.validate({
    items: [
      { sku: "JACKET", quantity: 2, price: 2_800_000 },
      { sku: "CAP", quantity: 1, price: 90_000 },
    ],
    paymentMethod: "cod",
    province: "Kiên Giang",
  }),
);
// [ "Out of stock: CAP", "Cash on delivery is not available for this amount",
//   "Some items cannot be delivered to Kiên Giang right now" ]

Marketing's flash-sale limit is now a new file with a new rule and one line in the list. CartValidator and the other rules are not touched, so they cannot break. Each rule has its own unit test, and each team owns its own rules. That is the principle working.

The extension points JavaScript gives you

You do not need class hierarchies to apply the principle. The common extension mechanisms in TypeScript code are:

  • Interfaces with multiple implementations, as above. The classic, polymorphic form.
  • Functions as parameters. array.sort(compare), retry(fn, { shouldRetry }). The caller extends behaviour by passing a function.
  • Registries and maps. Record<Carrier, FeeRule> from chapter 1, or a Map that plugins register into at startup.
  • Middleware chains. Express, Koa and NestJS interceptors let you add behaviour around requests without editing the handlers.
  • Events. The subject in the Observer pattern (chapter 14) is closed; new reactions are new subscribers.
  • Plugin APIs. ESLint rules, Vite and Rollup plugins, Babel transforms: the tool's core is closed, and the ecosystem is the extension.

What the principle does not mean

  1. It does not forbid fixing bugs. "Closed for modification" protects working behaviour. If the behaviour is wrong, fix it.
  2. It does not mean predicting every extension. You cannot make code open to every possible change, and trying produces abstraction nobody needed. Make it open in the direction it has already changed, usually after the second or third similar request.
  3. It does not require inheritance. Meyer's original 1988 version relied on subclassing. Martin's later version relies on abstractions and polymorphism, and composition (as in the validator) is usually the better tool.
  4. It applies to stable cores, not to everything. Application code at the edges (a controller, a migration script) is fine to edit directly. The principle pays off for code that many features depend on.

When to use it, and when not to

Use it when:

  • The same function or class keeps getting edited for unrelated requests.
  • Different people own different variations (finance rules, marketing rules).
  • You want to configure behaviour from data instead of deploys.

Skip it when:

  • There is one rule and no evidence of a second. A plain function is easier to read than a one-element rule list.
  • The variations are not independent. Rules that need to know about each other's results are a workflow, not a list.

Common pitfalls

  1. Abstracting too early. A RuleEngine with priorities, dependencies and a DSL for two rules is worse than the original ifs.
  2. Leaky extension points. If every new rule needs a new field added to CartContext by editing the validator, the core is not really closed. Pass enough context up front, or let rules fetch what they need through injected services.
  3. Order-dependent extensions. If rule B only works when rule A ran first, the list order becomes hidden logic. Make dependencies explicit or merge the rules.
  4. Forgetting the composition root. Extensions have to be registered somewhere. Keep that list in one obvious place, not scattered through import side effects.

Exercises

  1. Add the flash-sale rule (at most 2 units of any SKU in a given list) without editing CartValidator or any existing rule.
  2. Change CartRule.check to be async so a rule can call an inventory service. What changes in CartValidator, and is that a modification you are willing to make once?
  3. Load the parameters for minimumOrder and codLimit from a JSON config so finance can change limits without a deploy.
  4. Find a function in a codebase you work on that has been edited by three or more different features. Sketch the extension point that would have avoided it.