LLD Dojo

Syllabus · B1

Policy interface — the Strategy seam, and when it is not worth its file

The idea

Policy interface

A parking lot bills a stay by vehicle type. The version you write in minute three puts the rates in a switch inside ExitGate.feeMinor. On the afternoon it is written that is the right call: one class, nothing to follow.

Then finance replaces the tariff. First hour free, standard rate for hours two and three, long-stay rate from the fourth. You are back inside ExitGate.java, rewriting a method you had already tested.

Measured on the pair in contrast/: design A absorbs that change in 26 lines in one pre-existing file, a/ExitGate.java. Design B asks an injected PricingPolicy and absorbs it in 2 — a new file, TieredPricing.java, plus the one line that names today's tariff. Both numbers come from measureChange() in server/lib/diff.mjs, the function D4 is scored from. corpus/parking-lot/curveballs/02-tiered-pricing/budget.json records reference_diff: 2 for the same change at full scale.

The line count is not the whole difference. After A's edit, last month's tariff is gone. Against B, new ExitGate(new FlatHourlyPricing()) still compiles and still bills 8000, which is what auditing July needs.

So extract when a second implementation exists, when the requirements name one, or when the interviewer has said that rule moves. One implementation with no second in sight scores lower: D3 level 3 asks for a minimal seam set, and over-engineered routes back to when-not.md. Pulling the policy out afterwards cost 32 lines in one file, measured, base suite green throughout — a one-time price, paid when you know you need it.


Worked walkthrough

NOTES — eight files, and the two lines that decide whether the gate holds a rate

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: FlatHourlyPricing, a flat rate per started hour
  car, 1 minute          fee = 2000   stay = PT1M
  car, 61 minutes        fee = 4000   stay = PT1H1M
  car, 3h 1m             fee = 8000   stay = PT3H1M
  truck, 4 hours         fee = 16000  stay = PT4H
  motorbike, 0 seconds   fee = 1000   stay = PT0S

2. the new tariff: TieredPricing, first hour free
  car, 1 minute          fee = 0      stay = PT1M
  car, 61 minutes        fee = 1000   stay = PT1H1M
  car, 3h 1m             fee = 5000   stay = PT3H1M
  truck, 4 hours         fee = 10000  stay = PT4H
  motorbike, 0 seconds   fee = 0      stay = PT0S

3. one gate, two tariffs: the same stay after the change
  new ExitGate(new TieredPricing())    5000
  new ExitGate(new FlatHourlyPricing()) 8000  (last month's revenue is still billable)

4. what both tariffs agree on, because it is not behind the seam
  car, 1 minute          started hours = 1
  car, 61 minutes        started hours = 2
  car, 3h 1m             started hours = 4
  truck, 4 hours         started hours = 4
  motorbike, 0 seconds   started hours = 1

Two rows in that output are worth stopping on before reading any code.

car, 3h 1m costs 8000 on one tariff and 5000 on the other, and it is the requirement's own worked example: four started hours, free then 1000 then 1000 then 3000. Block 3 bills the same stay both ways from one ExitGate, which is the property that survives the tariff change.

motorbike, 0 seconds costs 1000 today and 0 next month. That is not a bug in either tariff. The contract says a zero-length stay is one started hour, and the new requirement says the first started hour is free. Two correct rules compose into free parking for a vehicle that never stopped. Raise that out loud in a round rather than discovering it in the suite.


PricingPolicy.java — the seam

long feeMinor(VehicleType type, Duration stay);

One method, and the whole design rests on which arguments it takes. VehicleType rather than Vehicle: a registration plate is not a pricing input, and passing the whole vehicle would let a future tariff quietly start charging by plate. Duration rather than two Instants: the gate has already measured the stay, so every tariff is spared the subtraction and none of them can disagree about it.

long, and the unit is minor units. A double fee loses paise on the way to a receipt, and no audit forgives that. This is the contract's rule, not a preference: "Money is always minor units (paise/cents) in a long. No doubles, no rounding of currency."

One abstract method also means the interface is a lambda. (type, stay) -> 0L is a valid PricingPolicy, which is why a test double needs no file. Add a default body to this method and that stops being true:

Stub.java:3: error: incompatible types: PricingPolicy is not a functional interface
    static final PricingPolicy FREE = (type, stay) -> 0L;
                                      ^
    no abstract method found in interface PricingPolicy
1 error

What a default body would break is worse than the lambda. A tariff someone writes next quarter and forgets to implement would compile and charge nothing, on every stay, silently. faded/GapTest.java asserts this interface has exactly one unanswered question for that reason.

static long startedHours(Duration stay) {
    long seconds = Math.max(0L, stay.toSeconds());
    return Math.max(1L, (seconds + 3599L) / 3600L);
}

This is the boundary of the seam, and drawing it here is the judgement the lesson is about. Rates vary. The unit they are charged in does not. A RoundingPolicy interface would be a second seam with one implementation, which when-not.md measures the cost of.

(seconds + 3599L) / 3600L is integer ceiling division, and toHours() is the trap. Duration.ofMinutes(61).toHours() returns 1, so a tariff written that way charges one hour for 61 minutes. Block 4 of the output is the check: 61 minutes is 2.

Math.max(1L, …) is the contract's floor, not defensive habit. "A zero-length stay costs one hour." Without it, a vehicle that arrives and leaves in the same second parks free under every tariff, including the flat one.

Math.max(0L, …) on the seconds guards a case the contract says cannot happen. ExitGate rejects an exit before an entry, so stay is never negative here. It costs one call and it means this method cannot return a negative unit count to a tariff that would multiply by it.

Static, on the interface, rather than a shared abstract base class. An abstract AbstractPricingPolicy would give every tariff a superclass it does not need, and Java has one of those to spend. J5 covers the choice; here the point is that a helper both implementations call is not a reason to make them siblings.


FlatHourlyPricing.java — the implementation that exists today

private static final Map<VehicleType, Long> PER_STARTED_HOUR = Map.of(
        VehicleType.MOTORBIKE, 1000L,
        VehicleType.CAR, 2000L,
        VehicleType.TRUCK, 4000L);

static final on a table shared by every instance, and Map.of makes it unmodifiable. No caller can reach it and no method can add to it, so the rates in this file are the rates in production. Map.of also refuses a duplicate key at class-initialisation time with IllegalArgumentException: duplicate key: CAR, which is the sort of paste error a rate table attracts.

private long rateFor(VehicleType type) {
    Long rate = PER_STARTED_HOUR.get(type);
    if (rate == null) {
        throw new IllegalArgumentException("no tariff configured for " + type);
    }
    return rate;
}

The null check is what stops a new vehicle type parking free. Add BICYCLE to VehicleType and this file still compiles. Measured, by adding that constant and billing a bicycle for an hour:

Exception in thread "main" java.lang.IllegalArgumentException: no tariff configured for BICYCLE
	at FlatHourlyPricing.rateFor(FlatHourlyPricing.java:21)
	at FlatHourlyPricing.feeMinor(FlatHourlyPricing.java:15)
	at ExitGate.charge(ExitGate.java:34)

Loud, at the till, on the first bicycle. Without the check, rate is null, the multiplication unboxes it and you get a NullPointerException with nothing in the message about tariffs.

Here is the one axis on which contrast/a's conditional beats this table, and it is real. Design A writes the rates as a switch expression over the enum with no default, so adding BICYCLE is a compile error:

ExitGate.java:28: error: the switch expression does not cover all possible input values
        long ratePerStartedHour = switch (type) {
                                  ^
1 error

Compile time beats run time. A table plus a throw is the corpus's choice because tariffs are data that finance edits. An exhaustive switch is the better tool when the set of cases is closed and known while you compile. Knowing which one you picked, and why, is the D3 answer; A1 covers the enum side of it.


TieredPricing.java — the second implementation, added not edited

This whole file is the answer to curveball 02, and no existing tariff was touched. corpus/parking-lot/curveballs/02-tiered-pricing/budget.json records reference_diff: 2, and the 2 is the convenience-constructor line in ExitGate, not anything in here.

private static final long FREE_HOURS = 1;
private static final long STANDARD_UNTIL_HOUR = 3;

Two named constants so the requirement's sentences are findable in the code. When finance says "make it two free hours", the edit is one number in one place, and it is the number the sentence named.

long fee = 0;
long hours = PricingPolicy.startedHours(stay);
for (long hour = FREE_HOURS + 1; hour <= hours; hour++) {
    fee += rate(hour <= STANDARD_UNTIL_HOUR ? STANDARD : LONG_STAY, type);
}
return fee;

Billing hour by hour is slower than the closed-form version and it is the right call. The closed form is three multiplications and two Math.min calls, and every one of them is a place to put the boundary off by one. This loop reads as the requirement reads: hour 1 free, hours 2 and 3 standard, the rest long-stay. Five started hours here is 0 + 1000 + 1000 + 3000 + 3000.

The loop starting at FREE_HOURS + 1 is where the free hour lives. Start it at 1 and a car staying 3h 1m pays 6000 instead of 5000. That is the failure faded/GapTest.java catches by name, because it is the mistake this arithmetic invites.

Calling PricingPolicy.startedHours rather than dividing by 3600 again is the invariant. Both tariffs round identically, so no stay can be two started hours to the flat tariff and one to this one. A tariff that rounds for itself passes every test you write for it and disagrees with its sibling at the 61-minute boundary.


ExitGate.java — the caller that asks

private final PricingPolicy pricing;

final means no code path can leave this gate without a tariff, and none can swap it mid-stay. Drop final and a setter becomes possible, and a gate whose tariff changes between park and unpark bills a stay under rules that were never in force for it.

public ExitGate() {
    this(new FlatHourlyPricing());
}

This line is the entire measured cost of the tariff change. contrast/ puts it at diffLines: 2, one added and one removed, and corpus/parking-lot's own reference patch changes the same single line in ParkingLot's convenience constructor. Wiring named in exactly one place is what makes the number 2 rather than 12.

The no-argument constructor exists so Entry.create() stays one line. That is the corpus's reason, and it is worth keeping: a design that needs a paragraph of assembly to instantiate is a design an interviewer cannot run.

public ExitGate(PricingPolicy pricing) {
    this.pricing = Objects.requireNonNull(pricing, "pricing");
}

The check fails at construction rather than at the first fee. A gate built with a null tariff and no check throws NullPointerException on the first vehicle to leave, in charge, pointing at a line that is not the mistake. C3 is the general form of this: dependencies arrive through the constructor and are validated there.

if (exitTime.isBefore(entryTime)) {
    throw new IllegalArgumentException("exit before entry for ticket " + ticketId);
}

Rejected explicitly, with the ticket id in the message. The contract requires this, and it is also what keeps startedHours from ever seeing a negative duration. The message names the ticket because a fee dispute starts with a ticket id and nothing else.

Duration stayed = Duration.between(entryTime, exitTime);
return new Receipt(ticketId, pricing.feeMinor(vehicle.type(), stayed), stayed);

The gate measures and the policy decides, and this line is where that split is either honoured or lost. stayed goes to the policy unrounded and onto the receipt unrounded. Round it here first, say by passing Duration.ofHours(stayed.toHours()), and the same stay is rounded twice. 3h 1m becomes 3 hours, then 3 started hours, and a car pays 6000 instead of 8000.

Receipt.stay() is exact and only the fee is rounded. That is the contract's sentence, and the output above shows it: stay = PT3H1M next to a fee computed from four started hours.

Nothing in this file names a rate. Search it for a number and you find none. That is the property the D3 anchor is describing when it says variation points sit behind interfaces, and it is checkable in one look.


Main.java — the driver

A missing driver caps D2 at 0, whatever the code does. Interviewers run it first. This one prints both tariffs over the same five stays rather than one happy path, which is the difference between D2 level 2 and level 3.

private record Stay(String label, VehicleType type, Duration length) {}

A local record so the five cases are data, not five copied blocks. Each stay sits on a boundary the two tariffs disagree about: one minute, the 61-minute rounding step, the requirement's own 3h 1m example, the fourth hour, and a zero-length stay.

System.out.printf("  new ExitGate(new FlatHourlyPricing()) %d%n", …);

Block 3 is the argument for the seam, in one line of output. After the tariff change, a gate on last month's rates is still constructible, still billable, and still 8000. In contrast/a-after that line does not compile, because FlatHourlyPricing was never a class there.


When not to

When a policy interface is not worth its file

Strategy is the most over-applied pattern in an LLD round, and the standard is built to notice. D3 level 3 reads "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". So a second PricingPolicy-shaped interface with one implementation does not add to your score. It subtracts.

The bad example, in the same file you have been reading

PricingPolicy.startedHours is the rounding rule: any part of an hour is a whole hour, and a zero-length stay is one. It is a static method on the interface, deliberately. Now take the lesson from contrast/ one step further than it goes:

public interface RoundingPolicy {
    long billableUnits(Duration stay);
}

public final class StartedHourRounding implements RoundingPolicy {
    @Override
    public long billableUnits(Duration stay) {
        long seconds = Math.max(0L, stay.toSeconds());
        return Math.max(1L, (seconds + 3599L) / 3600L);
    }
}

Then every tariff has to be handed one:

public TieredPricing(RoundingPolicy rounding) {
    this.rounding = Objects.requireNonNull(rounding, "rounding");
}

And then, because two constructor arguments feel like assembly work, PricingPolicyFactory arrives to build the pair. STANDARD v1.0 names this exact move: "A FooFactoryProvider for one concrete type is not sophistication."

What it costs, item by item

Two files that hold one arithmetic expression between them. RoundingPolicy.java and StartedHourRounding.java exist so that (seconds + 3599L) / 3600L can be replaced by something nobody has asked for.

A constructor parameter with exactly one possible value. Every tariff now takes a RoundingPolicy, and every call site passes new StartedHourRounding(). A parameter that only ever takes one value is a parameter that reads as a question and answers itself.

Four files to answer one question at 1am. A stay of 61 minutes billed 1000 and someone wants to know why. With the seam: ExitGate.charge to TieredPricing.feeMinor to RoundingPolicy to StartedHourRounding, and the answer is one line at the end of it. Without it: ExitGate.charge to TieredPricing.feeMinor to PricingPolicy.startedHours, and the last hop is a static method in a file you already had open.

A lower D3 score, with a tag attached. The failure tag is over-engineered (premature interface), and server/lib/lessons.mjs routes it straight back to this file: 'over-engineered': { item: 'B1', stage: 'when-not' }. A drill that earns it sends you here, not to the worked example.

And the measured payoff on the change that actually arrived is zero. Curveball 02 replaced the whole tariff and its requirement says, in one sentence, "Rounding does not change." Both FlatHourlyPricing and TieredPricing call PricingPolicy.startedHours, and neither of them would have called a RoundingPolicy differently. The seam absorbs nothing, because nothing moved along that axis.

Two implementations that should have been one

There is a second way to over-apply this, and it is subtler than the extra interface. If the only difference between two implementations is the numbers, the variation is data and not behaviour, and the answer is one class taking a table.

Look at what makes the corpus's two tariffs genuinely two classes. FlatHourlyPricing.feeMinor is rateFor(type) * startedHours(stay), a single multiplication. TieredPricing.feeMinor walks hour by hour, because hour 1 is free and hour 4 costs something different from hour 3. Those are different shapes of calculation, not two settings of one.

Had the new tariff been "the same flat rates, doubled at weekends", a second class would be the wrong answer. new FlatHourlyPricing(weekendRates) is the right one, and the seam you already have carries it.

The threshold, stated so you can fail it

Extract the interface when one of these is true, and not otherwise:

  1. A second implementation exists now. Not imagined. In your editor, or in the tests.
  2. The requirements name one. corpus/parking-lot/curveballs/01-motorbikes-share-a-spot is a second packing rule, stated as a requirement. SpotAllocator was already the seam it needed, so the patch absorbs it in reference_diff: 2: one new SharedMotorbikeAllocator.java, plus one rewritten constructor line.
  3. The interviewer has said that axis moves. "Pricing changes often" is a requirement, spoken aloud. Write it down and treat it as one.

Everything else stays a private method. contrast/ measures what it costs to change your mind later: 32 lines in one file, base suite green the whole way. Nineteen of those 32 are lines that moved rather than lines rewritten.

That number is also the sentence to say out loud when an interviewer asks why the tariff is still a method. "One tariff today, so it is a private method. If a second arrives I pull out a PricingPolicy. That is a one-file refactor with the suite green, and I would rather pay it once I know the shape of the second tariff."

That answer scores on D3 and on F4. Inventing the interface up front scores on neither.


The contrast pair

The curveball, and what each design paid to absorb it

What the interviewer says, ten minutes in

Finance signed off on a new tariff this morning and it is live from now on. The first started hour is free, for every vehicle. Hours two and three are charged at the standard rate: motorbike 500, car 1000, truck 2000. From the fourth started hour onward it is the long-stay rate: motorbike 1500, car 3000, truck 6000.

Rounding does not change. A car staying three hours and one minute has started four hours: free, then 1000, then 1000, then 3000. It pays 5000.

That is corpus/parking-lot/curveballs/02-tiered-pricing/REQUIREMENT-CHANGE.md, near enough verbatim, and CurveballTest asserts its worked example directly.

The two designs

a/ExitGate.java decides the fee itself, in a switch inside a private method. This is what a competent engineer writes under a 12-minute clock, and on the day it is written it is the right call: one class, one file, nothing to follow.

b/ExitGate.java asks a PricingPolicy it was handed, and b/FlatHourlyPricing.java is today's answer. PricingPolicy.java and FlatHourlyPricing.java are corpus/parking-lot/reference/src/ files with a provenance line added at the top and nothing else changed. Both gates are the billing half of that reference's ParkingLot, with the allocator half left out so the pricing seam is the only variable.

Design A is not weakened to make a point. It has one private feeMinor, so no rate table is duplicated, and its rounding helper is the same arithmetic the reference uses. Before the curveball lands the two designs are behaviourally identical, and BaseTest cannot tell them apart:

a  / BaseTest        [ 6 tests successful ][ 0 tests failed ]
b  / BaseTest        [ 6 tests successful ][ 0 tests failed ]
a  / CurveballTest   [ 0 tests successful ][ 6 tests failed ]
b  / CurveballTest   [ 0 tests successful ][ 6 tests failed ]

Six failures each, with the same messages, starting with expected: <5000> but was: <8000>. A grader looking only at behaviour has nothing to separate these designs, which is why STANDARD v1.0 measures extensibility (D4) apart from functionality (D2).

Both designs go green

a-after/ and b-after/ are the same two designs with the change absorbed. Both pass the curveball suite, and both fail the same four base-suite tests, because the tariff the base suite asserts has been replaced. That is expected here, and the corpus patch notes say so.

a-after / CurveballTest   [ 6 tests successful ][ 0 tests failed ]
b-after / CurveballTest   [ 6 tests successful ][ 0 tests failed ]
a-after / BaseTest        [ 2 tests successful ][ 4 tests failed ]
b-after / BaseTest        [ 2 tests successful ][ 4 tests failed ]

Identical outcomes. The whole difference is in what it cost to get there.

The measurement

Run from the repository root:

node -e "import('./server/lib/diff.mjs').then(async (m) => {
  const { readdirSync, readFileSync } = await import('node:fs');
  const f = (d) => readdirSync('lessons/B1/contrast/' + d)
    .filter((n) => n.endsWith('.java'))
    .map((n) => ({ path: n, content: readFileSync('lessons/B1/contrast/' + d + '/' + n, 'utf8') }));
  for (const [before, after] of [['a', 'a-after'], ['b', 'b-after'], ['a', 'b']]) {
    const r = m.measureChange(f(before), f(after));
    console.log(before, '->', after, JSON.stringify({ diffLines: r.diffLines,
      touchedFiles: r.touchedFiles, newFiles: r.newFiles, perFile: r.perFile }));
  }
})"
ChangediffLinespre-existing files editednew files
A absorbs the new tariff261 · a/ExitGate.java (+21 −5)0
B absorbs the new tariff21 · b/ExitGate.java (+1 −1)1 · TieredPricing.java
Extracting the policy later, behaviour unchanged321 · ExitGate.java (+13 −19)2

measureChange returns diffLines, and lines in brand-new files are free, because the cost being measured is being forced back into code that already existed. TieredPricing.java is 45 lines and charges nothing.

B's 2 is not a number this lesson invented. corpus/parking-lot/curveballs/02-tiered-pricing/budget.json records reference_diff: 2 for the same change against the full reference, with the same note: one new file, plus the one convenience-constructor line that names today's tariff. The seam reproduces its cost at two different scales.

Two lines is D4 level 2, not level 3. Level 3 wants zero lines changed in pre-existing files, and there are real cases that reach it. corpus/lru-cache/curveballs/01-least-frequently-used/budget.json records reference_diff: 0 for an entire new eviction policy, because the wiring line lives in the contract rather than in reference/src. corpus/logger/curveballs/03-rolling-appender/budget.json also records 0: Appender.write(String) already is the seam a rolling file destination needs, so reference/src is byte-identical before and after.

The difference the line count does not show

After A's edit, last month's tariff is gone. Rebilling a July stay for an audit needs a class that no longer exists:

Audit.java:8: error: cannot find symbol
        System.out.println(new ExitGate(new FlatHourlyPricing())
                                            ^
  symbol:   class FlatHourlyPricing
  location: class Audit
1 error

The same file compiles against b-after/ and prints 8000. FlatHourlyPricing was left in place rather than edited, which is what the reference patch does and why:

FlatHourlyPricing is left in place rather than edited — the old tariff is still a fact about last month's revenue, and deleting it would be a bigger diff than keeping it.

A tariff you can still instantiate is a tariff you can still test and still reconcile against. It is also the answer when an interviewer asks what happens to stays that were already open at the moment the rate changed.

The number that argues for the other side

Extracting the policy later cost 32 lines in one file, which is more than absorbing this tariff in place cost. Nineteen of those 32 are lines that moved out of ExitGate into the two new files, rather than lines rewritten. The base suite stays green throughout, so this is a refactor and not a rewrite. Read it as a one-time price you pay when you know you need the seam. Once paid, each tariff after it costs 2.


Worked source

The 8 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/ExitGate.java45 lines

// ExitGate.java
//
// The billing half of corpus/parking-lot/reference/src/ParkingLot.java, lifted out on its own so
// the pricing seam is the only thing in view. The allocator half (SpotGrid, SpotAllocator,
// FirstFitAllocator) is a second seam of the same shape and is left in the corpus.
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;

/**
 * Bills one stay. It measures the time and asks what that costs; it holds no rate.
 *
 * Every rule a requirement change can reach lives behind the policy, so what is left here is
 * the sequence — validate, measure, ask, issue — and the errors.
 */
public final class ExitGate {

    private final PricingPolicy pricing;

    /** The gate a caller builds with no arguments. Today's tariff is named on this one line. */
    public ExitGate() {
        this(new FlatHourlyPricing());
    }

    public ExitGate(PricingPolicy pricing) {
        this.pricing = Objects.requireNonNull(pricing, "pricing");
    }

    /**
     * @param exitTime when the vehicle left, always passed in rather than read from a clock
     * @return the ticket id, the fee in minor units, and the exact stay
     * @throws IllegalArgumentException if the vehicle left before it arrived
     */
    public Receipt charge(String ticketId, Vehicle vehicle, Instant entryTime, Instant exitTime) {
        Objects.requireNonNull(vehicle, "vehicle");
        Objects.requireNonNull(entryTime, "entryTime");
        Objects.requireNonNull(exitTime, "exitTime");
        if (exitTime.isBefore(entryTime)) {
            throw new IllegalArgumentException("exit before entry for ticket " + ticketId);
        }

        Duration stayed = Duration.between(entryTime, exitTime);
        return new Receipt(ticketId, pricing.feeMinor(vehicle.type(), stayed), stayed);
    }
}

worked/src/FlatHourlyPricing.java25 lines

// FlatHourlyPricing.java — verbatim from corpus/parking-lot/reference/src/FlatHourlyPricing.java.
import java.time.Duration;
import java.util.Map;

/** Today's tariff: a flat rate per started hour, by vehicle type. */
public final class FlatHourlyPricing implements PricingPolicy {

    private static final Map<VehicleType, Long> PER_STARTED_HOUR = Map.of(
            VehicleType.MOTORBIKE, 1000L,
            VehicleType.CAR, 2000L,
            VehicleType.TRUCK, 4000L);

    @Override
    public long feeMinor(VehicleType type, Duration stay) {
        return rateFor(type) * PricingPolicy.startedHours(stay);
    }

    private long rateFor(VehicleType type) {
        Long rate = PER_STARTED_HOUR.get(type);
        if (rate == null) {
            throw new IllegalArgumentException("no tariff configured for " + type);
        }
        return rate;
    }
}

worked/src/PricingPolicy.java28 lines

// PricingPolicy.java — verbatim from corpus/parking-lot/reference/src/PricingPolicy.java.
import java.time.Duration;

/**
 * Decides HOW MUCH a stay costs.
 *
 * The tariff is the requirement most likely to change — free hours, weekend rates, EV
 * discounts — so it is the one thing the lot must not know. The lot measures the stay
 * and asks; the policy answers in minor units.
 */
public interface PricingPolicy {

    /**
     * @param stay exact elapsed time in the lot, never negative
     * @return the fee in minor units (paise/cents)
     */
    long feeMinor(VehicleType type, Duration stay);

    /**
     * Started hours: any part of an hour is a whole hour, and even a zero-length stay is
     * one. Shared here because every tariff bills in the same unit — what differs between
     * policies is the rate, not the clock.
     */
    static long startedHours(Duration stay) {
        long seconds = Math.max(0L, stay.toSeconds());
        return Math.max(1L, (seconds + 3599L) / 3600L);
    }
}

worked/src/Receipt.java2 lines

// Receipt.java — GIVEN, verbatim from corpus/parking-lot/contract/Receipt.java.
public record Receipt(String ticketId, long feeMinor, java.time.Duration stay) {}

worked/src/TieredPricing.java45 lines

// TieredPricing.java — verbatim from
// corpus/parking-lot/curveballs/02-tiered-pricing/reference-patch/TieredPricing.java.
//
// This file is the whole answer to curveball 02. It was added, not edited into anything.
import java.time.Duration;
import java.util.Map;

/**
 * The new tariff: the first started hour free, the next two at the standard rate, everything
 * from the fourth onward at the long-stay rate. Billed hour by hour so the boundaries are the
 * requirement's own sentences rather than an algebraic rearrangement of them.
 */
public final class TieredPricing implements PricingPolicy {

    private static final long FREE_HOURS = 1;
    private static final long STANDARD_UNTIL_HOUR = 3;

    private static final Map<VehicleType, Long> STANDARD = Map.of(
            VehicleType.MOTORBIKE, 500L,
            VehicleType.CAR, 1000L,
            VehicleType.TRUCK, 2000L);

    private static final Map<VehicleType, Long> LONG_STAY = Map.of(
            VehicleType.MOTORBIKE, 1500L,
            VehicleType.CAR, 3000L,
            VehicleType.TRUCK, 6000L);

    @Override
    public long feeMinor(VehicleType type, Duration stay) {
        long fee = 0;
        long hours = PricingPolicy.startedHours(stay);
        for (long hour = FREE_HOURS + 1; hour <= hours; hour++) {
            fee += rate(hour <= STANDARD_UNTIL_HOUR ? STANDARD : LONG_STAY, type);
        }
        return fee;
    }

    private long rate(Map<VehicleType, Long> tier, VehicleType type) {
        Long rate = tier.get(type);
        if (rate == null) {
            throw new IllegalArgumentException("no tariff configured for " + type);
        }
        return rate;
    }
}

worked/src/Vehicle.java2 lines

// Vehicle.java — GIVEN, verbatim from corpus/parking-lot/contract/Vehicle.java.
public record Vehicle(String registration, VehicleType type) {}

worked/src/VehicleType.java2 lines

// VehicleType.java — GIVEN, verbatim from corpus/parking-lot/contract/VehicleType.java.
public enum VehicleType { MOTORBIKE, CAR, TRUCK }

worked/src/Main.java55 lines

// Main.java — the driver. An interviewer runs this first, so it shows the interesting cases
// rather than the happy path only.
import java.time.Duration;
import java.time.Instant;
import java.util.List;

public final class Main {

    private static final Instant ENTRY = Instant.parse("2026-08-18T09:00:00Z");

    /** Four stays chosen because each one lands on a boundary the tariffs disagree about. */
    private static final List<Stay> STAYS = List.of(
            new Stay("car, 1 minute", VehicleType.CAR, Duration.ofMinutes(1)),
            new Stay("car, 61 minutes", VehicleType.CAR, Duration.ofMinutes(61)),
            new Stay("car, 3h 1m", VehicleType.CAR, Duration.ofMinutes(181)),
            new Stay("truck, 4 hours", VehicleType.TRUCK, Duration.ofHours(4)),
            new Stay("motorbike, 0 seconds", VehicleType.MOTORBIKE, Duration.ZERO));

    private record Stay(String label, VehicleType type, Duration length) {}

    public static void main(String[] args) {
        bill("1. today's tariff: FlatHourlyPricing, a flat rate per started hour", new FlatHourlyPricing());
        bill("2. the new tariff: TieredPricing, first hour free", new TieredPricing());

        System.out.println();
        System.out.println("3. one gate, two tariffs: the same stay after the change");
        Vehicle car = new Vehicle("KA01AB1234", VehicleType.CAR);
        Instant exit = ENTRY.plus(Duration.ofMinutes(181));
        System.out.printf("  new ExitGate(new TieredPricing())    %d%n",
                new ExitGate(new TieredPricing()).charge("T1", car, ENTRY, exit).feeMinor());
        System.out.printf("  new ExitGate(new FlatHourlyPricing()) %d  (last month's revenue is still billable)%n",
                new ExitGate(new FlatHourlyPricing()).charge("T1", car, ENTRY, exit).feeMinor());

        System.out.println();
        System.out.println("4. what both tariffs agree on, because it is not behind the seam");
        for (Stay stay : STAYS) {
            System.out.printf("  %-22s started hours = %d%n",
                    stay.label(), PricingPolicy.startedHours(stay.length()));
        }
    }

    private static void bill(String heading, PricingPolicy tariff) {
        ExitGate gate = new ExitGate(tariff);
        System.out.println();
        System.out.println(heading);
        for (Stay stay : STAYS) {
            Vehicle vehicle = new Vehicle("KA01AB1234", stay.type());
            Receipt receipt = gate.charge("T1", vehicle, ENTRY, ENTRY.plus(stay.length()));
            System.out.printf("  %-22s fee = %-6d stay = %s%n",
                    stay.label(), receipt.feeMinor(), receipt.stay());
        }
    }

    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.

← A7 · Collection encapsulation — no mutable internals escape B2 · Factory — construction isolated from use →

← all lessons