Cruxly

Cruxly › Low-level design › Principles & Patterns

Open/Closed

extend without editing

Open the interactive lesson

The principle: a class should be open for extension, closed for modification.

New behaviour should mean a new class, not an edited if/switch in an existing one.

Where it comes from. The principle is Bertrand Meyer's (Object-Oriented Software Construction, 1988), not Martin's. Martin re-framed it as: "You should be able to extend the behavior of a system without having to modify that system."

The caveat most summaries drop. In his 1996 article Martin is explicit that no significant program can be 100% closed. You cannot close against every possible change, so you choose which kinds of change to close against. He calls this strategic closure. That is the same idea as the LLD method's "draw interface seams where change is expected, and nowhere else": OCP is not "make everything extensible", it is "be closed against the specific changes this prompt says are coming."

His clearest real-world example is a plugin architecture. The rule is that dependencies inside the plugin point at the system, and nothing in the system points out at the plugins — so adding a plugin never edits the core.

The violation, concretely

class FeeCalculator {
    Fee compute(Ticket t, VehicleType type) {
        if (type == VehicleType.CAR) {
            return new Fee(hours(t) * 2.0);
        } else if (type == VehicleType.MOTORCYCLE) {
            return new Fee(hours(t) * 1.0);
        } else if (type == VehicleType.TRUCK) {
            return new Fee(hours(t) * 3.5);
        }
        throw new IllegalArgumentException("unknown type");
    }
}

Adding electric-vehicle pricing means editing this method — and if pricing logic is switched-on-type in three other places (a discount calculator, a reporting job, a receipt formatter), it means editing all four, and missing one is a bug that only shows up for the new type, which is exactly the kind of bug that ships because nobody tested the new case everywhere it mattered.

The fix — polymorphism instead of a switch

interface FeeStrategy { Fee compute(Ticket t); }

class CarFee implements FeeStrategy {
    public Fee compute(Ticket t) { return new Fee(hours(t) * 2.0); }
}
class MotorcycleFee implements FeeStrategy {
    public Fee compute(Ticket t) { return new Fee(hours(t) * 1.0); }
}
class ElectricVehicleFee implements FeeStrategy {   // the new requirement
    FeeStrategy base; double chargingSurcharge;
    public Fee compute(Ticket t) {
        return base.compute(t).plus(chargingSurcharge);   // composes an existing strategy
    }
}

Adding electric pricing is one new class. Nothing that already works is touched, so nothing that already works can regress. Note ElectricVehicleFee wraps another FeeStrategy rather than duplicating its logic — this is the Decorator pattern showing up naturally once the interface exists, not because anyone reached for it by name.

Why one switch is fine and four is the bug

The switch in the violation above is not, on its own, a catastrophe. The catastrophe is what happens to it over eighteen months.

shotgun surgery add ELECTRIC FeeCalculator DiscountCalc ReportingJob ReceiptFmt forgotten →bug Four files switch on the same enum. The compiler cannot tell you which you missed. one new file add ELECTRIC ElectricVehicleFee.java implements FeeStrategy everything else: untouched Untouched code cannot regress.
The cost of a type switch is not the switch. It is that the same switch reproduces itself across the codebase, and nothing checks that you updated every copy.

The name for the left-hand picture is shotgun surgery: one conceptual change, scattered edits. The reason it is worse than it looks is that the compiler is no help — ReceiptFormatter still compiles perfectly with an unhandled ELECTRIC case falling through to a default. You find out in production, for that one vehicle type.

Converting a switch to strategies, step by step

Doing this to live code is not a rewrite. It is four mechanical steps, and each one leaves the system working.

@@ 1 | Extract the interface from the switch's shape

The switch takes a Ticket and returns a Fee, so that is the interface — you are not inventing an abstraction, you are reading one off the code that already exists.

interface FeeStrategy { Fee compute(Ticket t); }

@@ 2 | Move each branch body into its own class, unchanged

Copy the branch body verbatim. Resist improving it at the same time — a refactor that also changes behaviour is a refactor whose test failures you cannot attribute.

@@ 3 | Replace the switch with a lookup

The switch does not disappear; it moves into one place whose only job is construction.

class FeeStrategies {
    private static final Map<VehicleType, FeeStrategy> BY_TYPE = Map.of(
        VehicleType.CAR,        new CarFee(),
        VehicleType.MOTORCYCLE, new MotorcycleFee(),
        VehicleType.TRUCK,      new TruckFee());

    static FeeStrategy forType(VehicleType t) {
        FeeStrategy s = BY_TYPE.get(t);
        if (s == null) throw new IllegalArgumentException("no strategy for " + t);
        return s;
    }
}

@@ 4 | Point the other three call sites at the same lookup

This is the step that actually pays. DiscountCalculator, ReportingJob and ReceiptFormatter stop switching on the enum and start asking FeeStrategies — so the next new vehicle type touches one map entry, not four files.

:::key The switch never fully vanishes, and that is correct Something must map a runtime enum to a concrete class. Concentrating that into one factory is the win — not eliminating it. A candidate who claims polymorphism removes every conditional has not built this. :::

The honest limit of this principle

Open/Closed does not mean "add an interface everywhere, preemptively." An interface with one implementation and no expected second implementation is a cost with no benefit — indirection the reader has to trace through for nothing. The LLD method material elsewhere in this track puts it plainly: put a seam exactly where the prompt implies variation, and nowhere else.

The tell for when OCP actually applies: you are editing the same type-switch in more than one place, or the interviewer has told you a dimension will grow (vehicle types, pricing rules, notification channels). Vehicle types growing was given in the parking-lot example's prompt; that's what made FitPolicy and FeeStrategy real interfaces instead of premature ones.

The counter-case worth saying out loud. If the set of types is genuinely closed — enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES } — a switch is the better answer. It is shorter, it is all visible in one place, and in languages with exhaustive matching the compiler will actually catch a missing case for you. Open/Closed protects against unbounded variation; applying it to a set that will never grow is ceremony.

:::warn A rule of thumb that survives contact with real code Write the switch the first time. Write it the second time and notice. Refactor to strategies the third time, or the first time a new case is added — whichever comes first. Refactoring on the first write is guessing; refactoring on the fourth is late. :::

How to say it in the interview

"Every time I see a switch on a type enum with behaviour in each branch, I ask what happens when a new type is added — if the answer is 'edit this switch, and every other switch on this enum elsewhere in the codebase,' that's the violation."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons