Open-Closed Principle
Core Idea
Open-Closed Principle states that a module should stay open for extension and closed for modification. New behavior enters through new types, not through repeated edits in stable core logic. This principle protects change safety. It lowers regression risk in mature code paths.
The goal is not zero edits forever. The goal is stable extension points. A team should define where variation belongs. Then new variants plug into that boundary.
Conceptual Overview
The shared system context in this handbook is the car intelligence and marketplace platform. Examples in this page follow that context: Hypothetical Scenario.
A core use case ranks cars for a user profile. Ranking blends reliability, ownership cost, market value trends, and lifestyle fit. Business policy evolves over time. One quarter adds child-seat safety emphasis. A later quarter adds electric charging access scoring. If the core ranking class changes on each new policy, that class grows unstable.
Open-Closed design introduces extension points.
A ScoringRule contract defines one method.
Each rule type owns one scoring concern.
The orchestrator loops through rules and aggregates scores.
A new policy enters as a new class that implements the same contract.
Core orchestration code stays unchanged.
The diagram shows a stable orchestrator that accepts rule plugins. New rule classes extend behavior with no edits in the orchestrator.
Computing History
Bertrand Meyer introduced Open-Closed Principle in 1988. The principle came from Eiffel design practice and contract-centered software construction. Later SOLID guidance placed OCP in broad object-oriented practice.
Sources: Meyer (1988)
Quote
"software entities should be open for extension, but closed for modification"
Source: Meyer, 1988
Generic Example: Python
Extension contract:
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class CandidateCar:
id: str
reliability: float
yearly_cost: float
market_trend: float
charging_access: float
@dataclass(frozen=True)
class UserProfile:
profile_id: str
needs_child_seat: bool
wants_electric: bool
class ScoringRule(Protocol):
def score(self, candidate: CandidateCar, profile: UserProfile) -> float:
raise NotImplementedError
Stable orchestrator:
class CarMatchScorer:
def __init__(self, rules: list[ScoringRule]):
self.rules = rules
def score(self, candidate: CandidateCar, profile: UserProfile) -> float:
total = 0.0
for rule in self.rules:
total += rule.score(candidate, profile)
return total
Rule extensions:
class ReliabilityRule:
def score(self, candidate: CandidateCar, profile: UserProfile) -> float:
return candidate.reliability * 0.35
class CostRule:
def score(self, candidate: CandidateCar, profile: UserProfile) -> float:
return max(0.0, 1.0 - candidate.yearly_cost / 12000) * 0.30
class ChildSeatSafetyRule:
def score(self, candidate: CandidateCar, profile: UserProfile) -> float:
if profile.needs_child_seat:
return candidate.reliability * 0.20
return 0.0
New policy integration uses a new class only.
CarMatchScorer stays unchanged.
Generic Example: TypeScript
type CandidateCar = {
id: string;
reliability: number;
yearlyCost: number;
marketTrend: number;
chargingAccess: number;
};
type UserProfile = {
profileId: string;
needsChildSeat: boolean;
wantsElectric: boolean;
};
interface ScoringRule {
score(candidate: CandidateCar, profile: UserProfile): number;
}
class CarMatchScorer {
constructor(private readonly rules: ScoringRule[]) {}
score(candidate: CandidateCar, profile: UserProfile): number {
return this.rules.reduce((sum, rule) => sum + rule.score(candidate, profile), 0);
}
}
class ChargingAccessRule implements ScoringRule {
score(candidate: CandidateCar, profile: UserProfile): number {
if (!profile.wantsElectric) {
return 0;
}
return candidate.chargingAccess * 0.15;
}
}
Generic Example: Rust
Rust traits give a direct way to define extension contracts.
The scorer struct depends on a ScoringRule trait.
New rule behavior enters as new types that implement the trait.
The scorer code stays unchanged.
#[derive(Debug, Clone)]
struct CandidateCar {
reliability: f64,
yearly_cost: f64,
charging_access: f64,
}
#[derive(Debug, Clone)]
struct UserProfile {
needs_child_seat: bool,
wants_electric: bool,
}
trait ScoringRule {
fn score(&self, candidate: &CandidateCar, profile: &UserProfile) -> f64;
}
struct CarMatchScorer {
rules: Vec<Box<dyn ScoringRule>>,
}
impl CarMatchScorer {
fn new(rules: Vec<Box<dyn ScoringRule>>) -> Self {
Self { rules }
}
fn score(&self, candidate: &CandidateCar, profile: &UserProfile) -> f64 {
self.rules
.iter()
.map(|rule| rule.score(candidate, profile))
.sum()
}
}
Rule implementations:
struct ReliabilityRule;
impl ScoringRule for ReliabilityRule {
fn score(&self, candidate: &CandidateCar, _profile: &UserProfile) -> f64 {
candidate.reliability * 0.35
}
}
struct CostRule;
impl ScoringRule for CostRule {
fn score(&self, candidate: &CandidateCar, _profile: &UserProfile) -> f64 {
(1.0 - candidate.yearly_cost / 12_000.0).max(0.0) * 0.30
}
}
New policy extension with no scorer edits:
struct ChargingAccessRule;
impl ScoringRule for ChargingAccessRule {
fn score(&self, candidate: &CandidateCar, profile: &UserProfile) -> f64 {
if profile.wants_electric {
candidate.charging_access * 0.15
} else {
0.0
}
}
}
fn build_scorer() -> CarMatchScorer {
CarMatchScorer::new(vec![
Box::new(ReliabilityRule),
Box::new(CostRule),
Box::new(ChargingAccessRule),
])
}
This model keeps the orchestrator stable. Policy growth happens through new trait implementations.
Relationship to Other Principles
Open-Closed Principle works best with complementary principles.
Link to Single Responsibility Principle: Extension classes stay small when each class owns one concern.
Link to Liskov Substitution Principle: Each extension class must honor the shared scoring contract.
Link to Dependency Inversion:
The orchestrator depends on ScoringRule abstraction, not concrete rule classes.
Link to Demeter's Law: Rule classes should call direct collaborators only. No deep object traversal in scoring logic.
Link to Dependency Injection: A composition root assembles the rule list and injects it into the orchestrator.
Trade-offs and Failure Modes
Open-Closed design increases type count. A small project can feel heavy if each variation gets a class. Some teams then create too many tiny rules with weak naming. Review time rises when rule intent is unclear.
Common trade-offs:
- more files and interfaces
- need for strong naming rules
- need for assembly logic in composition root
- extra test cases for each extension class
Good control tactics:
- create extension points only for known change axes
- merge rules that always change together
- track rule count and remove dead rules
- keep contract tests for extension classes
Foundational Principle Links
This principle links to Modularity and Composition. Extension classes compose into one stable orchestrator.
This principle links to Abstraction and Boundaries. Extension points are boundary contracts.
This principle links to Simplicity First. Stable orchestration logic reduces change risk in core paths.
This principle links to Correctness and Testing. Rule-level tests isolate one scoring concern per case.
Practice Checklist
- define explicit extension points for known policy variation
- keep core orchestration free from variant condition chains
- require contract tests for every extension class
- prefer one rule per business concern
- inject extension sets from a composition root
- review extension names for domain clarity
Written by: Pedro Guzmán
See References for complete APA-style bibliographic entries used on this page.