Low Level DesignSOLID principles
Open/closed principle
The open/closed principle (OCP) through three real examples, from checkout discounts to notification channels to a plugin system, with live demos, class diagrams and code in five languages.
- Read first
- Single responsibility
- Patterns
- Strategy
The open/closed principle says code should be open for extension and closed for modification. In practice, it works like this. A new variant of something arrives: a new kind of discount, a new notification channel, a new file format. You add it by writing new code, not by editing code that already works.
Bertrand Meyer coined it in 1988, and he meant inheritance: once a class is published, leave it alone and extend it with subclasses. The version taught today comes from Robert Martin in the 1990s: code depends on an interface, and new behavior arrives as new implementations of it. The code that uses the interface is “closed” because it never has to change to support them.
Here’s the part that is usually left out: no code is closed against every change. You pick the kinds of change you expect, and close against those. This page shows how to pick, in three real systems. Pick a level below. The rest of the page is shared.
The shape
Section titled “The shape”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Three things make it work:
- An extension point that asks one question every variant can answer.
- A client that only talks to the extension point. It never names a variant, so a new one can’t require an edit to it.
- One place that knows the variants: startup code, configuration or a registry. Something has to choose which variants exist. Keeping that in one place is what lets everything else stay closed.
This is the Strategy pattern’s shape, and that’s no coincidence. Strategy, Decorator, Observer and most plugin systems are ways of building an extension point. OCP is the reason you’d want one.
Worked examples
Section titled “Worked examples”Discounts at a checkout
- Finding the axis of change
- Variants as classes
- What stays open
A café’s till applies promotions: 10% off, $5 off, buy one pastry get one free. Marketing invents a new kind of deal every few weeks. The checkout code charges every customer, so a bug in it is expensive.
Without the principle
Section titled “Without the principle”Each promotion is a case in the checkout, chosen by its code:
import java.util.List;
record Line(String name, String category, int unitCents, int qty) {}
// Before: every promotion the café has ever run is a case in this one method.class Checkout { int totalCents(List<Line> lines, List<String> promoCodes) { int subtotal = 0; for (Line l : lines) subtotal += l.unitCents() * l.qty();
int discount = 0; for (String code : promoCodes) { // flaw: marketing invents a new kind of deal every few weeks, and each one is an // edit here, to the method that charges every customer. One bad case breaks checkout. switch (code) { case "TENOFF" -> discount += subtotal * 10 / 100; case "FIVEOFF" -> discount += 500; case "PASTRY_BOGO" -> { for (Line l : lines) { if (l.category().equals("pastry")) discount += (l.qty() / 2) * l.unitCents(); } } // flaw: a promo code typed into the admin panel fails at the till, not at setup. default -> throw new IllegalArgumentException("unknown promo " + code); } } return Math.max(0, subtotal - discount); }}
class Main { public static void main(String[] args) { List<Line> cart = List.of( new Line("Flat white", "coffee", 450, 2), new Line("Croissant", "pastry", 325, 2)); System.out.println(new Checkout().totalCents(cart, List.of("TENOFF", "PASTRY_BOGO"))); // 1070 }}from dataclasses import dataclass
@dataclass(frozen=True)class Line: name: str category: str unit_cents: int qty: int
# Before: every promotion the café has ever run is a case in this one method.class Checkout: def total_cents(self, lines: list[Line], promo_codes: list[str]) -> int: subtotal = sum(l.unit_cents * l.qty for l in lines)
discount = 0 for code in promo_codes: # flaw: marketing invents a new kind of deal every few weeks, and each one is an # edit here, to the method that charges every customer. One bad case breaks checkout. match code: case "TENOFF": discount += subtotal * 10 // 100 case "FIVEOFF": discount += 500 case "PASTRY_BOGO": discount += sum((l.qty // 2) * l.unit_cents for l in lines if l.category == "pastry") case _: # flaw: a promo code typed into the admin panel fails at the till, not at setup. raise ValueError(f"unknown promo {code}") return max(0, subtotal - discount)
if __name__ == "__main__": cart = [Line("Flat white", "coffee", 450, 2), Line("Croissant", "pastry", 325, 2)] print(Checkout().total_cents(cart, ["TENOFF", "PASTRY_BOGO"])) # 1070using System;using System.Collections.Generic;using System.Linq;
record Line(string Name, string Category, int UnitCents, int Qty);
// Before: every promotion the café has ever run is a case in this one method.class Checkout{ public int TotalCents(IReadOnlyList<Line> lines, IEnumerable<string> promoCodes) { var subtotal = lines.Sum(l => l.UnitCents * l.Qty);
var discount = 0; foreach (var code in promoCodes) { // flaw: marketing invents a new kind of deal every few weeks, and each one is an // edit here, to the method that charges every customer. One bad case breaks checkout. discount += code switch { "TENOFF" => subtotal * 10 / 100, "FIVEOFF" => 500, "PASTRY_BOGO" => lines.Where(l => l.Category == "pastry").Sum(l => (l.Qty / 2) * l.UnitCents), // flaw: a promo code typed into the admin panel fails at the till, not at setup. _ => throw new ArgumentException($"unknown promo {code}"), }; } return Math.Max(0, subtotal - discount); }}
static class Program{ static void Main() { var cart = new[] { new Line("Flat white", "coffee", 450, 2), new Line("Croissant", "pastry", 325, 2) }; Console.WriteLine(new Checkout().TotalCents(cart, new[] { "TENOFF", "PASTRY_BOGO" })); // 1070 }}package main
import "fmt"
type Line struct { Name, Category string UnitCents, Qty int}
// TotalCents before: every promotion the café has ever run is a case in this one function.func TotalCents(lines []Line, promoCodes []string) (int, error) { subtotal := 0 for _, l := range lines { subtotal += l.UnitCents * l.Qty }
discount := 0 for _, code := range promoCodes { // flaw: marketing invents a new kind of deal every few weeks, and each one is an // edit here, to the function that charges every customer. One bad case breaks checkout. switch code { case "TENOFF": discount += subtotal * 10 / 100 case "FIVEOFF": discount += 500 case "PASTRY_BOGO": for _, l := range lines { if l.Category == "pastry" { discount += (l.Qty / 2) * l.UnitCents } } default: // flaw: a promo code typed into the admin panel fails at the till, not at setup. return 0, fmt.Errorf("unknown promo %s", code) } } return max(0, subtotal-discount), nil}
func main() { cart := []Line{{"Flat white", "coffee", 450, 2}, {"Croissant", "pastry", 325, 2}} total, _ := TotalCents(cart, []string{"TENOFF", "PASTRY_BOGO"}) fmt.Println(total) // 1070}interface Line { readonly name: string; readonly category: string; readonly unitCents: number; readonly qty: number;}
// Before: every promotion the café has ever run is a case in this one method.class Checkout { totalCents(lines: readonly Line[], promoCodes: readonly string[]): number { const subtotal = lines.reduce((s, l) => s + l.unitCents * l.qty, 0);
let discount = 0; for (const code of promoCodes) { // flaw: marketing invents a new kind of deal every few weeks, and each one is an // edit here, to the method that charges every customer. One bad case breaks checkout. switch (code) { case "TENOFF": discount += Math.floor((subtotal * 10) / 100); break; case "FIVEOFF": discount += 500; break; case "PASTRY_BOGO": for (const l of lines) if (l.category === "pastry") discount += Math.floor(l.qty / 2) * l.unitCents; break; default: // flaw: a promo code typed into the admin panel fails at the till, not at setup. throw new Error(`unknown promo ${code}`); } } return Math.max(0, subtotal - discount); }}
const cart: Line[] = [ { name: "Flat white", category: "coffee", unitCents: 450, qty: 2 }, { name: "Croissant", category: "pastry", unitCents: 325, qty: 2 },];console.log(new Checkout().totalCents(cart, ["TENOFF", "PASTRY_BOGO"])); // 1070Every new deal edits the method that charges every customer. A slip in the new case, or a merge conflict with another deal being added the same week, breaks checkout for everyone. And a promo code typed wrong in the admin panel is found when a customer pays.
With OCP
Section titled “With OCP”The axis of change is clear from the history: new kinds of deal. So close the checkout against that. Each deal answers one question, “how much do you take off this cart?”, and the checkout adds up the answers.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.List;
record Line(String name, String category, int unitCents, int qty) {}
// The extension point: "how much does this deal take off this cart?"// Checkout depends on this question, never on any particular deal.interface Discount { int cents(List<Line> lines);}
// Each kind of deal is its own class, carrying its own settings. A new kind of deal is a// new class; Checkout and the existing deals are not opened.record PercentOff(int percent) implements Discount { public int cents(List<Line> lines) { return Checkout.subtotal(lines) * percent / 100; }}
record AmountOff(int amountCents) implements Discount { public int cents(List<Line> lines) { return amountCents; }}
record BuyOneGetOne(String category) implements Discount { public int cents(List<Line> lines) { int off = 0; for (Line l : lines) if (l.category().equals(category)) off += (l.qty() / 2) * l.unitCents(); return off; }}
// Closed for modification along one axis: new kinds of deal. It still owns the rules that// apply to every deal, like "never below zero", so those live in exactly one place.class Checkout { private final List<Discount> discounts;
Checkout(List<Discount> discounts) { this.discounts = List.copyOf(discounts); }
static int subtotal(List<Line> lines) { int sum = 0; for (Line l : lines) sum += l.unitCents() * l.qty(); return sum; }
int totalCents(List<Line> lines) { int subtotal = subtotal(lines); int off = 0; for (Discount d : discounts) off += d.cents(lines); return Math.max(0, subtotal - off); }}
class Main { public static void main(String[] args) { List<Line> cart = List.of( new Line("Flat white", "coffee", 450, 2), new Line("Croissant", "pastry", 325, 2)); // Choosing deals is configuration now. A typo fails here, when the deals are built, // not when a customer pays. A "student discount" is just new PercentOff(15): no new code. Checkout checkout = new Checkout(List.of(new PercentOff(10), new BuyOneGetOne("pastry"))); System.out.println(checkout.totalCents(cart)); // 1070 }}from dataclasses import dataclassfrom typing import Protocol
@dataclass(frozen=True)class Line: name: str category: str unit_cents: int qty: int
def subtotal(lines: list[Line]) -> int: return sum(l.unit_cents * l.qty for l in lines)
# The extension point: "how much does this deal take off this cart?"# Checkout depends on this question, never on any particular deal.class Discount(Protocol): def cents(self, lines: list[Line]) -> int: ...
# Each kind of deal is its own class, carrying its own settings. A new kind of deal is a# new class; Checkout and the existing deals are not opened.@dataclass(frozen=True)class PercentOff: percent: int
def cents(self, lines: list[Line]) -> int: return subtotal(lines) * self.percent // 100
@dataclass(frozen=True)class AmountOff: amount_cents: int
def cents(self, lines: list[Line]) -> int: return self.amount_cents
@dataclass(frozen=True)class BuyOneGetOne: category: str
def cents(self, lines: list[Line]) -> int: return sum((l.qty // 2) * l.unit_cents for l in lines if l.category == self.category)
# Closed for modification along one axis: new kinds of deal. It still owns the rules that# apply to every deal, like "never below zero", so those live in exactly one place.class Checkout: def __init__(self, discounts: list[Discount]) -> None: self._discounts = tuple(discounts)
def total_cents(self, lines: list[Line]) -> int: off = sum(d.cents(lines) for d in self._discounts) return max(0, subtotal(lines) - off)
if __name__ == "__main__": cart = [Line("Flat white", "coffee", 450, 2), Line("Croissant", "pastry", 325, 2)] # Choosing deals is configuration now. A typo fails here, when the deals are built, # not when a customer pays. A "student discount" is just PercentOff(15): no new code. checkout = Checkout([PercentOff(10), BuyOneGetOne("pastry")]) print(checkout.total_cents(cart)) # 1070using System;using System.Collections.Generic;using System.Linq;
record Line(string Name, string Category, int UnitCents, int Qty);
// The extension point: "how much does this deal take off this cart?"// Checkout depends on this question, never on any particular deal.interface IDiscount{ int Cents(IReadOnlyList<Line> lines);}
// Each kind of deal is its own class, carrying its own settings. A new kind of deal is a// new class; Checkout and the existing deals are not opened.record PercentOff(int Percent) : IDiscount{ public int Cents(IReadOnlyList<Line> lines) => Checkout.Subtotal(lines) * Percent / 100;}
record AmountOff(int AmountCents) : IDiscount{ public int Cents(IReadOnlyList<Line> lines) => AmountCents;}
record BuyOneGetOne(string Category) : IDiscount{ public int Cents(IReadOnlyList<Line> lines) => lines.Where(l => l.Category == Category).Sum(l => (l.Qty / 2) * l.UnitCents);}
// Closed for modification along one axis: new kinds of deal. It still owns the rules that// apply to every deal, like "never below zero", so those live in exactly one place.class Checkout{ private readonly IReadOnlyList<IDiscount> _discounts;
public Checkout(IEnumerable<IDiscount> discounts) => _discounts = discounts.ToList();
public static int Subtotal(IReadOnlyList<Line> lines) => lines.Sum(l => l.UnitCents * l.Qty);
public int TotalCents(IReadOnlyList<Line> lines) { var off = _discounts.Sum(d => d.Cents(lines)); return Math.Max(0, Subtotal(lines) - off); }}
static class Program{ static void Main() { var cart = new[] { new Line("Flat white", "coffee", 450, 2), new Line("Croissant", "pastry", 325, 2) }; // Choosing deals is configuration now. A typo fails here, when the deals are built, // not when a customer pays. A "student discount" is just new PercentOff(15): no new code. var checkout = new Checkout(new IDiscount[] { new PercentOff(10), new BuyOneGetOne("pastry") }); Console.WriteLine(checkout.TotalCents(cart)); // 1070 }}package main
import "fmt"
type Line struct { Name, Category string UnitCents, Qty int}
func subtotal(lines []Line) int { sum := 0 for _, l := range lines { sum += l.UnitCents * l.Qty } return sum}
// Discount is the extension point: "how much does this deal take off this cart?"// Checkout depends on this question, never on any particular deal.type Discount interface { Cents(lines []Line) int}
// Each kind of deal is its own type, carrying its own settings. A new kind of deal is a// new type; Checkout and the existing deals are not opened.type PercentOff struct{ Percent int }
func (d PercentOff) Cents(lines []Line) int { return subtotal(lines) * d.Percent / 100 }
type AmountOff struct{ AmountCents int }
func (d AmountOff) Cents([]Line) int { return d.AmountCents }
type BuyOneGetOne struct{ Category string }
func (d BuyOneGetOne) Cents(lines []Line) int { off := 0 for _, l := range lines { if l.Category == d.Category { off += (l.Qty / 2) * l.UnitCents } } return off}
// Checkout is closed for modification along one axis: new kinds of deal. It still owns the// rules that apply to every deal, like "never below zero", so those live in one place.type Checkout struct { Discounts []Discount}
func (c Checkout) TotalCents(lines []Line) int { off := 0 for _, d := range c.Discounts { off += d.Cents(lines) } return max(0, subtotal(lines)-off)}
func main() { cart := []Line{{"Flat white", "coffee", 450, 2}, {"Croissant", "pastry", 325, 2}} // Choosing deals is configuration now, checked by the compiler. A "student discount" // is just PercentOff{15}: no new code. checkout := Checkout{Discounts: []Discount{PercentOff{10}, BuyOneGetOne{"pastry"}}} fmt.Println(checkout.TotalCents(cart)) // 1070}interface Line { readonly name: string; readonly category: string; readonly unitCents: number; readonly qty: number;}
const subtotal = (lines: readonly Line[]) => lines.reduce((s, l) => s + l.unitCents * l.qty, 0);
// The extension point: "how much does this deal take off this cart?"// Checkout depends on this question, never on any particular deal.interface Discount { cents(lines: readonly Line[]): number;}
// Each kind of deal is its own class, carrying its own settings. A new kind of deal is a// new class; Checkout and the existing deals are not opened.class PercentOff implements Discount { constructor(readonly percent: number) {} cents(lines: readonly Line[]) { return Math.floor((subtotal(lines) * this.percent) / 100); }}
class AmountOff implements Discount { constructor(readonly amountCents: number) {} cents() { return this.amountCents; }}
class BuyOneGetOne implements Discount { constructor(readonly category: string) {} cents(lines: readonly Line[]) { return lines .filter((l) => l.category === this.category) .reduce((s, l) => s + Math.floor(l.qty / 2) * l.unitCents, 0); }}
// Closed for modification along one axis: new kinds of deal. It still owns the rules that// apply to every deal, like "never below zero", so those live in exactly one place.class Checkout { constructor(private readonly discounts: readonly Discount[]) {}
totalCents(lines: readonly Line[]): number { const off = this.discounts.reduce((s, d) => s + d.cents(lines), 0); return Math.max(0, subtotal(lines) - off); }}
const cart: Line[] = [ { name: "Flat white", category: "coffee", unitCents: 450, qty: 2 }, { name: "Croissant", category: "pastry", unitCents: 325, qty: 2 },];// Choosing deals is configuration now. A typo fails here, when the deals are built,// not when a customer pays. A "student discount" is just new PercentOff(15): no new code.const checkout = new Checkout([new PercentOff(10), new BuyOneGetOne("pastry")]);console.log(checkout.totalCents(cart)); // 1070Send three requests through both designs. Watch the third one closely:
- Checkoutadds up lines, and every deal in a switch
- Checkoutadds up lines and applies deals
- Discounthow much off this cart?
- PercentOff
- AmountOff
- BuyOneGetOne
- BundlePricenewcoffee + pastry for $6
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- The axis came from history, not imagination. Deals change every few weeks, so they got an extension point. The way lines are summed hasn’t changed in years, so it didn’t.
- Settings live on the variant.
PercentOff(10)andPercentOff(15)are the same class with different data. A new deal with the same shape needs no code. - Rules about all variants stay in the client. “Never below zero” and “at most 50% off” apply to
every deal. So they belong to
Checkout. Pushing them into each discount would repeat them, and the one that forgets breaks the rule. - The choice moved. Which deals are active is decided when the
Checkoutis built. A typo fails then, not when a customer pays.
We add a new kind of promotion every month. How would you design the checkout?
Whether you pick the axis of change out loud (“new kinds of deal”). Whether you close the
checkout against it, with one small interface. And whether you keep the rules about combining
deals in the checkout. All in under ten minutes. The red flag is a switch on promo codes. So
is the opposite: an interface for every part of the checkout, “in case it changes”.
New kinds of deal are the change I expect. So each deal is a class behind a Discount interface. A new deal is a new class. Checkout only changes when the rules for combining deals change.
Notification channels
- A registry instead of a switch
- Interfaces shaped by the caller
- One place that knows every variant
A product sends alerts: order shipped, password changed, payment failed. Users choose how they hear about them: email, SMS, push. Next year the business wants WhatsApp, and a large customer asks for Slack. Every alert in the product goes through one notifier.
Without the principle
Section titled “Without the principle”The notifier knows every channel, how to reach a user on it, and how to format for it:
import java.util.List;
record Message(String title, String body) {}
record Recipient(String name, String email, String phone, String deviceToken, List<String> channels) {}
// Before: the notifier knows every channel, how to reach it and how to format for it.class Notifier { void notify(Recipient r, Message m) { for (String channel : r.channels()) { // flaw: adding WhatsApp means editing this method, which every alert in the // product goes through. A bug in the new branch can stop emails too. if (channel.equals("email")) { System.out.println("EMAIL to " + r.email() + ": " + m.title() + " - " + m.body()); } else if (channel.equals("sms")) { // flaw: each channel's rules (SMS is 160 characters) are tangled into the routing. String text = m.title() + ": " + m.body(); if (text.length() > 160) text = text.substring(0, 159) + "…"; System.out.println("SMS to " + r.phone() + ": " + text); } else if (channel.equals("push")) { System.out.println("PUSH to " + r.deviceToken() + ": " + m.title()); } // flaw: an unknown channel is silently ignored; nobody finds out until a user complains. } }}
class Main { public static void main(String[] args) { Recipient ada = new Recipient("Ada", "ada@example.com", "+15550100", "dev-42", List.of("email", "sms")); new Notifier().notify(ada, new Message("Order shipped", "Your order #1001 is on its way.")); }}from dataclasses import dataclass
@dataclass(frozen=True)class Message: title: str body: str
@dataclass(frozen=True)class Recipient: name: str email: str | None phone: str | None device_token: str | None channels: tuple[str, ...]
# Before: the notifier knows every channel, how to reach it and how to format for it.class Notifier: def notify(self, r: Recipient, m: Message) -> None: for channel in r.channels: # flaw: adding WhatsApp means editing this method, which every alert in the # product goes through. A bug in the new branch can stop emails too. if channel == "email": print(f"EMAIL to {r.email}: {m.title} - {m.body}") elif channel == "sms": # flaw: each channel's rules (SMS is 160 characters) are tangled into the routing. text = f"{m.title}: {m.body}" if len(text) > 160: text = text[:159] + "…" print(f"SMS to {r.phone}: {text}") elif channel == "push": print(f"PUSH to {r.device_token}: {m.title}") # flaw: an unknown channel is silently ignored; nobody finds out until a user complains.
if __name__ == "__main__": ada = Recipient("Ada", "ada@example.com", "+15550100", "dev-42", ("email", "sms")) Notifier().notify(ada, Message("Order shipped", "Your order #1001 is on its way."))using System;using System.Collections.Generic;
record Message(string Title, string Body);
record Recipient(string Name, string? Email, string? Phone, string? DeviceToken, IReadOnlyList<string> Channels);
// Before: the notifier knows every channel, how to reach it and how to format for it.class Notifier{ public void Notify(Recipient r, Message m) { foreach (var channel in r.Channels) { // flaw: adding WhatsApp means editing this method, which every alert in the // product goes through. A bug in the new branch can stop emails too. if (channel == "email") { Console.WriteLine($"EMAIL to {r.Email}: {m.Title} - {m.Body}"); } else if (channel == "sms") { // flaw: each channel's rules (SMS is 160 characters) are tangled into the routing. var text = $"{m.Title}: {m.Body}"; if (text.Length > 160) text = text[..159] + "…"; Console.WriteLine($"SMS to {r.Phone}: {text}"); } else if (channel == "push") { Console.WriteLine($"PUSH to {r.DeviceToken}: {m.Title}"); } // flaw: an unknown channel is silently ignored; nobody finds out until a user complains. } }}
static class Program{ static void Main() { var ada = new Recipient("Ada", "ada@example.com", "+15550100", "dev-42", new[] { "email", "sms" }); new Notifier().Notify(ada, new Message("Order shipped", "Your order #1001 is on its way.")); }}package main
import "fmt"
type Message struct{ Title, Body string }
type Recipient struct { Name, Email, Phone, DeviceToken string // "" when the user hasn't given one Channels []string}
// Notify before: it knows every channel, how to reach it and how to format for it.func Notify(r Recipient, m Message) { for _, channel := range r.Channels { // flaw: adding WhatsApp means editing this function, which every alert in the // product goes through. A bug in the new branch can stop emails too. switch channel { case "email": fmt.Printf("EMAIL to %s: %s - %s\n", r.Email, m.Title, m.Body) case "sms": // flaw: each channel's rules (SMS is 160 characters) are tangled into the routing. text := []rune(m.Title + ": " + m.Body) if len(text) > 160 { text = append(text[:159], '…') } fmt.Printf("SMS to %s: %s\n", r.Phone, string(text)) case "push": fmt.Printf("PUSH to %s: %s\n", r.DeviceToken, m.Title) } // flaw: an unknown channel is silently ignored; nobody finds out until a user complains. }}
func main() { ada := Recipient{"Ada", "ada@example.com", "+15550100", "dev-42", []string{"email", "sms"}} Notify(ada, Message{"Order shipped", "Your order #1001 is on its way."})}interface Message { readonly title: string; readonly body: string;}
interface Recipient { readonly name: string; readonly email?: string; readonly phone?: string; readonly deviceToken?: string; readonly channels: readonly string[];}
// Before: the notifier knows every channel, how to reach it and how to format for it.class Notifier { notify(r: Recipient, m: Message): void { for (const channel of r.channels) { // flaw: adding WhatsApp means editing this method, which every alert in the // product goes through. A bug in the new branch can stop emails too. if (channel === "email") { console.log(`EMAIL to ${r.email}: ${m.title} - ${m.body}`); } else if (channel === "sms") { // flaw: each channel's rules (SMS is 160 characters) are tangled into the routing. let text = `${m.title}: ${m.body}`; if (text.length > 160) text = text.slice(0, 159) + "…"; console.log(`SMS to ${r.phone}: ${text}`); } else if (channel === "push") { console.log(`PUSH to ${r.deviceToken}: ${m.title}`); } // flaw: an unknown channel is silently ignored; nobody finds out until a user complains. } }}
const ada: Recipient = { name: "Ada", email: "ada@example.com", phone: "+15550100", deviceToken: "dev-42", channels: ["email", "sms"],};new Notifier().notify(ada, { title: "Order shipped", body: "Your order #1001 is on its way." });Adding WhatsApp edits the method every alert goes through, so a bug in the new branch can stop emails. Each channel’s rules, like SMS’s 160-character limit, are tangled into the routing. And a channel name the code doesn’t know is silently skipped.
With OCP
Section titled “With OCP”Close the notifier against new channels. Each channel answers the same three questions: “what’s your id?”, “can you reach this person?” and “send this”. The notifier looks channels up by id in a registry, so it never names one.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.HashMap;import java.util.List;import java.util.Map;
record Message(String title, String body) {}
record Recipient(String name, String email, String phone, String deviceToken, List<String> channels) {}
// The extension point. It is shaped by what the notifier needs to ask, not by what any one// provider's API looks like: "what are you called, can you reach this person, send this".interface Channel { String id(); boolean canReach(Recipient r); void send(Recipient r, Message m);}
// Each channel owns its own rules. The 160-character limit lives with SMS, not in the router.class EmailChannel implements Channel { public String id() { return "email"; } public boolean canReach(Recipient r) { return r.email() != null; } public void send(Recipient r, Message m) { System.out.println("EMAIL to " + r.email() + ": " + m.title() + " - " + m.body()); }}
class SmsChannel implements Channel { public String id() { return "sms"; } public boolean canReach(Recipient r) { return r.phone() != null; } public void send(Recipient r, Message m) { String text = m.title() + ": " + m.body(); if (text.length() > 160) text = text.substring(0, 159) + "…"; System.out.println("SMS to " + r.phone() + ": " + text); }}
class PushChannel implements Channel { public String id() { return "push"; } public boolean canReach(Recipient r) { return r.deviceToken() != null; } public void send(Recipient r, Message m) { System.out.println("PUSH to " + r.deviceToken() + ": " + m.title()); }}
// Added a year later. Nothing above this line changed to make room for it.class WhatsAppChannel implements Channel { public String id() { return "whatsapp"; } public boolean canReach(Recipient r) { return r.phone() != null; } public void send(Recipient r, Message m) { System.out.println("WHATSAPP to " + r.phone() + ": *" + m.title() + "*\n" + m.body()); }}
// Closed to new channels: it routes by id through a registry, so it never names one.// It still owns the rules every channel shares: skip what can't be reached, and don't let// one failing channel stop the others.class Notifier { private final Map<String, Channel> channels = new HashMap<>();
Notifier(List<Channel> available) { for (Channel c : available) { // Fail at startup, not at 3 a.m.: two channels claiming one id is a wiring bug. if (channels.putIfAbsent(c.id(), c) != null) throw new IllegalStateException("duplicate channel " + c.id()); } }
void notify(Recipient r, Message m) { for (String id : r.channels()) { Channel c = channels.get(id); if (c == null) { System.out.println("warn: no channel '" + id + "' for " + r.name()); // visible, not silent continue; } if (!c.canReach(r)) continue; try { c.send(r, m); } catch (RuntimeException e) { System.out.println("warn: " + id + " failed: " + e.getMessage()); } } }}
class Main { public static void main(String[] args) { // The composition root: the one place that lists every channel. Adding WhatsApp // was one class plus this one line. Notifier notifier = new Notifier(List.of( new EmailChannel(), new SmsChannel(), new PushChannel(), new WhatsAppChannel())); Recipient ada = new Recipient("Ada", "ada@example.com", "+15550100", null, List.of("email", "whatsapp", "push")); notifier.notify(ada, new Message("Order shipped", "Your order #1001 is on its way.")); }}from dataclasses import dataclassfrom typing import Protocol
@dataclass(frozen=True)class Message: title: str body: str
@dataclass(frozen=True)class Recipient: name: str email: str | None phone: str | None device_token: str | None channels: tuple[str, ...]
# The extension point. It is shaped by what the notifier needs to ask, not by what any one# provider's API looks like: "what are you called, can you reach this person, send this".class Channel(Protocol): id: str
def can_reach(self, r: Recipient) -> bool: ... def send(self, r: Recipient, m: Message) -> None: ...
# Each channel owns its own rules. The 160-character limit lives with SMS, not in the router.class EmailChannel: id = "email"
def can_reach(self, r: Recipient) -> bool: return r.email is not None
def send(self, r: Recipient, m: Message) -> None: print(f"EMAIL to {r.email}: {m.title} - {m.body}")
class SmsChannel: id = "sms"
def can_reach(self, r: Recipient) -> bool: return r.phone is not None
def send(self, r: Recipient, m: Message) -> None: text = f"{m.title}: {m.body}" if len(text) > 160: text = text[:159] + "…" print(f"SMS to {r.phone}: {text}")
class PushChannel: id = "push"
def can_reach(self, r: Recipient) -> bool: return r.device_token is not None
def send(self, r: Recipient, m: Message) -> None: print(f"PUSH to {r.device_token}: {m.title}")
# Added a year later. Nothing above this line changed to make room for it.class WhatsAppChannel: id = "whatsapp"
def can_reach(self, r: Recipient) -> bool: return r.phone is not None
def send(self, r: Recipient, m: Message) -> None: print(f"WHATSAPP to {r.phone}: *{m.title}*\n{m.body}")
# Closed to new channels: it routes by id through a registry, so it never names one.# It still owns the rules every channel shares: skip what can't be reached, and don't let# one failing channel stop the others.class Notifier: def __init__(self, available: list[Channel]) -> None: self._channels: dict[str, Channel] = {} for c in available: # Fail at startup, not at 3 a.m.: two channels claiming one id is a wiring bug. if c.id in self._channels: raise ValueError(f"duplicate channel {c.id}") self._channels[c.id] = c
def notify(self, r: Recipient, m: Message) -> None: for channel_id in r.channels: c = self._channels.get(channel_id) if c is None: print(f"warn: no channel '{channel_id}' for {r.name}") # visible, not silent continue if not c.can_reach(r): continue try: c.send(r, m) except Exception as e: print(f"warn: {channel_id} failed: {e}")
if __name__ == "__main__": # The composition root: the one place that lists every channel. Adding WhatsApp # was one class plus this one line. notifier = Notifier([EmailChannel(), SmsChannel(), PushChannel(), WhatsAppChannel()]) ada = Recipient("Ada", "ada@example.com", "+15550100", None, ("email", "whatsapp", "push")) notifier.notify(ada, Message("Order shipped", "Your order #1001 is on its way."))using System;using System.Collections.Generic;
record Message(string Title, string Body);
record Recipient(string Name, string? Email, string? Phone, string? DeviceToken, IReadOnlyList<string> Channels);
// The extension point. It is shaped by what the notifier needs to ask, not by what any one// provider's API looks like: "what are you called, can you reach this person, send this".interface IChannel{ string Id { get; } bool CanReach(Recipient r); void Send(Recipient r, Message m);}
// Each channel owns its own rules. The 160-character limit lives with SMS, not in the router.class EmailChannel : IChannel{ public string Id => "email"; public bool CanReach(Recipient r) => r.Email is not null; public void Send(Recipient r, Message m) => Console.WriteLine($"EMAIL to {r.Email}: {m.Title} - {m.Body}");}
class SmsChannel : IChannel{ public string Id => "sms"; public bool CanReach(Recipient r) => r.Phone is not null; public void Send(Recipient r, Message m) { var text = $"{m.Title}: {m.Body}"; if (text.Length > 160) text = text[..159] + "…"; Console.WriteLine($"SMS to {r.Phone}: {text}"); }}
class PushChannel : IChannel{ public string Id => "push"; public bool CanReach(Recipient r) => r.DeviceToken is not null; public void Send(Recipient r, Message m) => Console.WriteLine($"PUSH to {r.DeviceToken}: {m.Title}");}
// Added a year later. Nothing above this line changed to make room for it.class WhatsAppChannel : IChannel{ public string Id => "whatsapp"; public bool CanReach(Recipient r) => r.Phone is not null; public void Send(Recipient r, Message m) => Console.WriteLine($"WHATSAPP to {r.Phone}: *{m.Title}*\n{m.Body}");}
// Closed to new channels: it routes by id through a registry, so it never names one.// It still owns the rules every channel shares: skip what can't be reached, and don't let// one failing channel stop the others.class Notifier{ private readonly Dictionary<string, IChannel> _channels = new();
public Notifier(IEnumerable<IChannel> available) { foreach (var c in available) { // Fail at startup, not at 3 a.m.: two channels claiming one id is a wiring bug. if (!_channels.TryAdd(c.Id, c)) throw new InvalidOperationException($"duplicate channel {c.Id}"); } }
public void Notify(Recipient r, Message m) { foreach (var id in r.Channels) { if (!_channels.TryGetValue(id, out var c)) { Console.WriteLine($"warn: no channel '{id}' for {r.Name}"); // visible, not silent continue; } if (!c.CanReach(r)) continue; try { c.Send(r, m); } catch (Exception e) { Console.WriteLine($"warn: {id} failed: {e.Message}"); } } }}
static class Program{ static void Main() { // The composition root: the one place that lists every channel. Adding WhatsApp // was one class plus this one line. var notifier = new Notifier(new IChannel[] { new EmailChannel(), new SmsChannel(), new PushChannel(), new WhatsAppChannel(), }); var ada = new Recipient("Ada", "ada@example.com", "+15550100", null, new[] { "email", "whatsapp", "push" }); notifier.Notify(ada, new Message("Order shipped", "Your order #1001 is on its way.")); }}package main
import ( "errors" "fmt")
type Message struct{ Title, Body string }
type Recipient struct { Name, Email, Phone, DeviceToken string // "" when the user hasn't given one Channels []string}
// Channel is the extension point. It is shaped by what the notifier needs to ask, not by// what any one provider's API looks like: "what are you called, can you reach this person,// send this".type Channel interface { ID() string CanReach(r Recipient) bool Send(r Recipient, m Message) error}
// Each channel owns its own rules. The 160-character limit lives with SMS, not in the router.type EmailChannel struct{}
func (EmailChannel) ID() string { return "email" }func (EmailChannel) CanReach(r Recipient) bool { return r.Email != "" }func (EmailChannel) Send(r Recipient, m Message) error { fmt.Printf("EMAIL to %s: %s - %s\n", r.Email, m.Title, m.Body) return nil}
type SmsChannel struct{}
func (SmsChannel) ID() string { return "sms" }func (SmsChannel) CanReach(r Recipient) bool { return r.Phone != "" }func (SmsChannel) Send(r Recipient, m Message) error { text := []rune(m.Title + ": " + m.Body) if len(text) > 160 { text = append(text[:159], '…') } fmt.Printf("SMS to %s: %s\n", r.Phone, string(text)) return nil}
type PushChannel struct{}
func (PushChannel) ID() string { return "push" }func (PushChannel) CanReach(r Recipient) bool { return r.DeviceToken != "" }func (PushChannel) Send(r Recipient, m Message) error { fmt.Printf("PUSH to %s: %s\n", r.DeviceToken, m.Title) return nil}
// WhatsAppChannel was added a year later. Nothing above this line changed to make room for it.type WhatsAppChannel struct{}
func (WhatsAppChannel) ID() string { return "whatsapp" }func (WhatsAppChannel) CanReach(r Recipient) bool { return r.Phone != "" }func (WhatsAppChannel) Send(r Recipient, m Message) error { fmt.Printf("WHATSAPP to %s: *%s*\n%s\n", r.Phone, m.Title, m.Body) return nil}
// Notifier is closed to new channels: it routes by id through a registry, so it never names// one. It still owns the rules every channel shares: skip what can't be reached, and don't// let one failing channel stop the others.type Notifier struct { channels map[string]Channel}
func NewNotifier(available ...Channel) (*Notifier, error) { n := &Notifier{channels: map[string]Channel{}} for _, c := range available { // Fail at startup, not at 3 a.m.: two channels claiming one id is a wiring bug. if _, dup := n.channels[c.ID()]; dup { return nil, errors.New("duplicate channel " + c.ID()) } n.channels[c.ID()] = c } return n, nil}
func (n *Notifier) Notify(r Recipient, m Message) { for _, id := range r.Channels { c, ok := n.channels[id] if !ok { fmt.Printf("warn: no channel '%s' for %s\n", id, r.Name) // visible, not silent continue } if !c.CanReach(r) { continue } if err := c.Send(r, m); err != nil { fmt.Printf("warn: %s failed: %v\n", id, err) } }}
func main() { // The composition root: the one place that lists every channel. Adding WhatsApp // was one type plus this one line. notifier, err := NewNotifier(EmailChannel{}, SmsChannel{}, PushChannel{}, WhatsAppChannel{}) if err != nil { panic(err) } ada := Recipient{"Ada", "ada@example.com", "+15550100", "", []string{"email", "whatsapp", "push"}} notifier.Notify(ada, Message{"Order shipped", "Your order #1001 is on its way."})}interface Message { readonly title: string; readonly body: string;}
interface Recipient { readonly name: string; readonly email?: string; readonly phone?: string; readonly deviceToken?: string; readonly channels: readonly string[];}
// The extension point. It is shaped by what the notifier needs to ask, not by what any one// provider's API looks like: "what are you called, can you reach this person, send this".interface Channel { readonly id: string; canReach(r: Recipient): boolean; send(r: Recipient, m: Message): void;}
// Each channel owns its own rules. The 160-character limit lives with SMS, not in the router.class EmailChannel implements Channel { readonly id = "email"; canReach(r: Recipient) { return r.email !== undefined; } send(r: Recipient, m: Message) { console.log(`EMAIL to ${r.email}: ${m.title} - ${m.body}`); }}
class SmsChannel implements Channel { readonly id = "sms"; canReach(r: Recipient) { return r.phone !== undefined; } send(r: Recipient, m: Message) { let text = `${m.title}: ${m.body}`; if (text.length > 160) text = text.slice(0, 159) + "…"; console.log(`SMS to ${r.phone}: ${text}`); }}
class PushChannel implements Channel { readonly id = "push"; canReach(r: Recipient) { return r.deviceToken !== undefined; } send(r: Recipient, m: Message) { console.log(`PUSH to ${r.deviceToken}: ${m.title}`); }}
// Added a year later. Nothing above this line changed to make room for it.class WhatsAppChannel implements Channel { readonly id = "whatsapp"; canReach(r: Recipient) { return r.phone !== undefined; } send(r: Recipient, m: Message) { console.log(`WHATSAPP to ${r.phone}: *${m.title}*\n${m.body}`); }}
// Closed to new channels: it routes by id through a registry, so it never names one.// It still owns the rules every channel shares: skip what can't be reached, and don't let// one failing channel stop the others.class Notifier { private readonly channels = new Map<string, Channel>();
constructor(available: readonly Channel[]) { for (const c of available) { // Fail at startup, not at 3 a.m.: two channels claiming one id is a wiring bug. if (this.channels.has(c.id)) throw new Error(`duplicate channel ${c.id}`); this.channels.set(c.id, c); } }
notify(r: Recipient, m: Message): void { for (const id of r.channels) { const c = this.channels.get(id); if (!c) { console.log(`warn: no channel '${id}' for ${r.name}`); // visible, not silent continue; } if (!c.canReach(r)) continue; try { c.send(r, m); } catch (e) { console.log(`warn: ${id} failed: ${(e as Error).message}`); } } }}
// The composition root: the one place that lists every channel. Adding WhatsApp// was one class plus this one line.const notifier = new Notifier([new EmailChannel(), new SmsChannel(), new PushChannel(), new WhatsAppChannel()]);const ada: Recipient = { name: "Ada", email: "ada@example.com", phone: "+15550100", channels: ["email", "whatsapp", "push"] };notifier.notify(ada, { title: "Order shipped", body: "Your order #1001 is on its way." });The one place that still knows every channel is the startup code. That is where the switch
went:
What to notice
Section titled “What to notice”- The interface is shaped by the caller.
canReachexists because the notifier needs to skip users without a phone number. It isn’t a copy of any provider’s API. When the notifier is what asks the questions, every channel can answer them. - A registry replaces the
switch. Channels are found by id. Adding one means writing the class and adding it to the list at startup, one line in the one place that knows every channel. - Shared rules stay in the notifier. Skipping unreachable users and isolating failures apply to every channel. If they lived in each channel, a new channel could forget them, and one bad provider could stop the others.
- Wiring errors fail early. Two channels with one id stop startup. An unknown id in a user’s settings is logged, not silently ignored.
A big customer wants alerts in their Slack. Walk me through the change.
Whether you say “a SlackChannel class and one line at startup, with no edit to the notifier”.
Then whether you spot what doesn’t fit. Slack posts to a workspace channel, not to a person. So
canReach needs a Slack id on the recipient. That’s a change to Recipient. Saying so is
better than pretending OCP made it free. The red flag is editing notify().
A plugin system for file imports
- Plugins you don't own
- Choosing by content, not by name
- Growing an interface without breaking plugins
A data product lets customers import files: CSV and tab-separated today. Customers’ partners want to add their own formats, and they can’t wait for our release cycle or see our code. The import code is the extension point now, and other people implement it.
That changes what “closed” means. Our own classes can be edited with a refactor. An interface that partners have built against can’t change without breaking their code, so it is the most closed thing in the system.
Without the principle
Section titled “Without the principle”The import service picks a parser by file extension and parses inline:
import java.util.ArrayList;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;
// Before: the import service knows every file format, chosen by the file's extension.class ImportService { List<Map<String, String>> importFile(String filename, String content) { // flaw: a customer's partner wants to import their own format. They can't: the only // way to add one is to edit this method and wait for our next release. // flaw: "report.csv" that is really tab-separated is parsed wrongly, with no error. String sep; if (filename.endsWith(".csv")) sep = ","; else if (filename.endsWith(".tsv")) sep = "\t"; else throw new IllegalArgumentException("unsupported file " + filename);
String[] lines = content.strip().split("\n"); String[] header = lines[0].split(sep); List<Map<String, String>> rows = new ArrayList<>(); for (int i = 1; i < lines.length; i++) { String[] cells = lines[i].split(sep); Map<String, String> row = new LinkedHashMap<>(); for (int c = 0; c < header.length; c++) row.put(header[c], c < cells.length ? cells[c] : ""); rows.add(row); } return rows; }}
class Main { public static void main(String[] args) { ImportService svc = new ImportService(); System.out.println(svc.importFile("people.csv", "name,city\nAda,London\nGrace,Arlington")); // Mislabelled file: tab-separated content with a .csv name. One column, wrong data, no error. System.out.println(svc.importFile("export.csv", "name\tcity\nAda\tLondon")); }}# Before: the import service knows every file format, chosen by the file's extension.class ImportService: def import_file(self, filename: str, content: str) -> list[dict[str, str]]: # flaw: a customer's partner wants to import their own format. They can't: the only # way to add one is to edit this method and wait for our next release. # flaw: "report.csv" that is really tab-separated is parsed wrongly, with no error. if filename.endswith(".csv"): sep = "," elif filename.endswith(".tsv"): sep = "\t" else: raise ValueError(f"unsupported file {filename}")
lines = content.strip().split("\n") header = lines[0].split(sep) rows = [] for line in lines[1:]: cells = line.split(sep) rows.append({h: cells[i] if i < len(cells) else "" for i, h in enumerate(header)}) return rows
if __name__ == "__main__": svc = ImportService() print(svc.import_file("people.csv", "name,city\nAda,London\nGrace,Arlington")) # Mislabelled file: tab-separated content with a .csv name. One column, wrong data, no error. print(svc.import_file("export.csv", "name\tcity\nAda\tLondon"))using System;using System.Collections.Generic;using System.Linq;
// Before: the import service knows every file format, chosen by the file's extension.class ImportService{ public List<Dictionary<string, string>> ImportFile(string filename, string content) { // flaw: a customer's partner wants to import their own format. They can't: the only // way to add one is to edit this method and wait for our next release. // flaw: "report.csv" that is really tab-separated is parsed wrongly, with no error. string sep; if (filename.EndsWith(".csv")) sep = ","; else if (filename.EndsWith(".tsv")) sep = "\t"; else throw new ArgumentException($"unsupported file {filename}");
var lines = content.Trim().Split('\n'); var header = lines[0].Split(sep); var rows = new List<Dictionary<string, string>>(); foreach (var line in lines.Skip(1)) { var cells = line.Split(sep); rows.Add(header.Select((h, i) => (h, v: i < cells.Length ? cells[i] : "")) .ToDictionary(x => x.h, x => x.v)); } return rows; }}
static class Program{ static string Show(List<Dictionary<string, string>> rows) => string.Join(" ", rows.Select(r => "{" + string.Join(", ", r.Select(kv => $"{kv.Key}={kv.Value}")) + "}"));
static void Main() { var svc = new ImportService(); Console.WriteLine(Show(svc.ImportFile("people.csv", "name,city\nAda,London\nGrace,Arlington"))); // Mislabelled file: tab-separated content with a .csv name. One column, wrong data, no error. Console.WriteLine(Show(svc.ImportFile("export.csv", "name\tcity\nAda\tLondon"))); }}package main
import ( "fmt" "strings")
type Row map[string]string
// ImportFile before: it knows every file format, chosen by the file's extension.func ImportFile(filename, content string) ([]Row, error) { // flaw: a customer's partner wants to import their own format. They can't: the only // way to add one is to edit this function and wait for our next release. // flaw: "report.csv" that is really tab-separated is parsed wrongly, with no error. var sep string switch { case strings.HasSuffix(filename, ".csv"): sep = "," case strings.HasSuffix(filename, ".tsv"): sep = "\t" default: return nil, fmt.Errorf("unsupported file %s", filename) }
lines := strings.Split(strings.TrimSpace(content), "\n") header := strings.Split(lines[0], sep) var rows []Row for _, line := range lines[1:] { cells := strings.Split(line, sep) row := Row{} for i, h := range header { if i < len(cells) { row[h] = cells[i] } else { row[h] = "" } } rows = append(rows, row) } return rows, nil}
func main() { rows, _ := ImportFile("people.csv", "name,city\nAda,London\nGrace,Arlington") fmt.Println(rows) // Mislabelled file: tab-separated content with a .csv name. One column, wrong data, no error. rows, _ = ImportFile("export.csv", "name\tcity\nAda\tLondon") fmt.Println(rows)}type Row = Record<string, string>;
// Before: the import service knows every file format, chosen by the file's extension.class ImportService { importFile(filename: string, content: string): Row[] { // flaw: a customer's partner wants to import their own format. They can't: the only // way to add one is to edit this method and wait for our next release. // flaw: "report.csv" that is really tab-separated is parsed wrongly, with no error. let sep: string; if (filename.endsWith(".csv")) sep = ","; else if (filename.endsWith(".tsv")) sep = "\t"; else throw new Error(`unsupported file ${filename}`);
const [head, ...lines] = content.trim().split("\n"); const header = head.split(sep); return lines.map((line) => { const cells = line.split(sep); return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""])); }); }}
const svc = new ImportService();console.log(svc.importFile("people.csv", "name,city\nAda,London\nGrace,Arlington"));// Mislabelled file: tab-separated content with a .csv name. One column, wrong data, no error.console.log(svc.importFile("export.csv", "name\tcity\nAda\tLondon"));A partner can’t add a format at all. And because the format is chosen by name, a tab-separated
file named .csv is parsed into one wide column, with no error. Users mislabel files all the
time.
With OCP
Section titled “With OCP”Publish an Importer interface. Each importer says whether it recognizes a file from its
content, and reads it. The service keeps an ordered registry and asks each importer in turn.
When a new capability is needed later, it arrives as a separate, optional interface.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;
// The published extension point. Other teams and partners implement it, so once it ships it// is the one thing that must stay closed: adding a method here breaks every plugin out there.interface Importer { String name(); // Decide from the content, not the file name. Users mislabel files all the time. boolean recognizes(String firstLine); List<Map<String, String>> read(String content);}
// A later, optional capability. Instead of adding columns() to Importer and breaking every// plugin, it is a separate interface that importers can choose to implement.interface DescribesColumns { List<String> columns(String content);}
// Shared by the two delimited formats: they differ only in the separator.class DelimitedImporter implements Importer, DescribesColumns { private final String name; private final String sep;
DelimitedImporter(String name, String sep) { this.name = name; this.sep = sep; }
public String name() { return name; }
public boolean recognizes(String firstLine) { return firstLine.contains(sep); }
public List<String> columns(String content) { return List.of(content.strip().split("\n")[0].split(sep)); }
public List<Map<String, String>> read(String content) { // Simplified: a real CSV reader must handle quoted fields that contain separators. String[] lines = content.strip().split("\n"); List<String> header = columns(content); List<Map<String, String>> rows = new ArrayList<>(); for (int i = 1; i < lines.length; i++) { String[] cells = lines[i].split(sep); Map<String, String> row = new LinkedHashMap<>(); for (int c = 0; c < header.size(); c++) row.put(header.get(c), c < cells.length ? cells[c] : ""); rows.add(row); } return rows; }}
// Written by a partner, in their own package, against the published interface. Our code// was never edited or redeployed for it. It doesn't describe columns, and doesn't have to.class KeyValueImporter implements Importer { public String name() { return "key=value"; } public boolean recognizes(String firstLine) { return firstLine.contains("="); } public List<Map<String, String>> read(String content) { List<Map<String, String>> rows = new ArrayList<>(); for (String line : content.strip().split("\n")) { Map<String, String> row = new LinkedHashMap<>(); for (String pair : line.split(";")) { String[] kv = pair.split("=", 2); row.put(kv[0].strip(), kv.length > 1 ? kv[1].strip() : ""); } rows.add(row); } return rows; }}
// Closed to new formats: it holds an ordered registry and asks each importer in turn.// Order is part of the contract: TSV is asked before CSV, because a tab-separated line// can also contain commas.class ImportService { private final List<Importer> importers = new ArrayList<>();
void register(Importer importer) { importers.add(importer); }
private Importer pick(String content) { String first = content.strip().split("\n")[0]; for (Importer i : importers) if (i.recognizes(first)) return i; throw new IllegalArgumentException("no importer recognizes this file"); }
List<Map<String, String>> importFile(String content) { return pick(content).read(content); }
// Uses the optional capability when the importer has it, and degrades when it doesn't. String preview(String content) { Importer i = pick(content); return i instanceof DescribesColumns d ? i.name() + " columns " + d.columns(content) : i.name() + " (no column preview)"; }}
class Main { public static void main(String[] args) { ImportService svc = new ImportService(); svc.register(new DelimitedImporter("tsv", "\t")); svc.register(new DelimitedImporter("csv", ",")); svc.register(new KeyValueImporter()); // the partner's plugin: one line at startup
System.out.println(svc.importFile("name,city\nAda,London\nGrace,Arlington")); System.out.println(svc.importFile("name\tcity\nAda\tLondon")); // named .csv or not, read right System.out.println(svc.importFile("name=Ada; city=London")); System.out.println(svc.preview("name,city\nAda,London")); // csv columns [name, city] System.out.println(svc.preview("name=Ada; city=London")); // key=value (no column preview) }}from typing import Protocol, runtime_checkable
Row = dict[str, str]
# The published extension point. Other teams and partners implement it, so once it ships it# is the one thing that must stay closed: adding a method here breaks every plugin out there.class Importer(Protocol): name: str
# Decide from the content, not the file name. Users mislabel files all the time. def recognizes(self, first_line: str) -> bool: ... def read(self, content: str) -> list[Row]: ...
# A later, optional capability. Instead of adding columns() to Importer and breaking every# plugin, it is a separate protocol that importers can choose to implement.@runtime_checkableclass DescribesColumns(Protocol): def columns(self, content: str) -> list[str]: ...
# Shared by the two delimited formats: they differ only in the separator.class DelimitedImporter: def __init__(self, name: str, sep: str) -> None: self.name = name self._sep = sep
def recognizes(self, first_line: str) -> bool: return self._sep in first_line
def columns(self, content: str) -> list[str]: return content.strip().split("\n")[0].split(self._sep)
def read(self, content: str) -> list[Row]: # Simplified: a real CSV reader (the csv module) handles quoted fields with separators. header = self.columns(content) rows = [] for line in content.strip().split("\n")[1:]: cells = line.split(self._sep) rows.append({h: cells[i] if i < len(cells) else "" for i, h in enumerate(header)}) return rows
# Written by a partner, in their own package, against the published protocol. Our code# was never edited or redeployed for it. It doesn't describe columns, and doesn't have to.class KeyValueImporter: name = "key=value"
def recognizes(self, first_line: str) -> bool: return "=" in first_line
def read(self, content: str) -> list[Row]: rows = [] for line in content.strip().split("\n"): pairs = (p.split("=", 1) for p in line.split(";")) rows.append({kv[0].strip(): (kv[1].strip() if len(kv) > 1 else "") for kv in pairs}) return rows
# Closed to new formats: it holds an ordered registry and asks each importer in turn.# Order is part of the contract: TSV is asked before CSV, because a tab-separated line# can also contain commas.class ImportService: def __init__(self) -> None: self._importers: list[Importer] = []
def register(self, importer: Importer) -> None: self._importers.append(importer)
def _pick(self, content: str) -> Importer: first = content.strip().split("\n")[0] for i in self._importers: if i.recognizes(first): return i raise ValueError("no importer recognizes this file")
def import_file(self, content: str) -> list[Row]: return self._pick(content).read(content)
# Uses the optional capability when the importer has it, and degrades when it doesn't. def preview(self, content: str) -> str: i = self._pick(content) if isinstance(i, DescribesColumns): return f"{i.name} columns {i.columns(content)}" return f"{i.name} (no column preview)"
if __name__ == "__main__": svc = ImportService() svc.register(DelimitedImporter("tsv", "\t")) svc.register(DelimitedImporter("csv", ",")) svc.register(KeyValueImporter()) # the partner's plugin: one line at startup
print(svc.import_file("name,city\nAda,London\nGrace,Arlington")) print(svc.import_file("name\tcity\nAda\tLondon")) # named .csv or not, read right print(svc.import_file("name=Ada; city=London")) print(svc.preview("name,city\nAda,London")) # csv columns ['name', 'city'] print(svc.preview("name=Ada; city=London")) # key=value (no column preview)using System;using System.Collections.Generic;using System.Linq;
using Row = System.Collections.Generic.Dictionary<string, string>;
// The published extension point. Other teams and partners implement it, so once it ships it// is the one thing that must stay closed: adding a method here breaks every plugin out there.interface IImporter{ string Name { get; } // Decide from the content, not the file name. Users mislabel files all the time. bool Recognizes(string firstLine); List<Row> Read(string content);}
// A later, optional capability. Instead of adding Columns() to IImporter and breaking every// plugin, it is a separate interface that importers can choose to implement.interface IDescribesColumns{ List<string> Columns(string content);}
// Shared by the two delimited formats: they differ only in the separator.class DelimitedImporter : IImporter, IDescribesColumns{ private readonly string _sep; public string Name { get; }
public DelimitedImporter(string name, string sep) { Name = name; _sep = sep; }
public bool Recognizes(string firstLine) => firstLine.Contains(_sep);
public List<string> Columns(string content) => content.Trim().Split('\n')[0].Split(_sep).ToList();
public List<Row> Read(string content) { // Simplified: a real CSV reader must handle quoted fields that contain separators. var header = Columns(content); return content.Trim().Split('\n').Skip(1) .Select(line => line.Split(_sep)) .Select(cells => header.Select((h, i) => (h, v: i < cells.Length ? cells[i] : "")) .ToDictionary(x => x.h, x => x.v)) .ToList(); }}
// Written by a partner, in their own assembly, against the published interface. Our code// was never edited or redeployed for it. It doesn't describe columns, and doesn't have to.class KeyValueImporter : IImporter{ public string Name => "key=value"; public bool Recognizes(string firstLine) => firstLine.Contains('='); public List<Row> Read(string content) => content.Trim().Split('\n') .Select(line => line.Split(';') .Select(p => p.Split('=', 2)) .ToDictionary(kv => kv[0].Trim(), kv => kv.Length > 1 ? kv[1].Trim() : "")) .ToList();}
// Closed to new formats: it holds an ordered registry and asks each importer in turn.// Order is part of the contract: TSV is asked before CSV, because a tab-separated line// can also contain commas.class ImportService{ private readonly List<IImporter> _importers = new();
public void Register(IImporter importer) => _importers.Add(importer);
private IImporter Pick(string content) { var first = content.Trim().Split('\n')[0]; return _importers.FirstOrDefault(i => i.Recognizes(first)) ?? throw new ArgumentException("no importer recognizes this file"); }
public List<Row> ImportFile(string content) => Pick(content).Read(content);
// Uses the optional capability when the importer has it, and degrades when it doesn't. public string Preview(string content) { var i = Pick(content); return i is IDescribesColumns d ? $"{i.Name} columns [{string.Join(", ", d.Columns(content))}]" : $"{i.Name} (no column preview)"; }}
static class Program{ static string Show(List<Row> rows) => string.Join(" ", rows.Select(r => "{" + string.Join(", ", r.Select(kv => $"{kv.Key}={kv.Value}")) + "}"));
static void Main() { var svc = new ImportService(); svc.Register(new DelimitedImporter("tsv", "\t")); svc.Register(new DelimitedImporter("csv", ",")); svc.Register(new KeyValueImporter()); // the partner's plugin: one line at startup
Console.WriteLine(Show(svc.ImportFile("name,city\nAda,London\nGrace,Arlington"))); Console.WriteLine(Show(svc.ImportFile("name\tcity\nAda\tLondon"))); // named .csv or not, read right Console.WriteLine(Show(svc.ImportFile("name=Ada; city=London"))); Console.WriteLine(svc.Preview("name,city\nAda,London")); // csv columns [name, city] Console.WriteLine(svc.Preview("name=Ada; city=London")); // key=value (no column preview) }}package main
import ( "errors" "fmt" "strings")
type Row map[string]string
// Importer is the published extension point. Other teams and partners implement it, so once// it ships it is the one thing that must stay closed: adding a method breaks every plugin.type Importer interface { Name() string // Recognizes decides from the content, not the file name. Users mislabel files. Recognizes(firstLine string) bool Read(content string) []Row}
// DescribesColumns is a later, optional capability. Instead of adding Columns to Importer// and breaking every plugin, importers can choose to implement it. The standard library does// the same with io.WriterTo next to io.Reader.type DescribesColumns interface { Columns(content string) []string}
// DelimitedImporter covers the two delimited formats: they differ only in the separator.type DelimitedImporter struct{ name, sep string }
func (d DelimitedImporter) Name() string { return d.name }func (d DelimitedImporter) Recognizes(firstLine string) bool { return strings.Contains(firstLine, d.sep)}func (d DelimitedImporter) Columns(content string) []string { return strings.Split(strings.SplitN(strings.TrimSpace(content), "\n", 2)[0], d.sep)}
func (d DelimitedImporter) Read(content string) []Row { // Simplified: a real CSV reader (encoding/csv) handles quoted fields with separators. header := d.Columns(content) var rows []Row for _, line := range strings.Split(strings.TrimSpace(content), "\n")[1:] { cells := strings.Split(line, d.sep) row := Row{} for i, h := range header { if i < len(cells) { row[h] = cells[i] } else { row[h] = "" } } rows = append(rows, row) } return rows}
// KeyValueImporter was written by a partner, in their own module, against the published interface.// Our code was never edited or redeployed for it. It doesn't describe columns, and doesn't have to.type KeyValueImporter struct{}
func (KeyValueImporter) Name() string { return "key=value" }func (KeyValueImporter) Recognizes(firstLine string) bool { return strings.Contains(firstLine, "=") }func (KeyValueImporter) Read(content string) []Row { var rows []Row for _, line := range strings.Split(strings.TrimSpace(content), "\n") { row := Row{} for _, pair := range strings.Split(line, ";") { k, v, _ := strings.Cut(pair, "=") row[strings.TrimSpace(k)] = strings.TrimSpace(v) } rows = append(rows, row) } return rows}
// ImportService is closed to new formats: it holds an ordered registry and asks each// importer in turn. Order is part of the contract: TSV is asked before CSV, because a// tab-separated line can also contain commas.type ImportService struct{ importers []Importer }
func (s *ImportService) Register(i Importer) { s.importers = append(s.importers, i) }
func (s *ImportService) pick(content string) (Importer, error) { first := strings.SplitN(strings.TrimSpace(content), "\n", 2)[0] for _, i := range s.importers { if i.Recognizes(first) { return i, nil } } return nil, errors.New("no importer recognizes this file")}
func (s *ImportService) ImportFile(content string) ([]Row, error) { i, err := s.pick(content) if err != nil { return nil, err } return i.Read(content), nil}
// Preview uses the optional capability when the importer has it, and degrades when it doesn't.func (s *ImportService) Preview(content string) (string, error) { i, err := s.pick(content) if err != nil { return "", err } if d, ok := i.(DescribesColumns); ok { return fmt.Sprintf("%s columns %v", i.Name(), d.Columns(content)), nil } return i.Name() + " (no column preview)", nil}
func main() { svc := &ImportService{} svc.Register(DelimitedImporter{"tsv", "\t"}) svc.Register(DelimitedImporter{"csv", ","}) svc.Register(KeyValueImporter{}) // the partner's plugin: one line at startup
for _, in := range []string{"name,city\nAda,London\nGrace,Arlington", "name\tcity\nAda\tLondon", "name=Ada; city=London"} { rows, _ := svc.ImportFile(in) fmt.Println(rows) } p, _ := svc.Preview("name,city\nAda,London") fmt.Println(p) // csv columns [name city] p, _ = svc.Preview("name=Ada; city=London") fmt.Println(p) // key=value (no column preview)}type Row = Record<string, string>;
// The published extension point. Other teams and partners implement it, so once it ships it// is the one thing that must stay closed: adding a method here breaks every plugin out there.interface Importer { readonly name: string; // Decide from the content, not the file name. Users mislabel files all the time. recognizes(firstLine: string): boolean; read(content: string): Row[];}
// A later, optional capability. Instead of adding columns() to Importer and breaking every// plugin, it is a separate interface that importers can choose to implement.interface DescribesColumns { columns(content: string): string[];}// TypeScript interfaces vanish at run time, so the capability check is a small type guard.const describesColumns = (i: object): i is DescribesColumns => typeof (i as Partial<DescribesColumns>).columns === "function";
// Shared by the two delimited formats: they differ only in the separator.class DelimitedImporter implements Importer, DescribesColumns { constructor( readonly name: string, private readonly sep: string, ) {}
recognizes(firstLine: string) { return firstLine.includes(this.sep); }
columns(content: string) { return content.trim().split("\n")[0].split(this.sep); }
read(content: string): Row[] { // Simplified: a real CSV reader must handle quoted fields that contain separators. const header = this.columns(content); return content .trim() .split("\n") .slice(1) .map((line) => { const cells = line.split(this.sep); return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""])); }); }}
// Written by a partner, in their own package, against the published interface. Our code// was never edited or redeployed for it. It doesn't describe columns, and doesn't have to.class KeyValueImporter implements Importer { readonly name = "key=value"; recognizes(firstLine: string) { return firstLine.includes("="); } read(content: string): Row[] { return content .trim() .split("\n") .map((line) => Object.fromEntries( line.split(";").map((pair) => { const [k, v = ""] = pair.split("=", 2); return [k.trim(), v.trim()]; }), ), ); }}
// Closed to new formats: it holds an ordered registry and asks each importer in turn.// Order is part of the contract: TSV is asked before CSV, because a tab-separated line// can also contain commas.class ImportService { private readonly importers: Importer[] = [];
register(importer: Importer): void { this.importers.push(importer); }
private pick(content: string): Importer { const first = content.trim().split("\n")[0]; const found = this.importers.find((i) => i.recognizes(first)); if (!found) throw new Error("no importer recognizes this file"); return found; }
importFile(content: string): Row[] { return this.pick(content).read(content); }
// Uses the optional capability when the importer has it, and degrades when it doesn't. preview(content: string): string { const i = this.pick(content); return describesColumns(i) ? `${i.name} columns ${JSON.stringify(i.columns(content))}` : `${i.name} (no column preview)`; }}
const svc = new ImportService();svc.register(new DelimitedImporter("tsv", "\t"));svc.register(new DelimitedImporter("csv", ","));svc.register(new KeyValueImporter()); // the partner's plugin: one line at startup
console.log(svc.importFile("name,city\nAda,London\nGrace,Arlington"));console.log(svc.importFile("name\tcity\nAda\tLondon")); // named .csv or not, read rightconsole.log(svc.importFile("name=Ada; city=London"));console.log(svc.preview("name,city\nAda,London")); // csv columns ["name","city"]console.log(svc.preview("name=Ada; city=London")); // key=value (no column preview)Send three requests through both designs. The second one is the hard case:
- ImportServicepicks a parser by file name and parses
- ImportServiceasks each importer in turn
- Importerpublished; partners build on it
- DelimitedImporterCSV and TSV
- KeyValueImporternewa partner's format, in their package
- DescribesColumnsnewoptional: list the columns
Pick a change request to see which classes it touches in each design.
What to notice
Section titled “What to notice”- A published interface is closed for good. Our classes can be refactored any time. An interface outside teams implement can’t gain a required method without breaking them. That is OCP at its strictest, and why plugin interfaces should be small.
- Grow with new interfaces, not new methods.
DescribesColumnsis optional. The service checks for it and degrades when it’s missing. Go’s standard library does exactly this withio.WriterToalongsideio.Reader. - Order is part of the contract. A tab-separated line can contain commas, so TSV must be asked before CSV. When plugins compete for the same input, the registry’s order, or a priority on each plugin, has to be documented.
- Choosing by content is part of being open. A registry keyed by file extension would still need someone to agree which extensions mean what. Letting each importer recognize its own input means a new format needs nothing from anyone else.
- Finding plugins is a separate problem. Here plugins are registered in code. Real systems
discover them: Java’s
ServiceLoader, Python entry points, .NET assembly scanning. The extension point is the same either way.
Six months after launch, we need importers to report how many rows they skipped. Partners have shipped 30 plugins. What do you change?
Whether you protect the published interface: a new optional interface, or a default method that
returns “unknown”, and a service that handles both. Then whether you mention telling partners
and versioning. Adding a required method to Importer is the red flag, because it breaks all 30
at once.
Partners implement Importer, so I treat it as frozen. New features come as small optional interfaces the service checks for, so no existing plugin breaks.
Tradeoffs
Section titled “Tradeoffs”Add an extension point when:
- The same kind of change has happened at least twice, or is on the roadmap.
- Variants are chosen by configuration, a user, or a tenant.
- People who can’t edit the code (other teams, partners) need to add variants.
- The code being protected is risky to touch: it handles money, runs everywhere, or is hard to test.
Keep the conditional when the set of cases is fixed and small, like the four card suits or the
states of an order. A switch over a sealed type or an enum is easier to read, and the compiler
tells you when a case is missing. That is a fine kind of “closed” too.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| Idea | Looks like OCP because | The difference |
|---|---|---|
| Strategy | Same class diagram | Strategy is one way to build an extension point. OCP is the goal; Decorator, Observer and plugins also reach it. |
| Liskov substitution | Both are about plugging in subtypes | OCP lets you add a variant without edits. LSP makes sure the new variant doesn’t break the client when it arrives. OCP without LSP is a trap. |
| Feature flags | Both add behavior without changing callers | A flag switches between paths that already exist in the code. OCP adds a new path without editing the code that chooses. |
| Backward compatibility | Both avoid breaking existing users | Compatibility is about a published API’s callers. OCP is about the code that uses an extension point. A published plugin interface needs both. |
Where it shows up
Section titled “Where it shows up”On this site:
- Parking lot: new pricing rules and spot assignment strategies are new classes. The lot is never edited for them.
- Logging framework: new appenders and formatters plug into the logger without changing it.
- Rate limiter: a new limiting algorithm is a new
Bucket.
In real systems: middleware in web frameworks, VS Code and browser extensions, JDBC drivers, Kubernetes custom resources, and every “bring your own” provider interface.
Interview questions
Section titled “Interview questions”Interviewer asksHow do you decide what to make extensible?
Look at what has already changed and what is on the roadmap. If a kind of change has happened twice, or is planned, put an extension point there. Everything else stays concrete until it changes. Say which axis you’re closing against, out loud.
Interviewer asksYour design is open/closed, but this change still edits three classes. Did you get it wrong?
Not necessarily. Code is closed against the changes you designed for. A change on a different axis, like a new rule about how variants combine, should edit the class that owns that rule. If the same kind of change keeps editing the same classes, that’s the signal to add an extension point for it.
Interviewer asksWhere does the switch go when you replace it with polymorphism?
To the one place that builds the objects: startup code, a factory, or configuration. It runs once, instead of on every call. And it’s the only code that knows every variant. That’s still a place to edit for a new variant. That’s why OCP talks about closing the code that uses the variants, not the code that creates them.
Interviewer asksHow do you add a method to an interface other teams implement?
You mostly don’t. Add a new optional interface and check for it, or, in Java and C#, add a default method with safe behavior. Version the interface if the change is fundamental, and keep the old version working for a while. A required new method breaks every implementation at once.
| Decision | We chose | We gave up |
|---|---|---|
| What to close against | The axis that has changed before or is planned | Protection against changes nobody predicted |
| How variants are added | New classes behind one interface | One switch listing every case |
| Where the choice lives | Startup code, config or a registry, once | Seeing the choice next to the code that uses it |
| Rules about all variants | Kept in the client | A client with no rules of its own |
| A published interface | Frozen; new features as optional interfaces | Simple, required methods for new features |
| A fixed set of cases | A sealed switch the compiler checks |
Adding cases without editing |
Further reading
Section titled “Further reading”- Single responsibility, which finds the seams OCP extends
- Liskov substitution, which keeps new variants safe to plug in
- Strategy, the most common way to build an extension point
- Bertrand Meyer, Object-Oriented Software Construction (1988), where the principle was named
- Robert C. Martin, The Open-Closed Principle (2014)
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.