InterviewKata
Low-level design · Module 03b of 11 · Level 1: Foundations

Open/closed in depth

Module 03 showed open/closed with one promotion. This module looks at the choices behind it: which kind of extension point to use, the one place that always stays open, why a design is only closed against the changes it planned for, and when an extension point isn't worth adding.

1. The idea

A wall socket is closed: nobody rewires the house to plug in a new lamp. It is also open: any lamp with the right plug works. The socket is the extension point. A house with no sockets, where every lamp is wired straight into the wall, needs an electrician for every new lamp.

Bertrand Meyer's rule, as it's used today: software should be open for extension and closed for modification. In practice that means: when a new kind of thing arrives (a new vehicle, a new payment method, a new report format), you add new code at an extension point rather than editing code that already works and is tested.

Extension pointHow new behaviour arrivesGood for
An interface (strategy)A new class implementing itNew behaviour with its own logic: an EV charging fee
A registry or map of handlersRegister a new handler under a keyChoosing behaviour by type or name at run time
Data or configurationA new row or setting, no code at allChanges that are only numbers: a new hourly rate
Events or hooksA new listenerExtra reactions to something that already happens

Something stays open

Some code must still choose which rule to use and wire it in (here, one line in the setup). That's fine: keep all such choices in one place, the composition root (Module 03e).

You're only closed against what you planned for

A fee interface makes new vehicle types cheap. A change it didn't plan for (a fee that needs data the interface doesn't pass) still edits the interface and its callers.

Plug in, don't inherit

Extending by subclassing works, but subclasses depend on their parent's internals; a change to the base class can break them all. An interface plus composition keeps each rule independent.

2. How it works

The parking lot adds electric-vehicle bays that charge for parking and for the energy used. Follow the change in two designs.

3. Try it yourself

Where does each change land?

Four requests arrive over a year. Pick a design and see which requests edit tested code (red), add new code (green), or change only the setup or data (grey).

4. In code

Parking fees as rules behind an interface. Cars and motorbikes reuse one hourly rule with different numbers; electric bays add a new rule that wraps the hourly fee. FeeCalculator is never edited; the setup registers each rule.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Ticket:
    vehicle: str
    minutes: int
    kwh: int = 0          # energy drawn while parked (electric cars only)


def money(cents: int) -> str:
    return f"{cents // 100}.{cents % 100:02d}"


def started_hours(minutes: int) -> int:
    return -(-minutes // 60)    # 150 minutes -> 3 started hours


# The extension point: every way of charging is a FeeRule.
class FeeRule(ABC):
    @abstractmethod
    def fee(self, t: Ticket) -> int: ...


class HourlyFee(FeeRule):
    def __init__(self, cents_per_hour: int) -> None:
        self.rate = cents_per_hour

    def fee(self, t: Ticket) -> int:
        return started_hours(t.minutes) * self.rate


# Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee(FeeRule):
    def __init__(self, parking: FeeRule, cents_per_kwh: int) -> None:
        self.parking, self.per_kwh = parking, cents_per_kwh

    def fee(self, t: Ticket) -> int:
        return self.parking.fee(t) + t.kwh * self.per_kwh


# Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator:
    def __init__(self, rules: dict[str, FeeRule]) -> None:
        self.rules = rules

    def fee(self, t: Ticket) -> int:
        if t.vehicle not in self.rules:
            raise ValueError(f"no fee rule for {t.vehicle}")
        return self.rules[t.vehicle].fee(t)


if __name__ == "__main__":
    # The one place that is not closed: where rules are chosen and wired together.
    rules: dict[str, FeeRule] = {"car": HourlyFee(200), "motorbike": HourlyFee(100)}
    calc = FeeCalculator(rules)
    print(f"car, 150 min: {money(calc.fee(Ticket('car', 150)))}")
    print(f"motorbike, 150 min: {money(calc.fee(Ticket('motorbike', 150)))}")
    rules["ev"] = ChargingFee(HourlyFee(200), 30)    # new behaviour: one new class, one new line here
    print(f"ev, 150 min, 20 kWh: {money(calc.fee(Ticket('ev', 150, 20)))}")
interface Ticket { vehicle: string; minutes: number; kwh: number; } // kwh: energy drawn while parked

const money = (c: number): string => `${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;
const startedHours = (minutes: number): number => Math.ceil(minutes / 60); // 150 minutes -> 3 started hours

// The extension point: every way of charging is a FeeRule.
interface FeeRule { fee(t: Ticket): number; }

class HourlyFee implements FeeRule {
  constructor(private centsPerHour: number) {}
  fee(t: Ticket): number { return startedHours(t.minutes) * this.centsPerHour; }
}

// Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee implements FeeRule {
  constructor(private parking: FeeRule, private centsPerKwh: number) {}
  fee(t: Ticket): number { return this.parking.fee(t) + t.kwh * this.centsPerKwh; }
}

// Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator {
  constructor(private rules: Map<string, FeeRule>) {}
  fee(t: Ticket): number {
    const rule = this.rules.get(t.vehicle);
    if (!rule) throw new Error(`no fee rule for ${t.vehicle}`);
    return rule.fee(t);
  }
}

// The one place that is not closed: where rules are chosen and wired together.
const rules = new Map<string, FeeRule>([["car", new HourlyFee(200)], ["motorbike", new HourlyFee(100)]]);
const calc = new FeeCalculator(rules);
console.log(`car, 150 min: ${money(calc.fee({ vehicle: "car", minutes: 150, kwh: 0 }))}`);
console.log(`motorbike, 150 min: ${money(calc.fee({ vehicle: "motorbike", minutes: 150, kwh: 0 }))}`);
rules.set("ev", new ChargingFee(new HourlyFee(200), 30)); // new behaviour: one new class, one new line here
console.log(`ev, 150 min, 20 kWh: ${money(calc.fee({ vehicle: "ev", minutes: 150, kwh: 20 }))}`);
import java.util.HashMap;
import java.util.Map;

record Ticket(String vehicle, int minutes, int kwh) {} // kwh: energy drawn while parked

// The extension point: every way of charging is a FeeRule.
interface FeeRule {
    int fee(Ticket t);
}

class HourlyFee implements FeeRule {
    private final int centsPerHour;
    HourlyFee(int centsPerHour) { this.centsPerHour = centsPerHour; }
    public int fee(Ticket t) { return ((t.minutes() + 59) / 60) * centsPerHour; } // started hours
}

// Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee implements FeeRule {
    private final FeeRule parking;
    private final int centsPerKwh;
    ChargingFee(FeeRule parking, int centsPerKwh) { this.parking = parking; this.centsPerKwh = centsPerKwh; }
    public int fee(Ticket t) { return parking.fee(t) + t.kwh() * centsPerKwh; }
}

// Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator {
    private final Map<String, FeeRule> rules;
    FeeCalculator(Map<String, FeeRule> rules) { this.rules = rules; }
    int fee(Ticket t) {
        FeeRule rule = rules.get(t.vehicle());
        if (rule == null) throw new IllegalArgumentException("no fee rule for " + t.vehicle());
        return rule.fee(t);
    }
}

public class Main {
    static String money(int c) { return (c / 100) + "." + String.format("%02d", c % 100); }

    public static void main(String[] args) {
        // The one place that is not closed: where rules are chosen and wired together.
        Map<String, FeeRule> rules = new HashMap<>();
        rules.put("car", new HourlyFee(200));
        rules.put("motorbike", new HourlyFee(100));
        FeeCalculator calc = new FeeCalculator(rules);
        System.out.println("car, 150 min: " + money(calc.fee(new Ticket("car", 150, 0))));
        System.out.println("motorbike, 150 min: " + money(calc.fee(new Ticket("motorbike", 150, 0))));
        rules.put("ev", new ChargingFee(new HourlyFee(200), 30)); // new behaviour: one new class, one new line here
        System.out.println("ev, 150 min, 20 kWh: " + money(calc.fee(new Ticket("ev", 150, 20))));
    }
}

Output (the same in all three languages)

car, 150 min: 6.00
motorbike, 150 min: 3.00
ev, 150 min, 20 kWh: 12.00

5. In the interview

6. Remember it

Memory hook: "Add a plug, don't rewire the wall"New behaviour arrives as new code at an extension point; tested code stays as it is.
How this hook works: what “Add a plug, don't rewire the wall” means

What the slogan means, phrase by phrase

“Add a plug”
New behaviour is a new class, handler or row.
“don't rewire the wall”
Code that works and is tested isn't edited for every new variant.

Why this picture fits: A wall socket: new lamps plug in; nobody rewires the house.

The story → the concept

The houseThe code
The socketThe FeeRule interface
A new lampChargingFee for electric cars
Plugging it inOne line registering the rule
The wiring in the wallFeeCalculator, untouched

Watch it on an example

  1. Motorbikes: register HourlyFee(100). No new code.
  2. EV charging: one new class, one registration line.
  3. A change the socket didn't plan for (a fee needing new data) still means editing the interface.

The trap it saves you from

Speculative extension points: interfaces for variants nobody has asked for, making simple code hard to follow.

It still works when…

Even in data-driven designs: a rates table is an extension point too, open to new numbers and closed to new kinds of rule.

7. Common mistakes

A switch that grows with every variantEach new case edits and re-tests the same function.
An interface for everythingOne implementation and no expected change: the abstraction only adds reading time.
Extension by subclassing a big base classSubclasses depend on its internals; a change in the parent breaks them.
Forgetting the wiringClaiming "nothing changes" when the setup code does; say where the one open spot is.

8. Practice exercise (25 minutes)

  1. Find the axis (5 min). In the vending machine problem, list three things the prompt suggests will vary.
  2. Add an extension point (10 min). Put one of them behind an interface; add a second implementation.
  3. Check what's still open (5 min). List every line you'd edit to add a third implementation. Is it only the setup?
  4. Data or class? (5 min). Which of your three variations could be a config value instead of a class?

9. Self-check quiz

Adding motorbikes needs a new case in FeeCalculator's switch. Which principle is strained?

New behaviour edits tested code instead of arriving as new code.

With fee rules behind an interface, what still changes when EV bays arrive?

Something must choose and wire the rule; keep that in one place.

The car rate goes from 2.00 to 2.50 an hour. Best design for that kind of change?

Number changes belong in data; new kinds of behaviour belong in classes.

When is adding an extension point premature?

Abstractions have a cost; pay it where change is expected.

10. Go deeper

ResourceTimeWhy
Module 03c: Liskov substitution in depthNextEvery plugged-in rule must keep the interface's promises.
Module 04: Design patterns30 minStrategy, decorator and observer are ready-made extension points.
P06: Parking lot45 minThe full problem this module's example comes from.