Syllabus · D1
Method signature and naming design — what the call site can read
The idea
The call site is the API
park(vehicle, true) compiles and runs. What is true? Reserved, or recursive, or charge-on-entry? You have to open park to find out, and you will guess wrong once.
Across the 20 corpus/*/contract/ directories, not one method takes a boolean parameter. The only boolean parameter in any of them is an enum's own constructor, in TripState.java. Booleans come out of those APIs and never go in. ParkingLotApi.availableSpots(VehicleType) takes an enum, and Rule offers fixedWindow(...) and tokenBucket(...) as two named factories rather than one call with a flag. Either fix works.
The harder question is what a method returns when there is nothing to return. The corpus uses three answers on purpose. Optional<T> when absence is an ordinary outcome: SpotAllocator.allocate returns Optional<List<String>> because "no room" is what a search finds. A throwing accessor plus a cheap check when the caller usually knows the thing is there. FileSystemApi.read throws one of three exceptions, and exists never throws for anything. An empty collection, always, when the answer is a collection.
Never null. In worked/ a null from spotsFor is stored in a map, and the NullPointerException lands in countSpots, whose stack trace names neither the method that returned null nor the method that made it null.
Two properties of the signature itself are left. A name says whether state changes: tryAcquire charges, remaining does not. And a method that mutates and returns cannot be called twice, so you cannot quote a fee without billing it.
Worked walkthrough
Reading the two lots
ValetLot.java and Lot.java are the same class written twice. Same spot ids, same adjacency rule for a truck, same fees per started hour, same exit-before-entry guard. Diff the private helpers and you find runIsAvailable, feeMinor and footprint doing the same arithmetic in both.
Only the public surface differs. Nothing below is about correctness. ValetLot bills a 90-minute car stay at 4000 and refuses an arrival into a lot with no room, exactly as Lot does, and Main prints both. Everything quoted here comes from that run.
1 · Two booleans in a row
ValetLot.java:58
public Ticket park(Vehicle vehicle, Instant entryTime, boolean reserved, boolean waiveFee) {
The driver holds a reservation and the gate has been told to charge her, so the call wants (true, false). Main.java:39 writes (false, true):
== 1 - two booleans in a row ==
wrote valet.park(ROVER, 08:00, false, true)
ticket T1 on S2 (her reserved bay S1 is empty)
fee 0 minor units for a 90-minute stay
next car IllegalStateException: no spot available for CAR (S1 free, S2 taken)
She parks in the general run, her own bay stays empty all morning, the lot refuses the next car with a free spot in it, and she pays nothing. javac -Xlint:all reported zero errors and zero warnings on that file. Nothing in the language can help, because both arguments have the same type.
Lot.java:59 takes two enums instead:
public Ticket park(Vehicle vehicle, Instant entryTime, Reservation reservation, Billing billing) {
Now make the identical mistake:
Swap.java:9: error: incompatible types: Billing cannot be converted to Reservation
lot.park(rover, t, Billing.CHARGED, Reservation.RESERVED);
^
That is the benefit, stated as narrowly as it deserves. An enum parameter does not make the code shorter and it does not make it faster. It moves one class of mistake from a Tuesday-afternoon support ticket to a compiler error, and it makes lot.park(rover, t, RESERVED, CHARGED) legible without opening Lot.java.
Two methods work as well as an enum. corpus/rate-limiter/contract/Rule.java:70 and :78 are Rule.fixedWindow(name, limit, window) and Rule.tokenBucket(name, limit, window), over a canonical constructor that does take the Algorithm enum. Both forms are in that one file. Pick the enum when the axis has three or more values or is likely to grow; pick two methods when there are exactly two and the bodies differ.
A thing worth checking yourself. Across the 20 corpus/*/contract/ directories, not one method takes a boolean parameter:
grep -rn "boolean" corpus/*/contract/*.java
Every hit is a return type or a record component. FileSystemApi.exists, LruCacheApi.containsKey, Level.atLeast, Loan.overdueAt all return one. Decision, HoldResult, MonthlyCharge and CartPage all carry one as a field. The single boolean parameter in any of those directories is TripState(boolean terminal) at corpus/trip-state-machine/contract/TripState.java:38, which is an enum's own constructor and never appears at a call site. Booleans come out of these APIs. They do not go in.
FileSystemApi.delete is the clearest case of a flag that was refused rather than added. Its javadoc says delete "always removes a directory's entire contents along with it — there is no separate recursive flag and no refusal for a non-empty directory." A delete(path, true) would have been cheaper to write and unreadable at every call site.
And the overload trap. The tempting migration is to keep the old parameter and add a new overload. Two park overloads that differ only in their last parameter make some perfectly ordinary calls uncompilable:
Ticket park(Vehicle vehicle, Instant entryTime, Reservation reservation) { ... }
Ticket park(Vehicle vehicle, Instant entryTime, String reservationId) { ... }
park(vehicle, entryTime, null); // no reservation
Gate.java:13: error: reference to park is ambiguous
park(vehicle, entryTime, null);
^
both method park(Vehicle,Instant,Reservation) in Gate and method park(Vehicle,Instant,String) in Gate match
null is what a caller writes for a parameter that does not apply to them. A parameter that only sometimes applies is the same defect as a boolean flag, one layer down. Replace the parameter, or add a differently named method. Do not overload it.
2 · What comes back when there is nothing to come back
This is the part that costs the most points, because there are three defensible answers and the wrong one is defensible-looking.
Optional<T> — Lot.java:70
public Optional<Stay> openStay(String ticketId) {
return Optional.ofNullable(openStays.get(ticketId));
}
Optional.ofNullable, not Optional.of. Optional.of(openStays.get(ticketId)) compiles and throws NullPointerException on the first unknown ticket, which is the opposite of what the return type promises.
Threshold: use Optional<T> when absence is an ordinary outcome of the question, and when there is exactly one kind of absence. corpus/parking-lot/reference/DECISION_LOG.md states the first half directly, under "Errors: Optional inside, exceptions at the edge": "optionals for expected absence, exceptions for a broken request." SpotAllocator.allocate returns Optional<List<String>> because a search that finds nothing has not failed.
The second half is where Optional gets misused, and the corpus documents the failure rather than hiding it. LruCacheApi.get returns Optional<Object>, and rule 2 of its contract admits what that cannot express:
a key stored with a
nullvalue (see rule 3 below) reads back asOptional#empty()too, indistinguishable from absent bygetalone.
One Optional had to carry two facts, so the contract added containsKey as "the one way to tell 'present, holding null' apart from 'not present at all'". If your absence has two meanings, one Optional is not enough and a second method is the cheap fix.
A throwing accessor plus a cheap check — Lot.java:79 and :84
public Stay requireOpenStay(String ticketId) {
return openStay(ticketId).orElseThrow(() -> unknownTicket(ticketId));
}
public boolean isOpen(String ticketId) {
return openStays.containsKey(ticketId);
}
requireOpenStay is built on openStay rather than reading the map again. One lookup rule, one place. isOpen calls containsKey and not requireOpenStay, so it answers for any string on earth and throws for none — Main prints isOpen false, threw nothing for a closed ticket.
Threshold: throw when the caller normally knows the thing is there, and when the reasons it is not are worth telling apart. corpus/file-system/contract/FileSystemApi.java is the case. read can fail three ways, each its own exception: NoSuchPathException, NotADirectoryException, IsADirectoryException. An Optional<String> read(path) would collapse all three into one empty and a caller could not act on which happened.
The javadoc goes further and pins the order when several are true at once, for mv:
When more than one of these conditions holds at once, they are checked in this order: the source
must exist first, then the self-move check, then whether the destination already exists, then
whether the destination's parent exists.
That sentence is the signature's real contract. A caller writing a retry cannot use a thrown type unless it knows which one wins. That is the difference between an exception hierarchy that is useful and one that is decoration.
And exists is what keeps the throwing accessor honest. Its javadoc:
answers a yes/no question and never throws for "not found," including when a segment partway
through the path turns out to be a file rather than a directory — both count simply as "no."
A throwing accessor with no cheap companion forces callers into try/catch for control flow, which is how the throwing option earns its bad reputation.
An empty collection — Lot.java:89
public List<String> spotsOf(String ticketId) {
return openStay(ticketId).map(Stay::spotIds).orElse(List.of());
}
Threshold: a method returning a collection returns an empty one, always. Not null, and not Optional<List<T>>. FileSystemApi.ls says "an empty directory lists as an empty list". RateLimiterApi.rulesFor returns "an unmodifiable snapshot; empty for a key that was never configured". SpotGrid.occupantsOf returns List.copyOf of a possibly-empty list. Whether the collection came from an empty answer or a missing subject, the caller's loop runs zero times either way, and that is the same code.
There is one exception in the corpus and it is worth understanding rather than copying. SpotAllocator.allocate returns Optional<List<String>>, and Lot.java:149 follows it. The reason is that an empty list would already mean something there: "these are the spots to occupy, and there are none of them." A vehicle parked on zero spots is not a refusal, it is a bug. Lot.java:60 is where that matters:
List<String> spots = allocate(vehicle.type(), reservation).orElseThrow(() -> noRoomFor(vehicle.type()));
Change orElseThrow to orElse(List.of()) and the lot admits vehicles it has no room for, in silence. So: an empty collection unless an empty collection is itself a legal answer meaning something else.
Note where the exception surfaces. Optional inside, IllegalStateException at the API edge, which is the boundary the parking-lot decision log draws and which ParkingLotApi.park requires.
3 · Why null is never on that list
ValetLot.java:74
public List<String> spotsFor(String ticketId) {
Stay stay = openStays.get(ticketId);
if (stay == null) {
return null;
}
return stay.spotIds();
}
Two problems, and the second is the expensive one.
First, null means two different things here: this ticket was never issued, and this ticket's stay is already closed. A caller reconciling a floor plan wants to treat those differently, and cannot.
Second, the distance from cause to symptom is unbounded, because a null can be stored. Main parks two cars, bills one, asks two questions about capacity, then builds a floor plan and adds it up:
== 2 - null for two different situations ==
paid 4000 for T1, which closed that stay
free CAR 3
free TRUCK 1
floorPlan {T1=null, T2=[S2]}
occupied NullPointerException: Cannot invoke "java.util.List.size()" because "<local3>" is null
at Main.countSpots(Main.java:90)
at Main.nullTravels(Main.java:76)
at Main.main(Main.java:24)
Read the three frames. Main.countSpots is a four-line loop that adds sizes and contains no defect. spotsFor is not in the trace. fee, which is what made the ticket unknown, is not in the trace either. Java 21's helpful message is genuinely helpful when the dereference is on a call result, and it names the method that returned null. Store the null first and all it can say is <local3>.
Lot.spotsOf returns [] for the same ticket. Main prints spotsOf [], size 0, countSpots returns 1, and there is nothing to diagnose.
4 · A name that says whether state changes
corpus/rate-limiter/contract/RateLimiterApi.java has the strongest pair in the corpus:
| Method | What the name promises | What the javadoc confirms |
|---|---|---|
tryAcquire(key) | may refuse, and refusal is normal | "If every rule on the key allows, all of them are charged" |
remaining(key) | a quantity, not an action | "How many more requests key could make right now, without consuming anything" |
try is doing real work in that name. It says the call can come back empty-handed and that this is an outcome rather than an error, which is why Decision exists instead of an exception. remaining is a noun, and a noun cannot promise to charge you.
Now the same pair on Lot. quotedFeeMinor at :99 is a noun phrase and consumes nothing. closeStay at :109 is an imperative and does. You can read which is which from the names alone, and the return types agree: a long for the question, a Receipt for the event.
The mechanism must stay out of the name. LruCacheApi's class javadoc says which one it is avoiding and why:
the default is least-recently-used, but nothing in this interface says "recency" anywhere, on purpose, so that a different rule can be supplied without touching this contract at all
A method called bumpRecency or getLruQueue would have welded today's policy into the name. Rule 8 of that contract makes the same choice about iteration order. Nothing hands back the key set, because "exposing an ordered view would leak the policy's internal representation into the contract".
One name in the corpus that misses, so this is not a set of rules only other people break. ParkingLotApi.availableSpots(VehicleType) returns vehicles, not spots. Its javadoc has to open by correcting the name in bold: "How many more vehicles of this type could be parked right now". An empty 10-spot lot answers TRUCK 5, because a truck takes two adjacent spots. Lot.java:126 is admissionsLeftFor(VehicleType), which needs no correcting sentence. Here is the test worth applying under time pressure. If the javadoc's first line has to restate what the method returns in different words, the name is wrong.
5 · A method that mutates and returns cannot be called twice
ValetLot.java:87
public long fee(String ticketId, Instant exitTime) {
It computes the fee, and at :95 it removes the stay and frees the spots. Both, in one call. Then the requirement everybody gets: show the driver what she owes before she pays.
== 3 - fee() both answers and bills ==
quote 4000 (shown to the driver)
bill IllegalArgumentException: unknown or already-used ticket: T1
spots null (released by the quote)
The quote billed her. There is no way to write "quote, then bill" against this signature, because the only method that knows the number is also the method that ends the stay. Note how the failure presents. The exception comes from the second call, which looks correct. Its message says the ticket is unknown, so you go looking for a lost ticket rather than for a method that consumed it.
That is command-query separation, and it is a property of a signature rather than a style. long coming out of a method that changes state is the tell. corpus/parking-lot/reference/src/SpotAllocator.java states the rule in its own javadoc:
Deliberately pure: allocate() chooses spots and changes nothing, so the lot applies the decision. That is what makes "where would the next car go?" answerable without parking a car.
Lot splits it. quotedFeeMinor at :99 and closeStay at :109 share the private feeMinor, so there is one fee rule and two entry points:
quote 4000 then 4000, still open: true
close Receipt[ticketId=T1, feeMinor=4000, stay=PT1H30M]
Two quotes, same number, stay still open. The command returns a Receipt rather than a bare long, which is Decision's argument at corpus/rate-limiter/contract/Decision.java:9. An API that answers true "forces its caller to guess the two things it most needs".
Where the corpus deliberately breaks this, and says so. LruCacheApi.get updates eviction standing, so it mutates and returns. Rule 1 of that contract calls this out as "the single most consequential rule in this contract" and rule 4 supplies containsKey as the pure alternative. When you have to violate command-query separation, name the rule in the javadoc and ship the pure companion. Do not leave the caller to find out.
6 · Two arguments of the same type, side by side
ValetLot.java:107
public void setRates(long carMinor, long truckMinor) {
== 4 - two longs in a row ==
wrote valet.setRates(4000L, 2000L)
car fee 4000 for one hour, and the truck rate is now 2000
Every car in the lot is billed at the truck rate. No exception, no warning, and a test written by the same person who transposed the arguments will assert 4000.
Lot.java:142 takes the enum instead, one rate per call:
public void setRatePerStartedHour(VehicleType type, long minorUnits) {
Swap.java:10: error: incompatible types: long cannot be converted to VehicleType
lot.setRatePerStartedHour(2000L, VehicleType.CAR);
^
The general form is a value object, and lessons/A2 is where that is taught. What belongs here is the signature half of it, which corpus/rate-limiter/contract/ClientKey.java states in one sentence:
a bare
Stringparameter next to a rule name (also aString) is a call the compiler cannot check
Same reasoning, one type up. Rule's window is a Duration and not a long of nanoseconds, so Rule.fixedWindow("burst", 5, Duration.ofSeconds(1)) cannot be transposed into five seconds of one request. Scan your parameter lists for two adjacent parameters of the same type. Each pair is a call site the compiler has stopped checking.
7 · Three methods, and that is the point
corpus/parking-lot/contract/ParkingLotApi.java declares exactly three methods: park, unpark, availableSpots. The reference implementation behind them is ten files — SpotGrid, SpotAllocator, FirstFitAllocator, PricingPolicy, FlatHourlyPricing, Footprint, Occupancy, Stay, ParkingLot, Demo.
Not one of those ten names appears in the contract. That is what the three methods buy. Every one of them can be renamed, split or replaced without a caller noticing, and a candidate who reorganises the internals mid-round breaks nothing.
The third method is the one to study. availableSpots(VehicleType) returns an int, so nothing needs to hand back the grid to answer "will my truck fit?" — a Map<String, List<Occupancy>> getter would have answered the same question and exposed the storage, the policy, and a mutable internal all at once. lessons/A7 measures what that costs. Here it is enough to notice that the smallest surface that answers the question is the one that does not need a name for how the answer is computed.
When not to
When the split costs more than the flag
Three of the moves in this lesson have a price, and one of them is a race condition.
Splitting a command from a query, when the two must be one step
Lot separates quotedFeeMinor from closeStay, and that is right for a parking lot. The reason is in the contract, not in a principle: corpus/parking-lot/contract/ParkingLotApi.java says the lot is "Single-threaded. The grader never calls two methods at once."
corpus/rate-limiter is the same problem with that sentence removed, and it refuses the split. RateLimiterApi.tryAcquire charges the budget and returns the outcome in one call. There is no canAcquire beside it, and the contract explains why:
"Read the counter, decide there is room, write the counter back" is three steps, and unless they
are one atomic step every thread sees the same room and every thread takes it.
Decision's javadoc closes the door: "If it is true the request has already been counted; there is no second call to confirm it." A pure canAcquire(key) followed by a charge(key) is the check-then-act race, spelled out. The corpus states the failure size too: with n threads racing on k units, a split design lets through "k + 30", not k.
So the rule has a boundary. Split a command from a query when the caller may act on the answer without the world changing underneath. Keep them in one call when the answer is only true for the instant it was computed, and then say so in the javadoc, as LruCacheApi rule 1 does for get. lessons/E1 and lessons/E2 own the rest of that argument.
Note what the corpus still gives you. remaining(key) is the pure query, and it is documented as a figure that "can rise on its own as time passes". The query survives; what does not survive is any promise that acting on it is safe.
Two named methods, once there are two axes
Rule.fixedWindow(...) and Rule.tokenBucket(...) are cheaper than an enum parameter, for one axis with two values. Add a second axis and count the methods.
Lot.park has two: Reservation with two constants, Billing with two. As named methods that is park, parkReserved, parkWaived, parkReservedWaived — four, and sixteen when a third two-valued axis arrives. Every one of them is a real method with a real body, and adding WAITLISTED to Reservation doubles the set again.
Threshold. Two named methods for one axis of two values whose bodies genuinely differ. An enum parameter as soon as there are two axes, three constants, or a constant you expect to add.
Optional where the caller needed to know which failure it was
Optional looks like the safe default because it can never be null. It is the wrong return when absence has more than one cause the caller acts on differently.
FileSystemApi.read can fail as NoSuchPathException, NotADirectoryException or IsADirectoryException. Write it as Optional<String> read(String path) and all three arrive as Optional.empty(). A caller writing a create-if-missing retry has to call exists and kindOf to rebuild the distinction that was thrown away. It also has to guess which check read ran first. mv's javadoc pins that order precisely because a caller needs it.
The narrower cost: Optional as a field or as a parameter. Optional<Instant> in a record adds a box per instance and a second empty state beside null, and no corpus record uses one that way. GameState and MoveResult carry Optional<Mark> winner as a component, which is a return through an accessor, and both constructors refuse an inconsistent pairing outright.
What this item does not buy
A signature rewrite does not change a line count, and this lesson measures none. ValetLot and Lot compute fees and allocations in the same private helpers, so a requirement landing in feeMinor or runIsAvailable costs both designs the same. measureChange, the function that scores D4, would report a tie, and reporting a tie is the honest outcome here.
The instruments that do see this work are named in worked/NOTES.md. Two of the four defects are caught by javac, and the messages are quoted. The other two are caught by a reader, and one of them by a caller who is billed twice. Claim it on that basis and no other.
Worked source
The 10 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/Billing.java8 linesworked/src/Lot.java200 linesworked/src/Receipt.java2 linesworked/src/Reservation.java9 linesworked/src/Stay.java15 linesworked/src/Ticket.java2 linesworked/src/ValetLot.java159 linesworked/src/Vehicle.java2 linesworked/src/VehicleType.java2 linesworked/src/Main.java161 lines
worked/src/Billing.java8 lines
/**
* Whether this stay is billed at all.
*
* Separate from {@link Reservation} on purpose, and that is the whole point of the pair: a signature
* taking {@code (Reservation, Billing)} cannot have its last two arguments swapped, because
* {@code javac} refuses it. A signature taking {@code (boolean, boolean)} can.
*/
public enum Billing { CHARGED, WAIVED }
worked/src/Lot.java200 lines
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* The same floor, the same arithmetic, four signatures rewritten.
*
* <p>Every private helper here computes what {@link ValetLot}'s does. The difference is at the
* boundary, which is the item: a signature is the part of a class that a stranger reads, and it is
* the part a compiler can be made to check.
*
* <p>Three shapes for "there is nothing to return", and each one is used where it belongs rather
* than everywhere:
* <ul>
* <li>{@link #openStay} returns {@code Optional<Stay>} — absence is an ordinary outcome of asking
* about a ticket the caller may not hold.</li>
* <li>{@link #requireOpenStay} throws, and {@link #isOpen} is the cheap check that never does.
* Same pairing as {@code FileSystemApi.read} against {@code FileSystemApi.exists}.</li>
* <li>{@link #spotsOf} returns an empty list. A collection return is never {@code null} and never
* {@code Optional}.</li>
* </ul>
*/
public final class Lot {
private final List<String> spotIds;
private final Set<String> reservedBays;
private final Map<String, Stay> openStays = new LinkedHashMap<>();
private final Map<String, String> occupiedBy = new LinkedHashMap<>();
private final Map<VehicleType, Long> ratePerStartedHour = new EnumMap<>(VehicleType.class);
private long issued;
public Lot(int standardSpots, Set<String> reservedBays) {
if (standardSpots < 1) {
throw new IllegalArgumentException("a lot needs at least one spot, got " + standardSpots);
}
List<String> ids = new ArrayList<>(standardSpots);
for (int i = 1; i <= standardSpots; i++) {
ids.add("S" + i);
}
this.spotIds = List.copyOf(ids);
this.reservedBays = Set.copyOf(reservedBays);
this.ratePerStartedHour.put(VehicleType.MOTORBIKE, 1000L);
this.ratePerStartedHour.put(VehicleType.CAR, 2000L);
this.ratePerStartedHour.put(VehicleType.TRUCK, 4000L);
}
/**
* Admit a vehicle. The call site reads {@code lot.park(rover, t, RESERVED, CHARGED)}.
*
* <p>Swapping the last two arguments does not compile, which is the entire benefit over two
* booleans. See worked/NOTES.md for the {@code javac} message.
*/
public Ticket park(Vehicle vehicle, Instant entryTime, Reservation reservation, Billing billing) {
List<String> spots = allocate(vehicle.type(), reservation).orElseThrow(() -> noRoomFor(vehicle.type()));
Ticket ticket = new Ticket("T" + (++issued), spots.get(0), entryTime);
for (String id : spots) {
occupiedBy.put(id, ticket.ticketId());
}
openStays.put(ticket.ticketId(), new Stay(ticket, vehicle, spots, billing));
return ticket;
}
/** The open stay for a ticket, or empty if there is none. Never throws for an unknown ticket. */
public Optional<Stay> openStay(String ticketId) {
return Optional.ofNullable(openStays.get(ticketId));
}
/**
* The open stay for a ticket the caller is asserting exists.
*
* @throws IllegalArgumentException if the ticket was never issued or its stay is already closed
*/
public Stay requireOpenStay(String ticketId) {
return openStay(ticketId).orElseThrow(() -> unknownTicket(ticketId));
}
/** Whether a stay is open under this ticket. Answers for any string, throws for none. */
public boolean isOpen(String ticketId) {
return openStays.containsKey(ticketId);
}
/** The spots a ticket holds, lowest-numbered first. Empty for a ticket holding none. */
public List<String> spotsOf(String ticketId) {
return openStay(ticketId).map(Stay::spotIds).orElse(List.of());
}
/**
* What this stay would cost if the vehicle left at {@code exitTime}. Consumes nothing, releases
* nothing, and returns the same number every time it is asked.
*
* @throws IllegalArgumentException if the ticket has no open stay, or the exit precedes entry
*/
public long quotedFeeMinor(String ticketId, Instant exitTime) {
Stay stay = requireOpenStay(ticketId);
return feeMinor(stay, stayLength(stay, exitTime, ticketId));
}
/**
* Release the spots and bill the stay. The command half of the pair above.
*
* @throws IllegalArgumentException if the ticket has no open stay, or the exit precedes entry
*/
public Receipt closeStay(String ticketId, Instant exitTime) {
Stay stay = requireOpenStay(ticketId);
Duration stayed = stayLength(stay, exitTime, ticketId);
openStays.remove(ticketId);
for (String id : stay.spotIds()) {
occupiedBy.remove(id);
}
return new Receipt(ticketId, feeMinor(stay, stayed), stayed);
}
/**
* How many more walk-in vehicles of this type could be admitted right now.
*
* <p>Named for the answer rather than for the storage. The corpus calls this
* {@code availableSpots(VehicleType)} and then has to correct itself in the javadoc — see
* NOTES.md.
*/
public int admissionsLeftFor(VehicleType type) {
int need = footprint(type);
int count = 0;
int i = 0;
while (i + need <= spotIds.size()) {
if (runIsAvailable(i, need, Reservation.WALK_IN)) {
count++;
i += need;
} else {
i++;
}
}
return count;
}
/** One rate at a time, keyed by the enum, so the two arguments cannot be transposed. */
public void setRatePerStartedHour(VehicleType type, long minorUnits) {
if (minorUnits < 0) {
throw new IllegalArgumentException("a rate cannot be negative, got " + minorUnits);
}
ratePerStartedHour.put(type, minorUnits);
}
private Optional<List<String>> allocate(VehicleType type, Reservation reservation) {
int need = footprint(type);
for (int i = 0; i + need <= spotIds.size(); i++) {
if (runIsAvailable(i, need, reservation)) {
return Optional.of(List.copyOf(spotIds.subList(i, i + need)));
}
}
return Optional.empty();
}
private boolean runIsAvailable(int from, int need, Reservation reservation) {
for (int k = from; k < from + need; k++) {
if (!isAvailableTo(spotIds.get(k), reservation)) {
return false;
}
}
return true;
}
/** Whether one spot may be handed to an arrival admitted under this reservation. */
private boolean isAvailableTo(String spotId, Reservation reservation) {
return !occupiedBy.containsKey(spotId)
&& (reservation == Reservation.RESERVED || !reservedBays.contains(spotId));
}
private Duration stayLength(Stay stay, Instant exitTime, String ticketId) {
if (exitTime.isBefore(stay.ticket().entryTime())) {
throw new IllegalArgumentException("exit before entry for ticket " + ticketId);
}
return Duration.between(stay.ticket().entryTime(), exitTime);
}
private long feeMinor(Stay stay, Duration stayed) {
if (stay.billing() == Billing.WAIVED) {
return 0L;
}
long startedHours = Math.max(1L, (stayed.getSeconds() + 3599L) / 3600L);
return startedHours * ratePerStartedHour.get(stay.vehicle().type());
}
private static int footprint(VehicleType type) {
return type == VehicleType.TRUCK ? 2 : 1;
}
private static IllegalStateException noRoomFor(VehicleType type) {
return new IllegalStateException("no spot available for " + type);
}
private static IllegalArgumentException unknownTicket(String ticketId) {
return new IllegalArgumentException("unknown or already-closed ticket: " + ticketId);
}
}
worked/src/Receipt.java2 lines
/** Copied from corpus/parking-lot/contract/Receipt.java, unchanged. */
public record Receipt(String ticketId, long feeMinor, java.time.Duration stay) {}
worked/src/Reservation.java9 lines
/**
* Why an arriving vehicle is being admitted.
*
* The type exists so that the third argument of {@code park} is a word at the call site instead of
* {@code true}. It has two constants today and it is not a boolean in disguise: WAITLISTED and
* STAFF_PERMIT are the two the operator has already asked about, and a boolean would need a second
* flag for each of them.
*/
public enum Reservation { WALK_IN, RESERVED }
worked/src/Stay.java15 lines
import java.util.List;
/**
* One vehicle's open stay: the ticket it was issued, what parked, where, and whether it pays.
*
* Shape borrowed from corpus/parking-lot/reference/src/Stay.java, which holds the ticket, the
* vehicle and the spot ids. The {@link Billing} field is this lesson's addition, because the fee
* waiver is the second flag that makes {@code (boolean, boolean)} unreadable.
*/
public record Stay(Ticket ticket, Vehicle vehicle, List<String> spotIds, Billing billing) {
public Stay {
spotIds = List.copyOf(spotIds);
}
}
worked/src/Ticket.java2 lines
/** Copied from corpus/parking-lot/contract/Ticket.java, unchanged. */
public record Ticket(String ticketId, String spotId, java.time.Instant entryTime) {}
worked/src/ValetLot.java159 lines
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* One floor of a lot, with the signatures a competent engineer reaches for under a twelve-minute
* clock. Nothing in here is arithmetically wrong. Adjacency for a truck, fees per started hour,
* reserved bays, the exit-before-entry guard: all correct, and a base suite over park and fee is
* green against it.
*
* <p>What is wrong is what a caller can <i>read</i>, and what a caller can do <i>twice</i>. Four
* defects, one per signature, and {@code Main} runs all four:
*
* <ul>
* <li>{@link #park} takes two booleans in a row, so the call site says {@code true, false} and
* the two can be swapped with no complaint from {@code javac}.</li>
* <li>{@link #spotsFor} returns {@code null} for two different situations, and the caller cannot
* tell them apart.</li>
* <li>{@link #fee} computes a number <b>and</b> closes the stay, so it cannot be called twice and
* a quote cannot be given without billing.</li>
* <li>{@link #setRates} takes two {@code long}s in a row with no type between them.</li>
* </ul>
*/
public final class ValetLot {
private final List<String> spotIds;
private final Set<String> reservedBays;
private final Map<String, Stay> openStays = new LinkedHashMap<>();
private final Map<String, String> occupiedBy = new LinkedHashMap<>();
private final Map<VehicleType, Long> ratePerStartedHour = new EnumMap<>(VehicleType.class);
private long issued;
public ValetLot(int standardSpots, Set<String> reservedBays) {
if (standardSpots < 1) {
throw new IllegalArgumentException("a lot needs at least one spot, got " + standardSpots);
}
List<String> ids = new ArrayList<>(standardSpots);
for (int i = 1; i <= standardSpots; i++) {
ids.add("S" + i);
}
this.spotIds = List.copyOf(ids);
this.reservedBays = Set.copyOf(reservedBays);
this.ratePerStartedHour.put(VehicleType.MOTORBIKE, 1000L);
this.ratePerStartedHour.put(VehicleType.CAR, 2000L);
this.ratePerStartedHour.put(VehicleType.TRUCK, 4000L);
}
/**
* Defect 1. {@code valet.park(rover, t, true, false)} at a call site tells the next reader
* nothing, and the reader who guesses gets a car in the wrong bay for free.
*/
public Ticket park(Vehicle vehicle, Instant entryTime, boolean reserved, boolean waiveFee) {
List<String> spots = allocate(vehicle.type(), reserved).orElseThrow(
() -> new IllegalStateException("no spot available for " + vehicle.type()));
Ticket ticket = new Ticket("T" + (++issued), spots.get(0), entryTime);
for (String id : spots) {
occupiedBy.put(id, ticket.ticketId());
}
openStays.put(ticket.ticketId(),
new Stay(ticket, vehicle, spots, waiveFee ? Billing.WAIVED : Billing.CHARGED));
return ticket;
}
/**
* Defect 2. {@code null} means "no such ticket was ever issued" and it also means "that stay
* has already been closed". Those are different facts and a caller may need to act on which.
*/
public List<String> spotsFor(String ticketId) {
Stay stay = openStays.get(ticketId);
if (stay == null) {
return null;
}
return stay.spotIds();
}
/**
* Defect 3. This bills the stay: the spots are released and the ticket stops existing. The name
* says only that a number comes back, and the return value is what a caller reaches for when
* they want to show a driver the cost before she pays.
*/
public long fee(String ticketId, Instant exitTime) {
Stay stay = openStays.get(ticketId);
if (stay == null) {
throw new IllegalArgumentException("unknown or already-used ticket: " + ticketId);
}
if (exitTime.isBefore(stay.ticket().entryTime())) {
throw new IllegalArgumentException("exit before entry for ticket " + ticketId);
}
openStays.remove(ticketId);
for (String id : stay.spotIds()) {
occupiedBy.remove(id);
}
Duration stayed = Duration.between(stay.ticket().entryTime(), exitTime);
if (stay.billing() == Billing.WAIVED) {
return 0L;
}
return feeMinor(stay.vehicle().type(), stayed);
}
/** Defect 4. Two {@code long}s, same type, adjacent, and the compiler cannot tell them apart. */
public void setRates(long carMinor, long truckMinor) {
ratePerStartedHour.put(VehicleType.CAR, carMinor);
ratePerStartedHour.put(VehicleType.TRUCK, truckMinor);
}
/** How many more walk-in vehicles of this type would fit right now. */
public int availableSpots(VehicleType type) {
int need = footprint(type);
int count = 0;
int i = 0;
while (i + need <= spotIds.size()) {
if (runIsAvailable(i, need, false)) {
count++;
i += need;
} else {
i++;
}
}
return count;
}
private Optional<List<String>> allocate(VehicleType type, boolean reserved) {
int need = footprint(type);
for (int i = 0; i + need <= spotIds.size(); i++) {
if (runIsAvailable(i, need, reserved)) {
return Optional.of(List.copyOf(spotIds.subList(i, i + need)));
}
}
return Optional.empty();
}
private boolean runIsAvailable(int from, int need, boolean reserved) {
for (int k = from; k < from + need; k++) {
String id = spotIds.get(k);
if (occupiedBy.containsKey(id)) {
return false;
}
if (!reserved && reservedBays.contains(id)) {
return false;
}
}
return true;
}
private long feeMinor(VehicleType type, Duration stayed) {
long startedHours = Math.max(1L, (stayed.getSeconds() + 3599L) / 3600L);
return startedHours * ratePerStartedHour.get(type);
}
private static int footprint(VehicleType type) {
return type == VehicleType.TRUCK ? 2 : 1;
}
}
worked/src/Vehicle.java2 lines
/** Copied from corpus/parking-lot/contract/Vehicle.java, unchanged. */
public record Vehicle(String registration, VehicleType type) {}
worked/src/VehicleType.java2 lines
/** Copied from corpus/parking-lot/contract/VehicleType.java, unchanged. */
public enum VehicleType { MOTORBIKE, CAR, TRUCK }
worked/src/Main.java161 lines
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Runs the four defects and then the same five requests against the rewritten signatures.
*
* Every number and every message printed here appears in worked/NOTES.md. Nothing is described
* that is not printed.
*/
public final class Main {
private static final Instant EIGHT = Instant.parse("2026-08-19T08:00:00Z");
private static final Instant NINE_THIRTY = Instant.parse("2026-08-19T09:30:00Z");
private static final Vehicle ROVER = new Vehicle("KA01AB1234", VehicleType.CAR);
private static final Vehicle MINI = new Vehicle("KA02CD5678", VehicleType.CAR);
public static void main(String[] args) {
swappedBooleans();
nullTravels();
quoteBills();
swappedLongs();
theRewrittenSurface();
}
/**
* Defect 1. She holds a reservation and the gate has been told to charge her. The two flags go
* in the wrong order, and javac has nothing to say about it.
*/
private static void swappedBooleans() {
System.out.println("== 1 - two booleans in a row ==");
ValetLot valet = new ValetLot(2, Set.of("S1"));
// Intended: reserved = true, waiveFee = false.
Ticket ticket = valet.park(ROVER, EIGHT, false, true);
System.out.println("wrote valet.park(ROVER, 08:00, false, true)");
System.out.println("ticket " + ticket.ticketId() + " on " + ticket.spotId()
+ " (her reserved bay S1 is empty)");
System.out.println("fee " + valet.fee(ticket.ticketId(), NINE_THIRTY)
+ " minor units for a 90-minute stay");
ValetLot second = new ValetLot(2, Set.of("S1"));
second.park(ROVER, EIGHT, false, true);
try {
second.park(MINI, EIGHT, false, false);
} catch (IllegalStateException refused) {
System.out.println("next car IllegalStateException: " + refused.getMessage()
+ " (S1 free, S2 taken)");
}
System.out.println();
}
/** Defect 2. The null is produced in one method and dereferenced in another. */
private static void nullTravels() {
System.out.println("== 2 - null for two different situations ==");
ValetLot valet = new ValetLot(4, Set.of());
Ticket rover = valet.park(ROVER, EIGHT, false, false);
Ticket mini = valet.park(MINI, EIGHT, false, false);
System.out.println("paid " + valet.fee(rover.ticketId(), NINE_THIRTY)
+ " for " + rover.ticketId() + ", which closed that stay");
System.out.println("free CAR " + valet.availableSpots(VehicleType.CAR));
System.out.println("free TRUCK " + valet.availableSpots(VehicleType.TRUCK));
Map<String, List<String>> floorPlan = new LinkedHashMap<>();
floorPlan.put(rover.ticketId(), valet.spotsFor(rover.ticketId()));
floorPlan.put(mini.ticketId(), valet.spotsFor(mini.ticketId()));
System.out.println("floorPlan " + floorPlan);
try {
System.out.println("occupied " + countSpots(floorPlan));
} catch (NullPointerException npe) {
System.out.println("occupied NullPointerException: " + npe.getMessage());
for (StackTraceElement frame : npe.getStackTrace()) {
System.out.println(" at " + frame);
}
}
System.out.println();
}
/** Adds up the spots in a floor plan. Contains no defect and is where the failure surfaces. */
private static int countSpots(Map<String, List<String>> floorPlan) {
int total = 0;
for (List<String> spots : floorPlan.values()) {
total += spots.size();
}
return total;
}
/** Defect 3. A method that mutates and returns cannot be called twice. */
private static void quoteBills() {
System.out.println("== 3 - fee() both answers and bills ==");
ValetLot valet = new ValetLot(4, Set.of());
Ticket rover = valet.park(ROVER, EIGHT, false, false);
System.out.println("quote " + valet.fee(rover.ticketId(), NINE_THIRTY)
+ " (shown to the driver)");
try {
System.out.println("bill " + valet.fee(rover.ticketId(), NINE_THIRTY));
} catch (IllegalArgumentException gone) {
System.out.println("bill IllegalArgumentException: " + gone.getMessage());
}
System.out.println("spots " + valet.spotsFor(rover.ticketId())
+ " (released by the quote)");
System.out.println();
}
/** Defect 4. Two longs, transposed, and the wrong rate is charged in silence. */
private static void swappedLongs() {
System.out.println("== 4 - two longs in a row ==");
ValetLot valet = new ValetLot(4, Set.of());
// Intended: car 2000, truck 4000.
valet.setRates(4000L, 2000L);
Ticket rover = valet.park(ROVER, EIGHT, false, false);
System.out.println("wrote valet.setRates(4000L, 2000L)");
System.out.println("car fee " + valet.fee(rover.ticketId(), EIGHT.plus(Duration.ofHours(1)))
+ " for one hour, and the truck rate is now 2000");
System.out.println();
}
/** The same five requests against Lot, whose signatures answer each of them directly. */
private static void theRewrittenSurface() {
System.out.println("== 5 - the rewritten signatures ==");
Lot lot = new Lot(2, Set.of("S1"));
lot.setRatePerStartedHour(VehicleType.CAR, 2000L);
Ticket ticket = lot.park(ROVER, EIGHT, Reservation.RESERVED, Billing.CHARGED);
System.out.println("park lot.park(ROVER, 08:00, RESERVED, CHARGED) -> "
+ ticket.ticketId() + " on " + ticket.spotId());
System.out.println("quote " + lot.quotedFeeMinor(ticket.ticketId(), NINE_THIRTY)
+ " then " + lot.quotedFeeMinor(ticket.ticketId(), NINE_THIRTY)
+ ", still open: " + lot.isOpen(ticket.ticketId()));
Receipt receipt = lot.closeStay(ticket.ticketId(), NINE_THIRTY);
System.out.println("close " + receipt);
Optional<Stay> gone = lot.openStay(ticket.ticketId());
System.out.println("openStay " + gone + " after closing");
System.out.println("isOpen " + lot.isOpen(ticket.ticketId()) + ", threw nothing");
System.out.println("spotsOf " + lot.spotsOf(ticket.ticketId()) + ", size "
+ lot.spotsOf(ticket.ticketId()).size());
try {
lot.requireOpenStay(ticket.ticketId());
} catch (IllegalArgumentException absent) {
System.out.println("require IllegalArgumentException: " + absent.getMessage());
}
System.out.println("admissions CAR " + lot.admissionsLeftFor(VehicleType.CAR)
+ ", TRUCK " + lot.admissionsLeftFor(VehicleType.TRUCK));
}
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.