Engineering Atlas › Low-Level Design

Open/closed principle (OCP)

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

A realistic "before"

A checkout service prices carts. Every promotion the marketing team invents lands in the same function:

def price(cart, promo_codes, customer):
    total = sum(i.price * i.qty for i in cart.items)
    for code in promo_codes:
        if code == "SUMMER10":
            total -= total // 10
        elif code.startswith("BOGO-"):
            sku = code[5:]
            qty = sum(i.qty for i in cart.items if i.sku == sku)
            total -= (qty // 2) * price_of(cart, sku)
        elif code == "FIRST" and customer.orders == 0:
            total -= 500
        elif code == "STUDENT" and customer.verified_student:
            total -= total * 15 // 100
    return max(total, 0)

Each new promotion edits the function that prices every order in the company. A typo in the "STUDENT" branch can break "SUMMER10". The function's tests grow combinatorially, and the people who change it most (whoever is adding this week's campaign) are the least familiar with its edge cases. Release managers learn to fear the file.

The "after"

Separate the part that is stable (walk the rules, keep a running total, never go below zero, record what applied) from the part that varies (what each promotion computes):

class PricingRule(Protocol):
    name: str
    def discount(self, items, running_total) -> int: ...

class PricingEngine:
    def __init__(self, rules):
        self.rules = rules
    def quote(self, items):
        total = sum(price * qty for _, price, qty in items)
        for rule in self.rules:
            total -= min(max(rule.discount(items, total), 0), total)
        return total

A new campaign is a new class with its own tests, plus one line where the rules are assembled. The engine, its invariants and its tests do not change. That is the whole idea: the engine is closed, the rule set is open.

Choosing the axis

Bertrand Meyer coined the principle in 1988 in terms of inheritance: extend a class by subclassing instead of editing it. The modern reading, popularised by Robert Martin, uses interfaces and composition. Either way, you can only be closed against changes you predicted. The pricing engine above is closed against new promotion types. It is wide open to "promotions now need the customer's loyalty tier", which changes the rule interface and every rule. This is the expression problem: with polymorphism, adding a variant is one new class but adding an operation touches every class; with a switch over a closed set of variants, adding an operation is one new function but adding a variant touches every switch. Look at your history and pick the axis that churns.

When following it too zealously hurts

Reviewing agent-written code

Given a long if/elif chain and a request for one more case, an agent will add one more branch, because that is the smallest local change. It will often add a boolean flag parameter to an existing function for the same reason. Neither is wrong once; repeated twenty times it produces the "before" above. When a seam exists, point the agent at it ("add a PricingRule and register it; do not edit the engine") and review for the shape of the diff: new files plus one registration line is a good sign, edits inside the engine loop deserve a closer look. Tests for the engine that use a rule defined only in the test file are the strongest evidence the seam is real.

The same shape elsewhere

The pricing engine is an ordered chain of independent steps over shared state, and order matters. Infrastructure is full of the same design: Envoy HTTP filter chains, Linux netfilter hooks, Express or Go net/http middleware, Kubernetes admission webhooks. Each lets you add behaviour by registering a new step without recompiling the core, and each has had bugs caused by steps running in an unexpected order.

Down the stack

Layer 1The contract

The engine promises: rules run in registration order; each rule sees the running total after the previous rules; a rule's discount is clamped to between zero and the running total, so the total never goes negative; the result lists exactly which rules applied and how much each took. Each rule promises to be a pure function of its inputs, with no I/O and no shared mutable state. Those two contracts are what make an unknown future rule safe to plug in.

Layer 2The mechanism

The mechanism is the strategy pattern plus a registry. In Go the rule is an interface; a registry is a slice or a map[string]func() Rule filled at startup, sometimes from init() functions in separate packages (the way database/sql drivers register themselves). In TypeScript the interface is checked at compile time and erased; at runtime each rule is an object whose discount method is found on its prototype. In Python the engine relies on duck typing, optionally checked with typing.Protocol. Statically typed languages with sum types offer the alternative axis: a Rust enum or a TypeScript discriminated union with an exhaustive switch makes the compiler list every place that must change when you add a variant.

Layer 3The machine

Each rule call is a dynamic dispatch. In C++ or Java the object's header points to a vtable, and the call loads the function pointer from a fixed slot and jumps: one or two dependent loads plus an indirect branch, about 1 to 3 ns when the branch predictor guesses right. In Go an interface value is two words, an itab pointer and a data pointer; the itab holds the method pointers for that concrete type, computed once and cached, so a call is the same load-and-jump. Storing a non-pointer value larger than a word in an interface can force a heap allocation, which is why rule types are usually pointers or zero-size structs. In V8, rule.discount(...) is a property lookup along the prototype chain that an inline cache remembers per call site: one shape (monomorphic) is fast, two to four (polymorphic) is a short check list, and beyond that (megamorphic) it falls back to a slower global cache. A loop over twenty different rule classes is megamorphic at that call site. CPython looks the method up through the type's MRO with a per-type method cache, roughly 50 ns per call.

At realistic scale this is noise. Twenty rules times 2,000 checkouts per second is 40,000 calls per second, a few milliseconds of CPU per second even in Python. The cost that matters is concurrency: rules shared by all request goroutines or threads must be stateless or immutable. Build the registry once at startup and only read it afterwards, and no lock is needed. If rules can be hot-reloaded, build a new slice and swap it in with an atomic pointer store, so readers never see a half-built list.

Why it's built this way

  1. Every edit to working code risks breaking it, and the risk grows with the code's age and number of callers.
  2. Some kinds of change recur (new promotions, new providers, new channels), so the same risky edit would happen again and again.
  3. A stable interface lets the recurring change be pure addition, which can only break the new code.
  4. Interfaces cost indirection and lock in an axis of change, so they pay off only where change actually recurs.
  5. Hence: identify the churning axis from history, put one seam there, and keep everything else simple.

Diagram

flowchart LR
  C["Cart items"] --> E["PricingEngine (closed)"]
  E --> R1["PercentOff"]
  R1 --> R2["BuyXGetYFree"]
  R2 --> R3["FixedOffOver"]
  R3 --> RN["Next rule (added later)"]
  RN --> Q["Quote: subtotal, applied, total"]

Worked example

A retailer runs 4 to 6 new promotions a month. In the old design each one edited price(), which had grown to 600 lines with 41 branches. Over a year, 9 of 14 checkout incidents traced back to promotion changes, and two of those broke promotions that had nothing to do with the change.

After the refactor, the engine is 40 lines and has not changed in eight months. Each promotion is a class of 15 to 60 lines with its own tests. A new promotion is one pull request that adds a file and one registration line; reviewers check the rule's tests and the registration order and nothing else. Incidents caused by promotions drop to two in the following year, both inside the new rule, neither affecting other promotions.

Ordering is the subtle part. With a 100.00 cart and two rules, "10% off" and "20.00 off orders of 95.00 or more": percent first gives 90.00, which is below the threshold, so the total is 90.00. Fixed first gives 80.00, then 10% off that is 72.00. The engine makes order explicit in the registry and returns the list of applied rules, so support staff can explain any quote.

Trade-offs

OptionStrengthsWeaknessesChoose when
if/elif or switch in one functionEverything visible in one place; cheapest to readEvery new case edits shared code; tests grow combinatoriallyTwo or three cases, rarely changing
Interface plus registry (strategy)New cases are additive; small reviewsIndirection; changing the interface touches every ruleVariants are added often; operations are stable
Sum type with exhaustive matchCompiler finds every place to update; easy to add operationsAdding a variant edits every matchVariant set is closed; operations grow
Rules as data (DSL, config)Non-engineers can change behaviour without deploysNo types, weak tests, hard debuggingMany simple, uniform rules owned by operations staff

Pitfalls in production

Leadership lens

Check yourself

  1. Your notification service supports email, SMS and push. Product asks for "quiet hours" across all channels. Does a Channel interface help?

    Show answer

    Not much: quiet hours is a new operation across every variant, which is the axis the interface does not cover. Put it in the dispatcher in front of the channels, or extend the interface knowingly.

  2. Why does the pricing engine clamp each rule's discount rather than trusting rules to behave?

    Show answer

    The engine is closed and must keep its invariant (total never negative) for rules that do not exist yet, written by people who may not know the invariant.

  3. When is an exhaustive switch better than an interface?

    Show answer

    When the set of variants is fixed or rarely changes but operations over them keep growing, such as order states; the compiler can then flag every unhandled case.

  4. Why might a loop over many rule types be slower in V8 than a loop over one type?

    Show answer

    The method call site sees many object shapes, so its inline cache becomes megamorphic and falls back to a slower generic lookup instead of a cached direct call.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →