Skip to main content

Open/closed principle

Classes should be open for extension but closed for modification.

The two halves sound like they cancel out. They do not, because they describe different activities. A class is open if you can build on it from the outside - subclass it, implement its interface, plug something new into it. A class is closed if it is complete: its interface is settled, other code relies on it, and nobody needs to reopen the file to add a feature.

Formal statement

A module is open/closed with respect to a given axis of variation if new variants along that axis can be added by writing new code - a new class, a new implementation of an existing interface - without editing a single line of the module's own source, and without recompiling or redeploying its existing clients. "Closed" is always closed with respect to something: Order stays closed against new shipping methods while remaining wide open to, say, a new getTotalWeight() method nobody anticipated. Pick the axis worth protecting; you cannot close a class against every possible future change, and trying to produces the over-abstracted version of this principle described below.

The point of holding both halves at once is simple: new features should not be able to break old ones. If shipping a new requirement means editing a class that is already developed, tested, reviewed and depended upon, you are gambling with code that was working an hour ago.

The example

An e-commerce Order class calculates shipping costs, and every shipping method lives inside it as a branch of an if. Ground shipping has a free-delivery threshold, air shipping is a flat rate, each has its own delivery estimate. It works, right up until sales promises customers same-day delivery.

Now adding a shipping method means editing Order, which is the class that also computes totals, and which every checkout path in the application runs through. The risk is entirely disproportionate to the feature.

Extract shipping methods into their own classes behind a common Shipping interface, and the new method arrives as a new class. Order is not touched. Client code links the order to whichever shipping object the user picked in the UI, and that is the only place that knows concrete types.

// Every shipping method the company will ever offer,
// hardcoded into the class that also handles orders.
class Order is
field lineItems: list
field shipping: string
 
method getTotal() is
total = 0
foreach item in lineItems
total += item.price * item.quantity
return total
 
method getShippingCost() is
if (shipping == "ground")
// free ground shipping on large orders
if (getTotal() > 100)
return 0
return max(10, getTotal() * 0.03)
else if (shipping == "air")
return 100
 
method getShippingDate() is
if (shipping == "ground")
return today() + 5 days
else if (shipping == "air")
return today() + 1 day
 
// Adding same-day delivery means editing Order.
// Order is tested, reviewed, and used by half the app.
Order- lineItems- shipping+ getTotal()+ getTotalWeight()+ setShippingType(st)+ getShippingCost()+ getShippingDate()if (shipping == "ground") { // free ground delivery on big orders if (getTotal() > 100) return 0 // $1.5 per kilogram, $10 minimum return max(10, getTotalWeight() * 1.5)}if (shipping == "air") { // $3 per kilogram, $20 minimum return max(20, getTotalWeight() * 3)}
BEFORE: every new shipping method edits Order.
Order- lineItems- shipping: Shipping+ getTotal()+ getTotalWeight()+ setShippingType(sh)+ getShippingCost()+ getShippingDate()«interface»Shipping+ getCost(order)+ getDate(order)Ground...+ getCost(order)+ getDate(order)Air...+ getCost(order)+ getDate(order)return shipping.getCost(this)
implementsaggregation
AFTER: a new shipping method is a new class, and Order never moves.

Two things worth noticing. First, this is the Strategy pattern, again arrived at by following a principle rather than reaching for a catalog. Second, moving the delivery-time calculation next to the cost calculation also fixed a single responsibility problem for free - the principles tend to pull in the same direction.

A second example

A Checkout class calculates sales tax with an if per country - no VAT logic at all for the US, a flat 20% for the UK, 19% for Germany. It is the same shape as the shipping example in a completely different domain: finance instead of logistics, but still one class growing a new branch every time the business expands into a market.

Extracting a TaxRule interface with one implementing class per jurisdiction means launching in a new country is a new TaxRule, reviewed and tested on its own, dropped into Checkout through the same constructor parameter every other jurisdiction already uses.

// Every country's tax rule, hardcoded into the checkout.
class Checkout is
field subtotal: number
field country: string
 
method getTax() is
if (country == "US")
return subtotal * 0.0 // sales tax handled at the register
else if (country == "UK")
return subtotal * 0.20
else if (country == "DE")
return subtotal * 0.19
 
// A new market means a new branch, in the class every
// order in every country already runs through.

The smell

A conditional - if/else if or switch - branching on a type code (a country string, a shipping-method string, an enum tag), where each arm is a self-contained rule and the list of arms has grown at least once since the code was written. The tell is in the git history more than the code itself: a file that gets a new commit every time the business adds a variant is the principle's target, not a stylistic complaint about if statements in general.

Patterns that satisfy it

This is the principle Strategy and Decorator both exist to serve. Strategy swaps the whole algorithm behind one interface (shipping cost calculation, tax calculation) so a new variant is a new class. Decorator adds behavior around an existing object without touching its class, which is open/closed applied to composition instead of branching logic - wrap an object in a new decorator instead of editing it to support a new combination of features.

Where it goes wrong

The principle is not a ban on editing classes.

  • Bugs get fixed in place. If a class is wrong, correct it. Creating a subclass to route around a defect makes the child responsible for the parent's mistakes, and now you have two problems and a confusing hierarchy.
  • Speculative extension points are a tax. Every seam you carve out costs indirection: one more interface to name, one more file to open, one more hop when reading a stack trace. Pay it where variation is plausible, not everywhere.
  • Two variants is not always a pattern. Ground and air, with no third on the roadmap and no team asking, is a perfectly respectable if. Extract on the second or third genuine change request, when the direction of variation is known rather than guessed.

The tell that you actually need this: a conditional that has grown one arm per variant, and a history of commits that all touch it. The honest cost of extracting too early is a codebase of one-implementation interfaces - Shipping with only Ground, TaxRule with only FlatVAT - which is indirection sold as flexibility nobody has used yet.

Try it yourself: Extract Order's getShippingCost()/getShippingDate() if/else chain into a Shipping interface with one implementing class per shipping method, then rewrite Order so adding same-day delivery never requires touching Order again.

Check yourself

Question 1 of 4

How can one class be open and closed at the same time?