- Low Level Design
- /
- Open/Closed Principle
Course content
The Open/Closed Principle says that software entities (classes, modules, functions) should be open for extension but closed for modification. In practice this means you should be able to add a new behaviour to a system by writing new code, not by editing existing code that already works.
The goal is stability. Code that has been written, tested, and shipped is hardened against bugs. Editing it puts that hardening at risk. If a class is structured so that new requirements can be added as new subclasses or new strategies plugged in, the original class never has to change, and its tests never have to be rerun for the new feature.
In this module's running example, the checkout backend of an online store (the full tour), this principle's slice is PaymentProcessor, the class that computes the final charge for every payment. It is the most-edited file in that project, and this page is about why that is a design smell rather than a fact of life. As with every concept in the module, the page stands on its own.
Imagine a PaymentProcessor that supports credit cards and UPI. Six months later the business adds wallet payments, then BNPL, then Apple Pay. If process is implemented as a series of if statements that branch on the payment type, every one of those new requirements forces an edit to the same method.
The consequence is that one method becomes a bottleneck. Two engineers working on two different payment types have to coordinate changes to the same file. A merge conflict in the wrong place produces a regression in a payment type that nobody touched. The class that should be the most stable in the system becomes the most edited.
Designing for OCP from the start avoids this. The processor depends on an abstraction (Payment), and each concrete payment type carries its own rule. New types are new files. The processor never changes.
| Smell | What it looks like | Why it's a problem |
|---|---|---|
| instanceof chain on a parameter type | if (p instanceof Foo) ... else if (p instanceof Bar) ... | Each new subtype forces an edit to this method |
| Switch on an enum that names types | switch (p.getType()) { case CREDIT: ...; case DEBIT: ... } | Same problem with a different syntax |
| A method called "process" or "handle" with multiple branches | process(Order o), handle(Event e) | These methods accumulate branches over time |
| Map of type name to handler that is edited per release | handlers.put("credit", new CreditHandler()) | Better, but the registration call still grows with each release |
| Bug fix for one type accidentally breaks another | Edit to credit card branch breaks debit card flow | All branches share the same method, so any change is high-risk |
The processor knows about every concrete payment type by name. When the business adds wallet payments, this method has to grow another else if branch. When it adds BNPL, another. When it adds Apple Pay, another.
Each edit puts the previous branches at risk. Every release has to retest all the existing payment types, not just the new one, because they all share the same method body. The processor is the most-edited class in the system, even though its job (compute a charge) has not changed at all.
What this costs in production
Adding wallet payments meant another else if in PaymentProcessor.process, the most-edited file in the system. The new branch was fine, but the edit sat next to the credit-card branch and a careless merge dropped the 1.025 surcharge multiplier. Every card transaction undercharged by 2.5% for two days, because a change that had nothing to do with cards still shipped inside the same method body.
Each subclass now owns the one piece of behaviour that varies between subclasses: the fee rule. PaymentProcessor.process no longer knows about any specific payment type. It only knows that any Payment can compute its own total charge.
Adding a new payment type is purely additive. A WalletPayment class with its own getTotalCharge slots in. The compiler is satisfied because WalletPayment is a Payment. PaymentProcessor does not change, its tests do not need to rerun, and the team responsible for credit card payments can ignore the wallet release entirely.
The principle calls this "closed for modification" because nothing in the existing files has to be edited to support the new requirement. The class is also "open for extension" because the abstraction (Payment) is designed to be extended by new subclasses.
The formal statement says software entities, and it means it. The principle applies to classes, to functions, and to whole modules, and a common interview probe is whether it applies to fields, so it is worth being precise about all four.
Classes are the case you have just seen: the entity is closed because new behaviour arrives as a new subclass through polymorphism.
Functions are closed for modification when the varying behaviour is passed in as a parameter. Your language's sort is the canonical example (Collections.sort with a Comparator in Java, sorted with a key in Python, std::sort with a comparator in C++): sort has not changed in decades, yet it is extended with every new ordering ever written, because the varying part travels in as an argument. The refactored PaymentProcessor and a comparator-accepting sort are the same move in different clothes; one plugs behaviour in through subtyping, the other through a function argument.
Modules are closed for modification when new capability plugs in through a registration point. JDBC is the textbook case: DriverManager was written years before your database driver existed, yet dropping a driver JAR on the classpath extends it without editing a line, because drivers register themselves through ServiceLoader. IDE plugins, Python's package entry points, and C++ hosts that load plugins as shared libraries work the same way. The map-of-handlers pattern from the smell table is the small-scale version of this, and it is genuinely more open than an if chain provided registration happens in one place, away from the logic.
Fields are the odd one out. A field is not a unit of behaviour, so open for extension does not apply to it. What the question is usually reaching for is encapsulation, a different principle: keeping a field private behind an accessor lets you change its representation without breaking callers. If an interviewer asks whether the Open/Closed Principle covers fields, that distinction is the answer they want.
Six months later the business signs a wallet provider, and wallet payments carry a 1.5% fee. In the instanceof version this is one more else if inside process, an edit to the most-edited method in the system, with the merge accident from the war story waiting to repeat. In the refactored version, the entire release is one new file.
What this saves in production
The credit card path cannot regress, because not a line of it was touched; the dropped-surcharge merge from the war story has nowhere to happen. No existing payment test reruns for the release, the review is five lines, and the team that owns cards never needs to see the wallet PR.
OCP is not a license to introduce abstraction for every conditional. If you have a single if that checks one boolean, you do not need to refactor it into a strategy pattern.
A good rule is to apply OCP when you have evidence that the set of variants will grow. If you are writing code for a payment system, new payment types are inevitable, so designing for extension is justified. If you are writing code for a one-off batch script, hardcoded conditionals are fine. The principle is about controlling the cost of change, not about avoiding all conditionals.
A related trap is anticipating extension that never arrives. If you build a plugin system for a feature that is only ever used in one way, the abstraction is overhead with no payoff. Wait until the second concrete type appears before you reach for OCP. The first one tells you the shape of the problem; the second one tells you what should be variable.
The companion exercise gives you a PaymentProcessor that uses an instanceof chain to decide how to charge each payment type. Refactor it so that adding a new payment type requires no change to PaymentProcessor. The validator runs five checks. The strict one defines a brand-new payment subclass inside the validator file and verifies that your processor handles it correctly. If you keep the instanceof chain, that test fails because the chain does not list the new type.
Open the exercise: Open/Closed: Refactor PaymentProcessor.