🏛️ Design Principles & Patterns · Advanced

Open/Closed in Java

Open for extension, closed for modification.

🧩 The mysteryYour app needs a 12th payment method, and every new one means editing the same tested switch. There's a principle for that.

The ever-growing switch

Here's a common start: a switch on a type string. Every new payment type means reopening this method, editing working code, and retesting all the old cases too. The switch keeps growing, and so does the risk.

double fee(String type, double amt) {
    return switch (type) {
        case "card" -> amt * 0.03;
        case "bank" -> 1.5;
        default -> throw new
            IllegalArgumentException(type);
    };
}

Open for extension, closed for modification

The Open/Closed Principle: add new behavior by adding new code, not by editing code that already works. Put the varying part behind an interface. Each payment type becomes its own implementation, and callers of fee() never change.

interface FeePolicy {
    double fee(double amt);
}
record Card() implements FeePolicy {
    public double fee(double amt) {
        return amt * 0.03;
    }
}
🔮 Predict it

Plug-in fees

Each fee rule is a separate implementation. What does this print?

interface Fee { int of(int amt); }
void main() {
    List<Fee> fees = List.of(
        a -> a / 10,
        a -> 5);
    int total = 0;
    for (var f : fees) total += f.of(200);
    System.out.println(total);
}
  1. 25
  2. 205
  3. 20
  4. 5
Show the answer

The loop asks each rule for its fee on 200: 200 / 10 = 20, then a flat 5, so 25. A new fee is just another list entry. The loop is closed for modification but the list is open for extension.

Adding "crypto"

✗ Modification
case "card" -> amt * 0.03;
case "bank" -> 1.5;
case "crypto" -> amt * 0.05; // edit!

Reopens tested code every month. Rewriting it as if/else or moving it to a utility class changes nothing.

✓ Extension
record Crypto() implements FeePolicy {
    public double fee(double amt) {
        return amt * 0.05;
    }
}

A brand-new class. Nothing that already works is touched.

Sealed types: closing on purpose

A sealed interface does the opposite on purpose: it closes the hierarchy. Adding a new shape is a modification, but in return the compiler checks every exhaustive switch and flags each one you must update. Great when the cases are truly fixed, like AST nodes.

sealed interface Shape
    permits Circle, Square {}
String kind(Shape s) {
    return switch (s) {
        case Circle c -> "round";
        case Square q -> "boxy";
    };
}
⚠️ The trap

What "closed" does NOT mean

"Closed for modification" doesn't mean classes must be final, fields must be private and immutable, or files are locked in version control. It means existing, tested code doesn't need to be edited to support new behavior. It's about where change happens.

💼 In the real world

Where it pays off

Payment providers, export formats, discount rules, notification channels: anything that grows every sprint. In interviews and reviews, a switch on a type string that keeps growing is the classic OCP smell. But don't abstract everything up front; apply it where change actually keeps coming.

Key takeaways

  1. New behavior = a new class or lambda, not edits to old code
  2. Interfaces plus polymorphism are the usual tool
  3. A switch on a 'type' string that keeps growing is the classic smell
  4. Sealed types deliberately close a hierarchy in exchange for exhaustive switches

💡 A power strip is open for extension: plug in a new device without rewiring the house.

🤯 Did you know?

Bertrand Meyer coined the Open/Closed Principle in 1988 in his book Object-Oriented Software Construction. His version leaned on inheritance; the interface-based version became popular through Robert C. Martin.

Practice questions

What does this print?

interface Discount { int apply(int p); }
void main() {
    List<Discount> ds = List.of(
        p -> p - 10,
        p -> p / 2);
    int price = 100;
    for (var d : ds) price = d.apply(price);
    System.out.println(price);
}
  1. 45
  2. 50
  3. 40
  4. 90
Check your answer

45. The discounts run in list order: 100 - 10 = 90, then 90 / 2 = 45. Adding a new discount means adding a list entry, not changing the loop.

New payment types arrive every month. Which refactor best follows Open/Closed?

double fee(String type, double amt) {
    return switch (type) {
        case "card" -> amt * 0.03;
        case "bank" -> 1.5;
        default -> throw new
            IllegalArgumentException(type);
    };
}
  1. Keep adding cases and write a unit test for each
  2. Define interface FeePolicy { double fee(double amt); } with one implementation per type
  3. Replace the switch with nested if/else
  4. Move the switch into a static utility class
Check your answer

Define interface FeePolicy { double fee(double amt); } with one implementation per type. With an interface, each new payment type is a new class and fee() callers never change. The other options still require editing the same decision logic every month.

Next: what if an interface forces a cheap printer to pretend it can send a fax?