Open/Closed Principle
The Open/Closed Principle (OCP) is the “O” in the
SOLID principles. It states that
a software entity should be open for extension but closed for modification –
you should be able to add new behavior without editing the code that already
works. A switch that grows a new branch every time a variant is added is the
canonical violation.
Why it matters
Every time you edit working code to bolt on a new case, you risk breaking the
cases that were already there, and you introduce more work.
A function that dispatches on a kind with a switch becomes a chokepoint: each
new feature reopens it, re-tests everything around it, and collides with anyone
else extending the same list. The code is never “done” – it grows without bound
in place.
“Closed for modification” means the existing, tested code stays untouched; “open for extension” means new behavior arrives as new code – a new type implementing an existing abstraction. The consumer iterates over the abstraction and never learns of the new variant, so adding one cannot regress the old ones. This is what lets a codebase grow by accretion instead of by constant surgery on its core.
Example
Consider pricing an order by applying a series of discounts. Dispatching on a discount “kind” works until the second discount type, and then every new kind means editing the pricing function itself.
enum class DiscountKind { PercentOff, FlatOff };
struct Discount {
DiscountKind kind;
int value;
};
auto total(int base, const std::vector<Discount>& discounts) -> int {
int result = base;
for (const auto& discount : discounts) {
switch (discount.kind) {
case DiscountKind::PercentOff:
result = result * (100 - discount.value) / 100;
break;
case DiscountKind::FlatOff:
result = std::max(0, result - discount.value);
break;
}
}
return result;
}
package pricing
type DiscountKind int
const (
PercentOff DiscountKind = iota
FlatOff
)
type Discount struct {
Kind DiscountKind
Value int
}
func Total(base int, discounts []Discount) int {
result := base
for _, discount := range discounts {
switch discount.Kind {
case PercentOff:
result = result * (100 - discount.Value) / 100
case FlatOff:
result = max(0, result-discount.Value)
}
}
return result
}
from dataclasses import dataclass
from enum import Enum, auto
class DiscountKind(Enum):
PERCENT_OFF = auto()
FLAT_OFF = auto()
@dataclass(frozen=True)
class Discount:
kind: DiscountKind
value: int
def total(base: int, discounts: list[Discount]) -> int:
result = base
for discount in discounts:
if discount.kind is DiscountKind.PERCENT_OFF:
result = result * (100 - discount.value) // 100
elif discount.kind is DiscountKind.FLAT_OFF:
result = max(0, result - discount.value)
return result
pub enum Discount {
PercentOff(u32),
FlatOff(u32),
}
pub fn total(base: u32, discounts: &[Discount]) -> u32 {
let mut result = base;
for discount in discounts {
result = match discount {
Discount::PercentOff(percent) => result * (100 - percent) / 100,
Discount::FlatOff(amount) => result.saturating_sub(*amount),
};
}
result
}
A “buy one, get one” or “loyalty tier” discount cannot be added without reopening
total, extending the enum, and adding another branch – so the function is a
magnet for change.
Inverting the dependency fixes it. A PriceRule abstraction with a single
apply method lets each discount be its own type, and total folds the rules
over the running amount without knowing which kinds exist.
class PriceRule {
public:
virtual ~PriceRule() = default;
virtual auto apply(int cents) const -> int = 0;
};
class PercentOff final : public PriceRule {
public:
explicit PercentOff(int percent) : m_percent(percent) {}
auto apply(int cents) const -> int override {
return cents * (100 - m_percent) / 100;
}
private:
int m_percent;
};
class FlatOff final : public PriceRule {
public:
explicit FlatOff(int amount) : m_amount(amount) {}
auto apply(int cents) const -> int override {
return std::max(0, cents - m_amount);
}
private:
int m_amount;
};
auto total(int base, const std::vector<const PriceRule*>& rules) -> int {
int result = base;
for (const auto* rule : rules) {
result = rule->apply(result);
}
return result;
}
package pricing
type PriceRule interface {
Apply(cents int) int
}
type PercentOff struct{ Percent int }
func (p PercentOff) Apply(cents int) int {
return cents * (100 - p.Percent) / 100
}
type FlatOff struct{ Amount int }
func (f FlatOff) Apply(cents int) int {
return max(0, cents-f.Amount)
}
func Total(base int, rules []PriceRule) int {
result := base
for _, rule := range rules {
result = rule.Apply(result)
}
return result
}
from dataclasses import dataclass
from typing import Protocol
class PriceRule(Protocol):
def apply(self, cents: int) -> int: ...
@dataclass(frozen=True)
class PercentOff:
percent: int
def apply(self, cents: int) -> int:
return cents * (100 - self.percent) // 100
@dataclass(frozen=True)
class FlatOff:
amount: int
def apply(self, cents: int) -> int:
return max(0, cents - self.amount)
def total(base: int, rules: list[PriceRule]) -> int:
result = base
for rule in rules:
result = rule.apply(result)
return result
pub trait PriceRule {
fn apply(&self, cents: u32) -> u32;
}
pub struct PercentOff {
pub percent: u32,
}
impl PriceRule for PercentOff {
fn apply(&self, cents: u32) -> u32 {
cents * (100 - self.percent) / 100
}
}
pub struct FlatOff {
pub amount: u32,
}
impl PriceRule for FlatOff {
fn apply(&self, cents: u32) -> u32 {
cents.saturating_sub(self.amount)
}
}
pub fn total(base: u32, rules: &[Box<dyn PriceRule>]) -> u32 {
rules.iter().fold(base, |cents, rule| rule.apply(cents))
}
A new discount is now a new type implementing PriceRule; total and every
existing rule stay exactly as they are:
classDiagram
class PriceRule {
<<interface>>
+apply(cents) int
}
class PercentOff {
+apply(cents) int
}
class FlatOff {
+apply(cents) int
}
class total {
<<function>>
}
PriceRule <|.. PercentOff
PriceRule <|.. FlatOff
total ..> PriceRuleOCP here rests on the other principles. total depends on the PriceRule
abstraction rather than concrete discounts, which is
dependency inversion;
that abstraction carries a single method, satisfying
interface segregation;
and each rule does one thing, satisfying
single responsibility.
The extension is only safe because every rule honors the same contract – take
a price, return a price no greater than it – which is exactly
Liskov substitution
at work.
References
- The Open Closed Principle – Martin on why abstraction is the mechanism that makes code closed to modification yet open to extension.
- Open–closed principle – covers both Meyer’s original inheritance-based form and Martin’s polymorphic form.