Skip to content
FollowtheDamnCode

Open/Closed Principle

Add new behaviour by adding code, not by editing code that already works.

Big idea

The Open/Closed Principle (the O in SOLID): software should be open for extension, closed for modification. When a new case arrives — a new discount, a new file format — you should be able to add a new class or function and leave the tested, deployed code alone.

The problem it solves

A DiscountService has grown a ladder:

if customer.kind == "student": ...
elif customer.kind == "senior": ...
elif customer.kind == "employee": ...

Every new promotion edits this function. Each edit risks breaking the earlier cases, needs the whole function re-tested, and lands as a merge conflict with whoever is adding the other promotion this sprint.

Think of it like

A power strip. You add a fan by plugging it in — you don't rewire the strip. The strip is closed (nobody opens it up) and open (anything with the right plug works). The plug shape is the abstraction that makes both true.

How it works

Find the part that varies, put it behind an abstraction, and make the stable code depend only on that abstraction. New variants are new implementations, registered from the outside.

New rules plug in; Checkout never changes

In code

Adding a rule without opening Checkout
class StudentRule:
    def applies(self, cart): return cart["customer"] == "student"
    def discount(self, cart): return 0.10 * cart["subtotal"]

class BulkRule:
    def applies(self, cart): return cart["items"] >= 10
    def discount(self, cart): return 5.0


class Checkout:                                   # closed: never edited for new rules
    def __init__(self, rules):
        self.rules = list(rules)

    def price(self, cart):
        best = max((r.discount(cart) for r in self.rules if r.applies(cart)), default=0)
        return round(cart["subtotal"] - best, 2)


cart = {"customer": "student", "items": 12, "subtotal": 80.0}
print(Checkout([StudentRule(), BulkRule()]).price(cart))

class FestivalRule:                               # open: a new class, added from outside
    def applies(self, cart): return True
    def discount(self, cart): return 0.15 * cart["subtotal"]

print(Checkout([StudentRule(), BulkRule(), FestivalRule()]).price(cart))
output
72.0
68.0

Run and edit it in the app.

FestivalRule arrived and Checkout didn't change — no retest of its logic, no conflict with the next rule.

What does "closed for modification" mean in practice?

Watch it

You can't close against everything

Closure is always with respect to some kind of change. Checkout above is closed against new discount rules, but not against "discounts now stack". Guess where variation will come from — or better, wait for the second or third real case, then refactor to open that axis. Abstracting every axis up front is speculative generality (YAGNI).

When to use it — and when not to

Apply it where new variants arrive regularly (payment methods, export formats, pricing rules, plugins). Leave simple code simple where the cases are few and stable; a two-branch if that hasn't changed in a year doesn't need a plugin system.

Pros and cons

ProsCons
New features don't destabilise old onesNeeds an abstraction, which costs indirection
Parallel work: each variant is its own fileGuessing the wrong axis wastes effort
Old tests stay validVariants scattered across many small classes

How it relates

Checkout accepts any number of DiscountRule objects. Which new requirement still forces you to edit Checkout?

Key terms

  • Open/Closed Principle — Code should accept new behaviour through extension, without editing what already works.
  • Extension point — The abstraction where new variants plug in.
  • Axis of change — A direction in which requirements tend to vary, such as "new payment method".