LLD Dojo

Syllabus · B2

Factory — construction isolated from use

The idea

Factory — construction isolated from use

corpus/vending-machine's VendingMachine takes five collaborators through its constructor and has no convenience constructor. The rack, the coin float, the tariff — every one of them gets assembled elsewhere, in MachineFactory. You might ask why that split earns a whole class when new VendingMachine(rack, purse, pricing, changer, table) is one line anyone could inline at every call site.

Here's the answer, measured. Three requirement changes hit "what a machine is made of," never "how a sale runs." A new coin denomination lands nowhere at all — MachineFactory's float is a Map that never mentions it, reference_diff: 0. A happy-hour discount costs one line in MachineFactory.standard() plus a new file, reference_diff: 2. A maintenance mode costs 8, and six of those are declaring two methods VendingMachine was contractually forced to grow anyway. VendingMachine itself is untouched by all three, because it never knew what a standard float was.

Had the float and the rack sat inside VendingMachine's own constructor as defaults, all three changes would have opened the class that validates state and empties escrow. That is the one class in this design that must not break.

So the threshold: pull assembly into a factory once a graph has two collaborators worth naming. Or once a second recipe is already in view — a promotion, a test double, a demo build. One new Foo() behind a static method with no second caller in sight is ceremony, not isolation.


Worked walkthrough

NOTES — six files, and the one line a factory exists to protect

Run it first

..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out src\*.java
..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main

javac -Xlint:all prints nothing and exits 0. Real output, verbatim:

1. today's tariff: ListPricing, you pay what the label says
  A1   Chips      label=65    charged=65
  A2   Chocolate  label=100   charged=100
  B1   Gum        label=25    charged=25
  B2   Water      label=125   charged=125

2. a happy-hour machine, built by reusing the same rack the factory exposes
  A1   Chips      label=65    charged=50
  A2   Chocolate  label=100   charged=80
  B1   Gum        label=25    charged=20
  B2   Water      label=125   charged=0

3. what the real requirement change costs, measured
   corpus/vending-machine/curveballs/02-happy-hour/budget.json: reference_diff = 2
   (one line rewritten inside MachineFactory.standard(), one new file, free)
   corpus/vending-machine/curveballs/01-a-new-coin/budget.json: reference_diff = 0
   corpus/vending-machine/curveballs/03-maintenance-mode/budget.json: reference_diff = 8

4. the rack itself never moved between the two machines
   standard rack:   [Slot[code=A1, ...], Slot[code=A2, ...], Slot[code=B1, ...], Slot[code=B2, ...]]
   happy-hour rack: [Slot[code=A1, ...], Slot[code=A2, ...], Slot[code=B1, ...], Slot[code=B2, ...]]

Two things in block 2 are worth stopping on. Water (B2) is free under the promotion, and Gum (B1) has zero units left but still prices at 20. Pricing and stock are two different questions, and Machine.priceOf never looks at quantity. Charging a sold-out slot is not this file's bug to catch; the real VendingMachine refuses that sale in SlotRack.available, a different seam.


MachineFactory.java — the seam

public static Machine standard() {
    return new Machine(standardSlots(), new ListPricing());
}

This line is the whole class's reason to exist. Every other file in this package could be handed a rack and a policy by whoever calls its constructor directly. This one method is the only place that has decided what "standard" means. It is the only place Main (or a future Entry.create()) needs to know about to get one.

public static List<Slot> standardSlots() {
    return List.of(...);
}

Exposed on its own, not only assembled: this is the line Main.java actually depends on. Block 2's happy-hour machine calls MachineFactory.standardSlots() directly rather than copying the four Slot literals. If the rack ever grows a fifth product, one edit here reaches both machines. Hide this behind standard() alone, and the second recipe has to either duplicate the rack or open standard() to split it out later. The corpus's own javadoc names this exact reason: "a variation on the standard machine can reuse the parts it is not changing instead of copying them."

private MachineFactory() {}

A class that is nothing but static methods gets no public constructor. Without this line, new MachineFactory() compiles and produces a useless object nobody asked for. Not a bug exactly, but a second, silent way to spell MachineFactory.standard() wrong that the compiler should have refused. faded/GapTest.java checks this by reflection, the same technique B1 uses to check PricingPolicy has exactly one abstract method.


Machine.java — the caller that asks

public long priceOf(String slotCode) {
    for (Slot slot : rack) {
        if (slot.code().equals(slotCode)) {
            return pricing.priceMinor(slot);
        }
    }
    throw new IllegalArgumentException("no slot " + slotCode);
}

pricing.priceMinor(slot), not slot.priceMinor(), is the entire argument for this lesson. Swap that one call and every price in block 2 goes back to the shelf label: 65, 100, 25, 125. The promotion silently stops happening while the code keeps compiling and keeps running. Nothing in Machine names a discount, a percentage or a rounding rule; search this file for a number and you find none. That absence is D3's anchor made concrete: the variation point sits behind an interface this class only calls, never inspects.

public Machine(List<Slot> rack, PricingPolicy pricing) {
    this.rack = List.copyOf(Objects.requireNonNull(rack, "rack"));
    this.pricing = Objects.requireNonNull(pricing, "pricing");
    ...

Both dependencies arrive through the constructor, and there is no second constructor that supplies a default tariff. That is what makes MachineFactory necessary rather than decorative. If Machine could build its own ListPricing() when none was passed, "what a standard machine charges" would have two owners, this file and the factory, and they could disagree.


PromotionalPricing.java — the second recipe, added not edited

long discounted = shelfPrices.priceMinor(slot) * (100 - DISCOUNT_PERCENT) / 100;
return discounted - (discounted % ROUND_DOWN_TO);

This decorates ListPricing rather than replacing it. shelfPrices.priceMinor(slot) still runs, so the field the receipt would call "the marked price" is still reachable through the wrapped policy — only what gets charged changes. Chips at 65 becomes 65 * 80 / 100 = 52, rounded down to the nearest five: 52 - (52 % 5) = 50. Chocolate at 100 becomes 80 exactly, because 80 is already a multiple of five.

if (FREE_SLOTS.contains(slot.code())) {
    return 0;
}

Checked before the discount arithmetic runs, not after. Water's label is 125. A version that discounted first and special-cased zero afterward would still print 0, but a version that forgot this line entirely charges 100 for water instead of 0. That is the exact number faded/GapTest.java checks for, because "forgot the exception" is a more likely slip than "got the arithmetic wrong."


Main.java — the driver

A missing driver caps D2 at 0, whatever the code does. This one builds two machines rather than one. The interesting fact here is not what either machine charges; it is that neither MachineFactory nor Machine was opened to get the second one. PromotionalPricing is a new file, and block 2's machine is built with the factory's own exposed standardSlots(), wired to a policy that did not exist yet when Machine was written.


When not to

When a factory is not worth its file

D3 level 3 penalises this exactly as much as level 0 penalises the missing seam. Its anchor reads "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one." A factory is a seam too, and the same rule applies to it.

The bad example, built from a file already in this lesson

Slot has four constructor arguments and gets built directly, four times, right inside MachineFactory.standardSlots():

new Slot("A1", "Chips", 65, 2)

Nothing stops someone from wrapping that in a SlotFactory:

public final class SlotFactory {
    public static Slot chips() { return new Slot("A1", "Chips", 65, 2); }
    public static Slot chocolate() { return new Slot("A2", "Chocolate", 100, 1); }
    // ... one method per product, forever
}

This is not a seam. It is one file that answers a question nobody asked twice. There is no second way to build a Slot, and no assembly step beyond the constructor. Nothing in this problem's requirements names a variation on how a slot comes into being. Every method only repeats its own arguments back with a name attached.

What it costs, and what it buys

A file, and a name for every product, for zero behaviour. SlotFactory.chips() and new Slot("A1", "Chips", 65, 2) compile to the same object. The factory adds one hop a reader has to follow to find four literals that were sitting in plain sight.

And the real MachineFactory earns its file on a different basis entirely. It assembles two collaborators, a rack and a tariff, not zero. This lesson's own curveball proves a second recipe exists too: standard() and the happy-hour build in Main.java share standardSlots() and differ only in which PricingPolicy gets wired in. SlotFactory has neither — one collaborator, nothing to assemble, and no second recipe for any single slot that this problem's requirements ever name.

The threshold, stated so you can fail it

Reach for a factory when a constructor's job is genuinely assembly: two or more collaborators, or one collaborator built through a multi-step recipe. The other trigger is a second way to build the same thing that already exists or is named in the requirements. A single value object built from arguments you already have in hand needs its constructor, not a class that calls that constructor for you.


Worked source

The 7 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/ListPricing.java11 lines

// ListPricing.java — GIVEN, unchanged.
//
// Verbatim from corpus/vending-machine/reference/src/ListPricing.java. Today's tariff: you
// pay what the label says. The one-line answer every other tariff is a variation on.
public final class ListPricing implements PricingPolicy {

    @Override
    public long priceMinor(Slot slot) {
        return slot.priceMinor();
    }
}

worked/src/Machine.java42 lines

// Machine.java
//
// Trimmed from corpus/vending-machine/reference/src/VendingMachine.java down to the two
// collaborators this lesson is about: the rack and the pricing policy. The coin float, the
// change maker and the state machine are a different lesson's worked example; this class
// only answers one question, "what does this slot cost right now," and it answers it by
// asking, never by holding a rate of its own.
import java.util.List;
import java.util.Objects;

public final class Machine {

    private final List<Slot> rack;
    private final PricingPolicy pricing;

    /**
     * Both collaborators arrive here, and there is no second constructor that supplies
     * defaults. See MachineFactory, which is where "what a standard machine is made of"
     * lives instead.
     */
    public Machine(List<Slot> rack, PricingPolicy pricing) {
        this.rack = List.copyOf(Objects.requireNonNull(rack, "rack"));
        this.pricing = Objects.requireNonNull(pricing, "pricing");
        if (this.rack.isEmpty()) {
            throw new IllegalArgumentException("a machine needs at least one slot");
        }
    }

    public List<Slot> slots() {
        return rack;
    }

    /** What this slot costs right now, under whichever tariff this machine was built with. */
    public long priceOf(String slotCode) {
        for (Slot slot : rack) {
            if (slot.code().equals(slotCode)) {
                return pricing.priceMinor(slot);
            }
        }
        throw new IllegalArgumentException("no slot " + slotCode);
    }
}

worked/src/MachineFactory.java29 lines

// MachineFactory.java
//
// Adapted from corpus/vending-machine/reference/src/MachineFactory.java: the same class,
// trimmed to the rack and the pricing policy. What a standard machine is made of, and the
// only place that knows it.
import java.util.List;

public final class MachineFactory {

    /** The machine a caller gets by default. */
    public static Machine standard() {
        return new Machine(standardSlots(), new ListPricing());
    }

    /**
     * The rack as loaded at the start of the day. Exposed on its own, not only assembled,
     * so a variation on the standard machine reuses this instead of copying it — see
     * Main.java, which builds a second tariff over this same rack.
     */
    public static List<Slot> standardSlots() {
        return List.of(
                new Slot("A1", "Chips", 65, 2),
                new Slot("A2", "Chocolate", 100, 1),
                new Slot("B1", "Gum", 25, 0),
                new Slot("B2", "Water", 125, 3));
    }

    private MachineFactory() {}
}

worked/src/PricingPolicy.java13 lines

// PricingPolicy.java — GIVEN, unchanged.
//
// Verbatim from corpus/vending-machine/reference/src/PricingPolicy.java. The seam that keeps
// "the price on the shelf label" and "what the till charges" as two concepts, so a factory
// has something worth choosing between when it assembles a machine.
public interface PricingPolicy {

    /**
     * @param slot the slot being bought, marked price included
     * @return what to charge, in minor units. Zero is legal: some things are free
     */
    long priceMinor(Slot slot);
}

worked/src/PromotionalPricing.java30 lines

// PromotionalPricing.java — GIVEN, unchanged.
//
// Verbatim from corpus/vending-machine/curveballs/02-happy-hour/reference-patch/PromotionalPricing.java.
// The answer to a real requirement change: a fifth off everything, rounded down to the
// nearest five, water free. It decorates ListPricing rather than replacing it, so the shelf
// label stays the shelf label and ending the promotion is one line back.
import java.util.Objects;
import java.util.Set;

public final class PromotionalPricing implements PricingPolicy {

    private static final long DISCOUNT_PERCENT = 20;
    private static final long ROUND_DOWN_TO = 5;
    private static final Set<String> FREE_SLOTS = Set.of("B2");

    private final PricingPolicy shelfPrices;

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

    @Override
    public long priceMinor(Slot slot) {
        if (FREE_SLOTS.contains(slot.code())) {
            return 0;
        }
        long discounted = shelfPrices.priceMinor(slot) * (100 - DISCOUNT_PERCENT) / 100;
        return discounted - (discounted % ROUND_DOWN_TO);
    }
}

worked/src/Slot.java29 lines

// Slot.java — GIVEN, unchanged.
//
// Verbatim from corpus/vending-machine/contract/Slot.java. A snapshot of one slot in the
// rack, not the slot itself, so handing one out never lets a caller reach back into the
// machine.
import java.util.Objects;

/**
 * @param code        the slot's code, e.g. "A1". Never null, never blank
 * @param productName what the slot sells, e.g. "Chips". Never null
 * @param priceMinor  the marked price in minor units; zero or more
 * @param quantity    how many are left; zero or more
 */
public record Slot(String code, String productName, long priceMinor, int quantity) {

    public Slot {
        Objects.requireNonNull(code, "code");
        Objects.requireNonNull(productName, "productName");
        if (code.isBlank()) {
            throw new IllegalArgumentException("a slot needs a code");
        }
        if (priceMinor < 0) {
            throw new IllegalArgumentException("a price cannot be negative: " + priceMinor);
        }
        if (quantity < 0) {
            throw new IllegalArgumentException("a slot cannot hold " + quantity + " items");
        }
    }
}

worked/src/Main.java42 lines

// Main.java
//
// The driver. It builds two machines from the one rack MachineFactory exposes, prices every
// slot under both tariffs, and prints the one line that would change in the real corpus
// patch — without touching MachineFactory, Machine or the rack to do it.
public final class Main {

    public static void main(String[] args) {
        Machine standard = MachineFactory.standard();

        System.out.println("1. today's tariff: ListPricing, you pay what the label says");
        printPrices(standard);

        System.out.println();
        System.out.println("2. a happy-hour machine, built by reusing the same rack the factory exposes");
        Machine happyHour = new Machine(MachineFactory.standardSlots(),
                new PromotionalPricing(new ListPricing()));
        printPrices(happyHour);

        System.out.println();
        System.out.println("3. what the real requirement change costs, measured");
        System.out.println("   corpus/vending-machine/curveballs/02-happy-hour/budget.json: reference_diff = 2");
        System.out.println("   (one line rewritten inside MachineFactory.standard(), one new file, free)");
        System.out.println("   corpus/vending-machine/curveballs/01-a-new-coin/budget.json: reference_diff = 0");
        System.out.println("   corpus/vending-machine/curveballs/03-maintenance-mode/budget.json: reference_diff = 8");

        System.out.println();
        System.out.println("4. the rack itself never moved between the two machines");
        System.out.println("   standard rack:   " + standard.slots());
        System.out.println("   happy-hour rack: " + happyHour.slots());
    }

    private static void printPrices(Machine machine) {
        for (Slot slot : machine.slots()) {
            long price = machine.priceOf(slot.code());
            System.out.printf("  %-4s %-10s label=%-4d  charged=%d%n",
                    slot.code(), slot.productName(), slot.priceMinor(), price);
        }
    }

    private Main() {}
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← B1 · Policy interface — the Strategy seam, and when it is not worth its file B3 · Builder for wide construction →

← all lessons