Open/closed principle (OCP)
Open in the interactive app: practice, quiz, mock interview →In one minute
- A module is open for extension and closed for modification when you can add new behaviour by adding code, not by editing code that already works and is already tested.
- The usual mechanism is a seam: an interface (pricing rule, notification channel, payment provider) plus a registry. The core loop is written once; new cases plug in beside it.
- You cannot be closed against every change. Pick the axis that actually varies, such as "new promotion types every sprint", and close against that one. Polymorphism makes new variants cheap and new operations expensive; a
switchdoes the opposite. - Over-applied, OCP produces plugin frameworks for two cases and configuration languages nobody can debug. A plain
ifis fine until the third variant shows up. - With agents, a good seam turns "add a feature" into "add a file": an additive diff with a small blast radius that is easy to review.
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 totalA 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
- Speculative seams. A
NotificationChannelFactorywith one channel and a plugin loader is complexity paid in advance for flexibility that may never be used. YAGNI applies. - Configuration as a programming language. Teams sometimes "close" the engine so completely that rules live in YAML with conditions, loops and references. Now the logic has no type checker, no debugger and no unit tests.
- Hidden ordering coupling. Rules that look independent often are not. Ten percent off and then five dollars off is a different price from the reverse. The seam does not remove that coupling; the engine has to own and document the order.
- Closed sets dressed up as open. Order states (pending, paid, shipped, refunded) are a fixed set with transitions between them. An exhaustive
switchwith a compiler check is clearer than a state interface with one class per state.
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
- Every edit to working code risks breaking it, and the risk grows with the code's age and number of callers.
- Some kinds of change recur (new promotions, new providers, new channels), so the same risky edit would happen again and again.
- A stable interface lets the recurring change be pure addition, which can only break the new code.
- Interfaces cost indirection and lock in an axis of change, so they pay off only where change actually recurs.
- 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
if/elif or switch in one function | Everything visible in one place; cheapest to read | Every new case edits shared code; tests grow combinatorially | Two or three cases, rarely changing |
| Interface plus registry (strategy) | New cases are additive; small reviews | Indirection; changing the interface touches every rule | Variants are added often; operations are stable |
| Sum type with exhaustive match | Compiler finds every place to update; easy to add operations | Adding a variant edits every match | Variant set is closed; operations grow |
| Rules as data (DSL, config) | Non-engineers can change behaviour without deploys | No types, weak tests, hard debugging | Many simple, uniform rules owned by operations staff |
Pitfalls in production
- Rules that do I/O (look up the customer, call a fraud API) inside the pricing loop, adding latency and partial failure to every quote. Fix: load the context once and pass it in; keep rules pure.
- Order defined implicitly by import order or map iteration, which in Go is randomised. Fix: an explicit ordered list or priority field, tested.
- Stacking promotions that together exceed the price. Fix: the engine clamps each discount, and business caps (one promotion per category) live in the engine, not in each rule.
- Changing the rule interface "just slightly" and editing thirty rules in one pull request. Fix: add a new optional capability or a context object that can grow without breaking existing rules.
- Seams with one implementation, kept "for the future". Fix: inline them; reintroduce the seam when the second variant arrives.
Leadership lens
- Ask which change recurs. Look at six months of commits to a hot file; if most add a case, put a seam there. If they change the core algorithm, a seam will not help.
- An additive seam changes who can safely contribute: a new team member or an agent can add a rule without understanding the engine. That is the organisational payoff.
- Own the invariants centrally. Ordering, caps and clamping belong to the engine's team, with tests, even when rule authors are spread across the company.
- Push back on configuration-driven "rule engines" unless non-engineers truly need to change behaviour without a deploy; the debugging and testing cost is large.
Check yourself
Your notification service supports email, SMS and push. Product asks for "quiet hours" across all channels. Does a
Channelinterface 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.
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.
When is an exhaustive
switchbetter 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.
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
- Nothing is Something (Sandi Metz, RailsConf 2015): replacing conditionals with injected collaborators so new behaviour is added, not edited in.
- All the Little Things (Sandi Metz, RailsConf 2014): a growing conditional refactored into small polymorphic objects, with an honest look at the intermediate mess.
- Breaking Dependencies: The SOLID Principles (Klaus Iglberger, CppCon 2020): OCP in terms of dependency direction and the cost of the wrong abstraction.
Free resources
- The Open Closed Principle (Robert C. Martin): a short restatement with plugin architectures as the main example.
- Replace Conditional with Polymorphism (refactoring.com): the mechanical refactoring that turns a branch chain into a seam.
- The Wrong Abstraction (Sandi Metz): why a premature seam can cost more than duplication.
- Yagni (Martin Fowler): the cost model behind not building extension points early.
- Expression problem (Wikipedia): why no design is open on both the variant and the operation axes at once.
- Go interfaces (Russ Cox): how itabs are laid out and cached, which is what an interface call actually costs.