Skip to content

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.

Foundation18 min read

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 idea in one picture
Checkout closed: tested, shipped, never edited for a new deal Discount open: anything that fits PercentOff AmountOff BuyOneGetOne + BundlePrice next month's deal: a new class plugs in
Scroll or pinch to zoom, drag to pan
Checkout depends on the Discount socket, not on any deal. Next month's deal is a new class that plugs in; Checkout is not opened.
The general shape

Client

-parts: List<Extension>

+doJob()

«interface»

Extension

+theOneQuestion(input) : output

VariantA

VariantB

NextYearsVariant

Client

-parts: List<Extension>

+doJob()

«interface»

Extension

+theOneQuestion(input) : output

VariantA

VariantB

NextYearsVariant

Scroll or pinch to zoom, drag to pan
  • 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.

Easy

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.

Each promotion is a case in the checkout, chosen by its code:

Checkout.javacheckout.pyCheckout.csline.gocheckout.ts
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
}
}

Every 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.

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.

Easy: the checkout depends on Discount, never on a deal

Checkout

-discounts: List<Discount>

+totalCents(lines) : int

«interface»

Discount

+cents(lines) : int

PercentOff

+percent

AmountOff

+amountCents

BuyOneGetOne

+category

Line

+name

+category

+unitCents

+qty

Checkout

-discounts: List<Discount>

+totalCents(lines) : int

«interface»

Discount

+cents(lines) : int

PercentOff

+percent

AmountOff

+amountCents

BuyOneGetOne

+category

Line

+name

+category

+unitCents

+qty

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Checkout.javacheckout.pyCheckout.cscheckout.gocheckout.ts
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
}
}

Send three requests through both designs. Watch the third one closely:

A change request arrives
Deals in a switch
  • Checkoutadds up lines, and every deal in a switch

Deals behind an interface
  • Checkoutadds up lines and applies deals
  • Discounthow much off this cart?
  • PercentOff
  • AmountOff
  • BuyOneGetOne

Pick a change request to see which classes it touches in each design.

  • 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) and PercentOff(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 Checkout is built. A typo fails then, not when a customer pays.
Interviewer

We add a new kind of promotion every month. How would you design the checkout?

What they are testing

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”.

Say this out loud

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.

Medium

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.

The notifier knows every channel, how to reach a user on it, and how to format for it:

Notifier.javanotifier.pyNotifier.csrecipient.gonotifier.ts
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."));
}
}

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.

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.

Medium: channels behind one interface, found through a registry

Notifier

-channels: Map<String, Channel>

+notify(Recipient, Message)

«interface»

Channel

+id() : String

+canReach(Recipient) : bool

+send(Recipient, Message)

EmailChannel

SmsChannel

PushChannel

WhatsAppChannel

Recipient

+name

+email

+phone

+deviceToken

+channels

Message

+title

+body

Notifier

-channels: Map<String, Channel>

+notify(Recipient, Message)

«interface»

Channel

+id() : String

+canReach(Recipient) : bool

+send(Recipient, Message)

EmailChannel

SmsChannel

PushChannel

WhatsAppChannel

Recipient

+name

+email

+phone

+deviceToken

+channels

Message

+title

+body

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Notifier.javanotifier.pyNotifier.csl.gonotifier.ts
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."));
}
}

The one place that still knows every channel is the startup code. That is where the switch went:

Where the list of channels lives now

Startup code
(the composition root)
lists every channel once

Notifier's registry
id → Channel

User settings
channels: email, whatsapp

notify()

EmailChannel.send

WhatsAppChannel.send

Startup code
(the composition root)
lists every channel once

Notifier's registry
id → Channel

User settings
channels: email, whatsapp

notify()

EmailChannel.send

WhatsAppChannel.send

Scroll or pinch to zoom, drag to pan
  • The interface is shaped by the caller. canReach exists 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.
Interviewer

A big customer wants alerts in their Slack. Walk me through the change.

What they are testing

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().

Hard

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.

The import service picks a parser by file extension and parses inline:

ImportService.javaimport_service.pyImportService.csrow.goimport-service.ts
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"));
}
}

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.

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.

Hard: a published interface, a registry, and an optional capability

if available

written by a partner

ImportService

-importers: List<Importer>

+register(Importer)

+importFile(content) : List<Row>

+preview(content) : String

«interface»

Importer

+name() : String

+recognizes(firstLine) : bool

+read(content) : List<Row>

«interface»

DescribesColumns

+columns(content) : List<String>

DelimitedImporter

+name

+sep

KeyValueImporter

if available

written by a partner

ImportService

-importers: List<Importer>

+register(Importer)

+importFile(content) : List<Row>

+preview(content) : String

«interface»

Importer

+name() : String

+recognizes(firstLine) : bool

+read(content) : List<Row>

«interface»

DescribesColumns

+columns(content) : List<String>

DelimitedImporter

+name

+sep

KeyValueImporter

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
DelimitedImporter.javaimport_service.pyDelimitedImporter.csimport_service.goimport-service.ts
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)
}
}

Send three requests through both designs. The second one is the hard case:

A change request arrives
Formats by extension
  • ImportServicepicks a parser by file name and parses

Published plugin interface
  • ImportServiceasks each importer in turn
  • Importerpublished; partners build on it
  • DelimitedImporterCSV and TSV

Pick a change request to see which classes it touches in each design.

  • 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. DescribesColumns is optional. The service checks for it and degrades when it’s missing. Go’s standard library does exactly this with io.WriterTo alongside io.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.
Interviewer

Six months after launch, we need importers to report how many rows they skipped. Partners have shipped 30 plugins. What do you change?

What they are testing

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.

Say this out loud

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.

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.

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.

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.

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