LLD Dojo

Open/Closed Principle

Core. Expect to meet this one, and expect to be asked for it by name.

Start with the problem

A vending machine's till has to charge a customer for whatever is in the slot. The obvious first move is to read the number straight off the label.

long priceMinor(Slot slot) {
    return slot.priceMinor();
}

For a machine that is not running any promotion, this is correct, and there is nothing to add to it.

Then the business wants a summer promotion. Everything is a fifth cheaper, rounded down to the nearest five, and the water is free. The obvious next move is to put that logic where the price is read.

long priceMinor(Slot slot) {
    if (slot.code().equals("B2")) {
        return 0;
    }
    long discounted = slot.priceMinor() * 80 / 100;
    return discounted - (discounted % 5);
}

This still works, and if the price were read in one place, editing that one place would be a reasonable afternoon's work.

Watch where it goes

The price is not read in one place. select() reads it to decide whether the customer has inserted enough money. A receipt needs it. A "how much is this?" query on the machine's display needs it too. Each of those call sites either reaches into select() for something that is not its job, or copies the promotion logic. In practice people copy, so now the 20%-off-and-round-down rule exists in three places.

That is the cost that matters. The method growing is tedious. Three copies of a rounding rule, checked by nobody, is where a bug gets in. The promotion ends, someone updates two of the three, and the third keeps discounting for a week.

The move

What a slot costs is a decision, and the decision is what varies. Give it a name of its own, apart from the slot's label and apart from the code that spends money.

public interface PricingPolicy {
    long priceMinor(Slot slot);
}

public final class ListPricing implements PricingPolicy {
    @Override
    public long priceMinor(Slot slot) {
        return slot.priceMinor();
    }
}

VendingMachine takes a PricingPolicy in its constructor and asks it, rather than reading the label itself. Every caller who needs a price, the till included, asks the same object, so there is one rule instead of three.

The promotion then arrives as a second implementation that wraps the first instead of replacing it.

public final class PromotionalPricing implements PricingPolicy {
    private final PricingPolicy shelfPrices;

    public PromotionalPricing(PricingPolicy shelfPrices) {
        this.shelfPrices = Objects.requireNonNull(shelfPrices, "shelfPrices");
    }

    @Override
    public long priceMinor(Slot slot) {
        if (slot.code().equals("B2")) {
            return 0;
        }
        long discounted = shelfPrices.priceMinor(slot) * 80 / 100;
        return discounted - (discounted % 5);
    }
}

ListPricing is not edited. VendingMachine is not edited. The one line of wiring that decides which policy is active for the summer is the entire change. Reverting it in the autumn is the same one line, run in the other direction.

That is what Bertrand Meyer meant by open for extension, closed for modification. The behaviour of pricing extends to cover a promotion. The classes that already worked, ListPricing and VendingMachine, stay exactly as they were, because the axis that was going to vary was given a seam before it had to vary.

What modern Java changes here

Deciding what varies is still a design judgment, and nothing in the language does that for you. What Java 21 changes is how cheap the seam is once you have found the axis. PricingPolicy has a single abstract method, so it is a functional interface. A rule with no state of its own can be a lambda at the call site instead of a named class. A rule with state, like PromotionalPricing's discount percentage, still earns a class, because a lambda has nowhere to keep a field.

The version of this that is wrong

Taken literally, "open for extension" produces an interface for every noun in the problem before any second implementation is in sight. A SlotLabel interface behind the price on the shelf has one implementation, and nothing in the requirements names a second. It adds a file for a number that was never going to vary on its own.

The seam that was worth adding, PricingPolicy, earns its place because a second implementation existed the day the requirement showed up. The test that separates a real seam from a guess: can you name the second implementation right now, and write the one method signature both of them satisfy? A seam that only names the dimension, with no second implementation and nothing in the requirements naming one, is a bet with no evidence behind it. STANDARD v1.0 scores that below the plain code it replaced. The failure tag is over-engineered (premature interface).

There is a second, sharper way to get this wrong even with a real seam: opening it on the wrong axis. PricingPolicy.priceMinor(Slot) answers a question about a slot. A rule that instead needs the time of day, or the customer's loyalty tier, does not fit through it any better than no interface would. Guessing the shape right matters more than guessing that a seam is needed at all.

Where this lives in the app

Syllabus item C2 is the full lesson, and its measurements are the ones to quote. A PricingPolicy-shaped seam on the parking lot saved 20 lines against a tiered-pricing requirement moving along its axis. It then cost 13 more than no seam at all against an overnight-rate requirement that crossed it. Read C2 for both numbers before claiming a seam pays off in an interview — a seam is a bet, and bets have a direction.

All reference pages