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. One caller 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)

No promotions are running, so priceOf returns the label price and that is correct. There is nothing here for you to improve yet.

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

Marketing signs off on a summer deal. Twenty percent off everything, rounded down to the nearest 5, and the water in slot B2 is free.

The direct fix is obvious, and you would ship it too. Put the new rule where the price gets 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;
    }
}

Now select charges the discounted price on its own. Good.

Then a second requirement lands on you. Print a receipt showing what the customer paid.

Whoever writes ReceiptPrinter does not reach into VendingMachine for its price. That class does a lot more than compute one, and pulling a single method out of it feels like grabbing the wrong seam. So they read the same requirement document, and they 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. The machine runs correctly all summer.

Then autumn arrives and the promotion has to end. Someone edits priceOf back to the plain shelf price. It is a two-line change and it looks completely safe.

Nobody remembers that ReceiptPrinter carries its own copy of the rule. Nothing in the code 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

Read those two lines again.

The till charges 65. The receipt still says 50.

Nothing crashed. Nothing failed a test, because the tests only exercise VendingMachine. This bug will sit there until a customer argues with an accountant.

Now find the real cause before you read on. Copying the rule made it worse, but copying is not the root of it. The root is that ending the promotion meant editing a method that was already working correctly.

That is the danger you need to carry out of this chapter. Editing working code can break anything that quietly depended on it, and the signature never changes, so nothing warns you.

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

Give pricing its own type. Then a promotion becomes 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 now take a PricingPolicy in the constructor and ask it. Neither one decides a price.

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

Look at what did not happen there.

ListPricing was never edited. PromotionalPricing was never edited. Ending the promotion is one line: choosing which policy to build. Both callers print 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 the machine can produce extends to cover a promotion. The classes that already worked stay exactly as they were.

Why did that work? Because the part that was going to vary got a seam before it had to vary.

4. The version of this that is wrong

Now push it too far.

Read the principle as "give everything a seam before it needs one" and you start writing interfaces for values that were never going to vary. The label on a shelf is data. It is a number somebody printed. Wrapping it buys you nothing.

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. It also adds a file, an interface, and a layer of indirection around a number that no requirement ever asked to change.

Here is the test that separates a real seam from a guess. Ask yourself two questions, right now, before you write the interface:

Can I name the second implementation? Can I write the one method both of them would need?

PricingPolicy passed that test the moment somebody named the summer promotion as a real requirement. SlotLabel fails it. 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. It scores 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. Two separate PromotionalPricing instances still compute the price the same way, because the rule lives in the class and not in some instance field that could drift. Sharing one object was convenient here, and nothing more. What actually killed the bug was moving the rule into a type that both callers ask.

Going deeper

Here is the part most candidates never learn, and it is the part that separates you from them.

You cannot open a design to every axis of change at once. Opening one axis usually closes another. corpus/parking-lot measures exactly this, on a PricingPolicy-shaped seam over `(vehicle type, duration)`.

First requirement: tiered pricing, a new rate that kicks in after a few hours. It moves along the axis the seam was built on. The seam absorbs it in 2 lines, against 22 for a version with no seam. That is the twenty-line saving a seam is supposed to deliver, measured rather than assumed.

Second requirement: an overnight rate, which needs the vehicle's entry time. The seam's signature never carried that. Absorbing it costs 27 lines across three files, against 14 with no seam at all.

Sit with those numbers for a moment. 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.

So which number is the real cost of PricingPolicy? Both of them are. A seam is a bet on which way the next change will move. This one paid off once and cost once, on two requirements that looked equally likely beforehand.

Say that out loud in an interview and you will sound like a senior engineer. Claim a seam always pays for itself and you will sound like someone reciting a slogan.

Why this matters in an interview

"Open for extension, closed for modification" is easy to say and easy to get wrong. The common wrong version is an instruction to wrap every value in an interface.

Carry a question instead of a slogan:

When the next requirement lands, can I satisfy it by adding a class? Or do I have to edit one that already passed its tests?

Point at the seam that would absorb a plausible next change and you are showing real judgment. Then name the change that would break that same seam. Very few candidates do the second part, and it is the part interviewers remember.


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 →