LLD Dojo

Objects that hold their shape · chapter 15 of 33

Open for extension, closed for modification

Chapter 2.5 · Part 2, Objects that hold their shape · about 30 minutes

What you need before this chapter: chapters 2.1 through 2.4. Chapter 2.3 in particular, since this chapter builds the same kind of seam from a different angle.

When you finish this chapter you will be able to:


1. Working code, for one caller

A vending machine charges whatever price is printed on the shelf. One method decides the price, and one caller, select, asks it.

public class VendingMachine {

    public long priceOf(String slotCode, long shelfPriceMinor) {
        return shelfPriceMinor;
    }

    public boolean select(String slotCode, long shelfPriceMinor, long insertedMinor) {
        long price = priceOf(slotCode, shelfPriceMinor);
        boolean afforded = insertedMinor >= price;
        System.out.println("charged: " + price + " (afforded: " + afforded + ")");
        return afforded;
    }
}
charged: 65 (afforded: true)

For a machine with no promotions running, priceOf returning the label price is correct, and there is nothing to add.

2. The new requirement, and the cost of editing what already works

Marketing signs off on a summer deal: 20% off everything, rounded down to the nearest 5, and the water in slot B2 is free. The direct fix is to put the new rule where the price is decided.

public class VendingMachine {

    // Summer promotion, added directly here: 20% off, rounded down to the nearest 5, water free.
    public long priceOf(String slotCode, long shelfPriceMinor) {
        if (slotCode.equals("B2")) {
            return 0;
        }
        long discounted = shelfPriceMinor * 80 / 100;
        return discounted - (discounted % 5);
    }

    public boolean select(String slotCode, long shelfPriceMinor, long insertedMinor) {
        long price = priceOf(slotCode, shelfPriceMinor);
        boolean afforded = insertedMinor >= price;
        System.out.println("charged: " + price + " (afforded: " + afforded + ")");
        return afforded;
    }
}

select now charges the discounted price automatically. Then a second requirement lands: print a receipt showing what the customer paid. Whoever writes ReceiptPrinter does not reach into VendingMachine for its price; that class does more than compute one, and pulling a single method out of it feels like the wrong seam to grab. They read the same requirement document and write the same rule again.

/** Added after the promotion. Whoever wrote this did not know priceOf() already had the rule. */
public class ReceiptPrinter {

    public void print(String slotCode, long shelfPriceMinor) {
        long charged;
        if (slotCode.equals("B2")) {
            charged = 0;
        } else {
            long discounted = shelfPriceMinor * 80 / 100;
            charged = discounted - (discounted % 5);
        }
        System.out.println("receipt: slot " + slotCode + " charged " + charged);
    }
}
charged: 50 (afforded: true)
receipt: slot A1 charged 50

Both numbers agree, and the machine works correctly all summer. Autumn arrives, and the promotion has to end. Someone edits priceOf back to the plain shelf price. Nobody remembers that ReceiptPrinter carries its own copy of the same rule, because nothing marks the two as connected.

// Autumn: the promotion is over. This method was edited back to the plain shelf price.
public long priceOf(String slotCode, long shelfPriceMinor) {
    return shelfPriceMinor;
}
charged: 65 (afforded: true)
receipt: slot A1 charged 50

The till charges 65. The receipt still says 50. Nothing crashed, nothing failed a test that only exercises VendingMachine, and the bug will not surface until a customer or an accountant compares the two numbers by hand. The root cause is not that the rule was copied, though that made things worse. It is that ending the promotion meant editing a method that was already working correctly. An edit to working code can break anything that quietly depended on the code it changed, with no signature change to warn you.

3. The move: a seam that gets added beside old code, not inside it

Give pricing its own type, so that a promotion is something you add next to the existing rule instead of something you edit into it.

public interface PricingPolicy {
    long priceMinor(String slotCode, long shelfPriceMinor);
}

public final class ListPricing implements PricingPolicy {
    @Override
    public long priceMinor(String slotCode, long shelfPriceMinor) {
        return shelfPriceMinor;
    }
}

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

    public PromotionalPricing(PricingPolicy shelfPrices) {
        this.shelfPrices = shelfPrices;
    }

    @Override
    public long priceMinor(String slotCode, long shelfPriceMinor) {
        if (slotCode.equals("B2")) {
            return 0;
        }
        long discounted = shelfPrices.priceMinor(slotCode, shelfPriceMinor) * 80 / 100;
        return discounted - (discounted % 5);
    }
}

VendingMachine and ReceiptPrinter both take a PricingPolicy in their constructor and ask it, rather than deciding a price themselves.

public final class VendingMachine {
    private final PricingPolicy pricing;
    public VendingMachine(PricingPolicy pricing) { this.pricing = pricing; }

    public boolean select(String slotCode, long shelfPriceMinor, long insertedMinor) {
        long price = pricing.priceMinor(slotCode, shelfPriceMinor);
        boolean afforded = insertedMinor >= price;
        System.out.println("charged: " + price + " (afforded: " + afforded + ")");
        return afforded;
    }
}

public final class ReceiptPrinter {
    private final PricingPolicy pricing;
    public ReceiptPrinter(PricingPolicy pricing) { this.pricing = pricing; }

    public void print(String slotCode, long shelfPriceMinor) {
        long charged = pricing.priceMinor(slotCode, shelfPriceMinor);
        System.out.println("receipt: slot " + slotCode + " charged " + charged);
    }
}
PricingPolicy summer = new PromotionalPricing(new ListPricing());
VendingMachine m = new VendingMachine(summer);
ReceiptPrinter receipt = new ReceiptPrinter(summer);
m.select("A1", 65, 100);
receipt.print("A1", 65);

System.out.println("--- autumn: promotion ends, one line of wiring changes ---");

PricingPolicy autumn = new ListPricing();
VendingMachine m2 = new VendingMachine(autumn);
ReceiptPrinter receipt2 = new ReceiptPrinter(autumn);
m2.select("A1", 65, 100);
receipt2.print("A1", 65);
charged: 50 (afforded: true)
receipt: slot A1 charged 50
--- autumn: promotion ends, one line of wiring changes ---
charged: 65 (afforded: true)
receipt: slot A1 charged 65

ListPricing was never edited. PromotionalPricing was never edited. Ending the promotion is one line, choosing which policy gets built, and both callers see the same number because both ask the same object. This is what Bertrand Meyer meant by open for extension, closed for modification. The set of prices a machine can produce extends to cover a promotion. The classes that already worked stay exactly as they were, because the part that was going to vary was given a seam before it had to vary.

4. The version of this that is wrong

Taken as "give everything a seam before it needs one," this principle produces an interface for a value that was never going to vary. The label on a shelf is data, not a decision, and wrapping it does not make the design safer.

public interface SlotLabel {
    long shelfPriceMinor();
}

public final class PrintedSlotLabel implements SlotLabel {
    private final long priceMinor;
    public PrintedSlotLabel(long priceMinor) { this.priceMinor = priceMinor; }

    @Override
    public long shelfPriceMinor() {
        return priceMinor;
    }
}
shelf price: 65

This compiles, and it adds a file, an interface, and a level of indirection for a number that nothing in the requirements ever asked to vary. The test that separates a real seam from a guess: can you name the second implementation right now, and write the one method both of them would need? PricingPolicy passed that test the moment the summer promotion was named as a real requirement. SlotLabel does not, because nothing anywhere names a second kind of label. A seam with one implementation and no second one in sight is a bet placed on nothing, and it is scored below the plain field it replaced.

Your turn

ReceiptPrinter and VendingMachine both hold their own PricingPolicy field, built from the same object at the call site in Main. Suppose instead each one built its own `new PromotionalPricing(new ListPricing())` independently, rather than sharing one instance. Would the autumn bug from section 2 still be possible? Explain in a sentence.

The answer. No. Even two separate PromotionalPricing instances compute price the same way, because the rule lives in the class, not in an instance field that could drift. Sharing one object was convenient here, not required. What removed the bug was moving the rule into a type both callers ask, not the choice to share one instance of it.

Going deeper

You cannot make a design open to every axis of change at once, and opening one axis is often what closes another. corpus/parking-lot measures this directly, on a PricingPolicy-shaped seam over (vehicle type, duration).

A tiered-pricing requirement, a new rate that kicks in after a few hours, moves along that axis. The seam absorbs it in 2 lines, against 22 for a version with no seam at all. That is the 20-line saving a seam is supposed to deliver, measured rather than assumed.

An overnight-rate requirement then arrives that needs the vehicle's entry time, which the seam's signature never carried. Absorbing it costs 27 lines across three files, against 14 for the version with no seam. The same seam that saved 20 lines on one requirement cost 13 more than nothing on the next. The second requirement crossed the axis instead of moving along it.

Neither number is the "real" cost of PricingPolicy. Both are. A seam is a bet on which way the next change will move. This bet paid off once and cost once, on two changes that looked equally likely in advance. Naming that trade-off, instead of claiming a seam always pays for itself, is what a senior engineer sounds like under a follow-up question.

Why this matters in an interview

"Open for extension, closed for modification" is easy to say and easy to state wrong, as an instruction to wrap every value in an interface. The version worth having ready is a question: if the next requirement lands, can it be satisfied by adding a class? Or does it require editing one that already passed its tests? A candidate who can point at the seam that would absorb a plausible next change is showing real judgment. Naming the change that would break the same seam shows it even more.


Next: chapter 2.6, Liskov substitution: keeping a promise a caller relies on — where a second implementation of an interface satisfies the compiler and breaks every caller anyway.

← 2.4 Single responsibility, and how to test for it · All chapters · 2.6 Liskov substitution: keeping a promise a caller relies on →