Syllabus · C2
Open/closed — the seam has to sit on the axis the change moves along
The idea
The seam only pays if the change moves along it
"Extend by adding, not editing" describes the outcome. The decision is which axis to open, and you make it before you know what the interviewer will change.
The corpus records how that bet turned out 60 times, once per curveball, every one of them with "measured": true. In corpus/lru-cache/curveballs/01-least-frequently-used/budget.json a whole new eviction policy landed for reference_diff: 0 — one new file, and not a line of an old one. A new coin in corpus/vending-machine/curveballs/01-a-new-coin is 0 as well, and so is the rolling appender in corpus/logger/curveballs/03-rolling-appender.
Then corpus/middleware-router/curveballs/02-method-aware-routing measures 47, and corpus/trip-state-machine/curveballs/03-no-show-timeout measures 28. Both of those designs have real seams. The requirement moved along a dimension their signatures never carried: a method ahead of a path, a rule that depends on a third input.
A seam is not insurance. It is a bet with a direction.
contrast/ measures the same pair twice, with measureChange() from server/lib/diff.mjs. Tiered pricing, the requirement from corpus/parking-lot/curveballs/02-tiered-pricing, costs 22 lines in contrast/a/ParkingLot.java and 2 in contrast/b/ — the same 2 the corpus records for its own reference. Then an overnight flat rate, which needs the entry time, costs 14 in a/ and 27 in b/. The seam made that change worse than having none. Reproduce both with node lessons/C2/contrast/measure.mjs.
Open a seam when you can name the second implementation and write the one signature both satisfy. Naming the dimension is not enough.
Worked walkthrough
NOTES — eight files, and three lines that decide what a tariff change costs
Compile and run from the directory holding the sources:
..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
javac -Xlint:all prints nothing and exits 0. Real output, from exactly this code:
== 1 - the same lot, the same stay, two tariffs ==
MOTORBIKE 3h01m flat 4000 tiered 2500
CAR 3h01m flat 8000 tiered 5000
TRUCK 3h01m flat 16000 tiered 10000
ParkingLot.java mentions neither tariff class. Entry.java names both.
== 2 - started hours: the unit both tariffs bill in ==
0 min -> 1 started hour(s)
1 min -> 1 started hour(s)
59 min -> 1 started hour(s)
60 min -> 1 started hour(s)
61 min -> 2 started hour(s)
180 min -> 3 started hour(s)
181 min -> 4 started hour(s)
== 3 - a barrier sign that re-derives the price instead of asking ==
quote() asks the injected policy 5000
a second call site with its own table 8000
Both compile. The tariff moved once and only one of them followed.
== 4 - Footprint is a fact, not a policy ==
4 standard spots: room for 2 trucks or 4 cars
one truck parked: room for 1 trucks or 2 cars
third truck: java.lang.IllegalStateException: no spot available for TRUCK
Two lines in there are the lesson. CAR 3h01m tiered 5000 is the worked example corpus/parking-lot/curveballs/02-tiered-pricing/REQUIREMENT-CHANGE.md states in its own words, so this code agrees with the corpus arithmetic rather than with itself. And 5000 against 8000 in block 3 is what a bypassed seam looks like when nothing has thrown.
PricingPolicy.java — the signature is the seam, and its width is two arguments
long feeMinor(VehicleType type, Duration stay);
This line is the bet. Any requirement that can be written as a function of a vehicle type and an elapsed duration is a new class and no edit anywhere. Tiered rates, weekend rates, an EV discount, a per-type cap: all of them are functions of these two arguments.
What the line does not carry is the whole of when-not.md. A rule that needs the entry instant, the lot's occupancy, or the customer's loyalty tier is not expressible here, and widening the signature costs every implementation and every call site. contrast/ measures that at 27 lines against 14 for the un-seamed version of the same design.
The corpus has the same finding at full scale, in corpus/trip-state-machine/curveballs/03-no-show-timeout/reference-patch/PATCH.md: "The table is a function of two values, and this rule is a function of three". Curveballs 01 and 02 on that problem cost 8 and 3, because they were functions of two. Number 03 cost 28.
static long startedHours(Duration stay) {
long seconds = Math.max(0L, stay.toSeconds());
return Math.max(1L, (seconds + 3599L) / 3600L);
}
A static on the interface is the one part a new implementation cannot fix by existing. Every tariff bills in started hours, so the rounding lives here once and two policies cannot disagree about what "three hours" means.
The price is stated rather than hidden. A tariff that bills by the minute cannot override this, because static interface methods are not inherited and not overridable. It would have to ignore the helper, and then the rounding rule exists in two places. That is the moment to move startedHours into a BillingUnit of its own — not before.
Math.max(1L, ...) is the "even nothing is one hour" rule, and it is load-bearing. Drop it and a vehicle that leaves in under a minute pays nothing at all. Block 2 shows the boundary: 60 minutes is one started hour, 61 is two.
Math.max(0L, seconds) is the negative guard. A caller that passes a negative duration gets one hour rather than a negative fee. Without it, (-3600 + 3599) / 3600 is 0, Math.max(1, 0) rescues it, and the bug only appears for durations under minus one hour. Guarding the input is cheaper than reasoning about that.
ParkingLot.java — the class that holds the sequence and no rule about money
public ParkingLot(int standardSpots, PricingPolicy pricing) {
One constructor, and it takes the tariff. There is no no-argument convenience constructor here, and the absence is deliberate. A default would be this class naming a concrete tariff class, and then a tariff change opens this file.
That is exactly the 2 in the corpus. corpus/parking-lot/reference/src/ParkingLot.java keeps ParkingLot(int standardSpots), which calls new FlatHourlyPricing(). When the tariff changed, one line of it changed, and corpus/parking-lot/curveballs/02-tiered-pricing/budget.json records reference_diff: 2 — one line added, one removed.
Read that as a fact about placement, not as a trick. Two lines is not a design defect, and diff_budget: 3 means the corpus reference passes comfortably. The reason to move the default out is that ParkingLot then cannot name a tariff even by accident. STANDARD v1.0's D4 level 3 is "absorbed by adding files only — zero lines changed in pre-existing files". The zero is a by-product of a decision that stands on its own.
this.pricing = Objects.requireNonNull(pricing, "pricing");
Fails at construction rather than at the barrier. Without it, a null policy is a NullPointerException inside unpark, at the end of somebody's stay, with a stack trace pointing at the lot rather than at whoever built it wrong.
occupied -= stayed.spots();
return pricing.feeMinor(stayed.type(), stay);
stayed.spots(), not Footprint.of(stayed.type()).spots(). The spots the vehicle took are recorded on the open stay, so freeing them cannot disagree with taking them. Recompute instead, and the day a footprint changes, vehicles parked before the change free the wrong number of spots and the lot leaks capacity.
The return line is the whole of open/closed in this file. It asks. It does not know the answer and it does not know the name of the class that does. Replace it with new FlatHourlyPricing().feeMinor(...) and everything still compiles, the base suite still passes, and the design is now closed to the one change it was built for. That defect has a name in the grader's vocabulary: seam-bypassed, and it routes back to this lesson.
public long quote(VehicleType type, Duration stay) {
return pricing.feeMinor(type, stay);
}
The second call site is where seams actually die. A price on a sign at the barrier is a different feature from a price on a receipt. It is also a plausible place to re-derive the number from a rate table of its own. Block 3 runs both: the injected policy says 5000, a re-derived flat table says 8000.
Nothing throws. Both numbers are legal fees, so the disagreement surfaces as a customer complaint. Then the next tariff change fixes one of them and not the other.
int needed = Footprint.of(type).spots();
if (occupied + needed > standardSpots) {
Asking Footprint keeps the room a vehicle takes in one place. Write int needed = 1 and a truck fits into a single spot. Block 4 would report room for 4 trucks instead of 2, and two trucks would leave a four-spot lot looking half empty.
The capacity rule stays here on purpose. Running out of room is the lot's own business, not a policy somebody revises by email. That is the same reasoning Footprint uses, applied to a different fact.
Footprint.java — the axis left un-seamed, and why that scores higher
public static Footprint of(VehicleType type) {
return switch (type) {
case MOTORBIKE -> MOTORBIKE;
case CAR -> CAR;
case TRUCK -> TRUCK;
};
}
A switch on a type, and it is the right answer here. STANDARD v1.0 puts "behaviour selected by if/switch on a type field, in more than one place" at D3 level 0, and the qualifier is the whole rule. This switch exists once, it returns a fact rather than selecting behaviour, and there is no second implementation of "how big is a truck" to name.
An exhaustive switch over an enum is a seam of a different kind. Add a fourth VehicleType and this file stops compiling, which is where somebody has to decide how much room the new thing takes. An interface here would accept the new type silently and return whatever the default did.
The compiler is the cheapest extension point you have, and it is free. That argument is made at scale in corpus/file-system/curveballs/01-shortcuts/reference-patch/PATCH.md. Adding a type to a permits clause broke exactly one switch there, and the patch calls the forced case "the A3 payoff, not a cost".
A FootprintPolicy interface with one implementation is the over-engineered tag. It routes to when-not.md, and it costs score rather than earning it.
TieredPricing.java — the entire absorption of a requirement, in one new file
for (long hour = FREE_HOURS + 1; hour <= hours; hour++) {
fee += rate(hour <= STANDARD_UNTIL_HOUR ? STANDARD : LONG_STAY, type);
}
Billed hour by hour so the boundaries in the code are the requirement's own sentences. The closed form is shorter and it hides where the tiers change. When finance moves the free hour to two, that is a constant here, not an algebraic rearrangement someone has to re-derive.
hour <= STANDARD_UNTIL_HOUR is the tier boundary, and getting it wrong is silent. Charge every hour at the standard rate and a car staying 3h01m pays 3000 instead of 5000. Both are plausible numbers on a receipt, which is why faded/GapTest.java asserts this exact case.
The file is free. measureChange charges nothing for a file that did not exist at injection time, because the cost being measured is being forced back into an old file. See the header of server/lib/diff.mjs.
Entry.java — where the wiring lives decides what the change is charged
public static ParkingLot create(int standardSpots) {
return new ParkingLot(standardSpots, new TieredPricing());
}
One file names tariffs, and this is it. Grep the design for FlatHourlyPricing or TieredPricing and every hit is in this file or in the class itself. That is a checkable property, and it is what makes "the tariff changed" a one-line edit in a known place.
Its counterpart in the corpus is corpus/parking-lot/contract/Entry.java, and contract files are not measured. The cheapest curveball in the whole corpus says so in as many words. corpus/lru-cache/curveballs/01-least-frequently-used/budget.json records reference_diff: 0 and explains the zero: the new policy is a new file, and the line that names it lives in contract-delta/Entry.java, "contract, not reference/src, so not measured at all".
Do not read that as a way to hide lines from the instrument. Read it as the reason lru-cache measures 0 and parking-lot measures 2 for changes of identical shape. Both seams are real. They differ in where the sentence "today we use this one" is written.
createFlat is kept rather than deleted. Last month's revenue is reported against last month's rates, and corpus/parking-lot/curveballs/02-tiered-pricing/reference-patch/PATCH.md makes the same call: "deleting it would be a bigger diff than keeping it."
The threshold
Open a seam when you can name the second implementation and write the one signature both would satisfy. Naming the dimension is not enough. "Pricing might change" is a feeling; `(VehicleType, Duration) -> long` is an axis.
Two corollaries, both measured in contrast/:
- If you cannot write the signature without an extra argument you do not yet need, you do not know the axis. Leave the code concrete and small. Widening
contrast/b/'s interface cost 27 lines across 3 files; the same change to the concrete version cost 14 in 1. - Do not seam a set the compiler already protects.
FootprintoverVehicleTypeis exhaustive, so a new type is a build failure at the one place a decision is owed.
When not to
When not to open a seam
The symmetry in STANDARD v1.0 is deliberate. D3 level 0 is "behaviour selected by if/switch on a type field, in more than one place". D3 level 3 is "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". The standard then says it outright: "level 3 penalises over-abstraction as much as level 0 penalises none", and the failure tag is over-engineered (premature interface).
So this file is not a caveat bolted onto the lesson. It is the other half of it.
The concrete bad example
Here is the version that looks like the lesson has been learned. Split into one file per public type and put next to worked/src/VehicleType.java, it compiles: javac 21 with -Xlint:all prints nothing and exits 0.
public interface PricingPolicy {
long feeMinor(VehicleType type, java.time.Duration stay);
}
public final class StandardPricing implements PricingPolicy {
@Override public long feeMinor(VehicleType type, java.time.Duration stay) {
return 2000L * Math.max(1L, (Math.max(0L, stay.toSeconds()) + 3599L) / 3600L);
}
}
public final class PricingPolicyFactory {
public static PricingPolicy forLot(String lotName) {
return new StandardPricing();
}
}
One implementation. No second one named anywhere in the requirements. A factory whose only branch is the absence of a branch, and a String parameter it ignores.
What a reviewer sees. Three files and one behaviour. forLot("terminal-2") and forLot("basement") return the same object, so the parameter is a promise the code does not keep. The next person adds a PricingPolicyRegistry so lots can be configured, and now there is a configuration format for a system with one rate.
What the grader sees. Behaviour is behind an interface, so D3 is at least level 2 — and level 3 is out of reach, because the seam set is not minimal. over-engineered routes to lessons/B1 at the when-not stage. This is a case where doing more scores less.
What the seam costs, measured
Three separate prices, and the middle one is the expensive one.
It costs code before any requirement arrives. From node lessons/C2/contrast/measure.mjs:
size of a 2 file(s) 33 normalised lines
size of b 4 file(s) 66 normalised lines
Read the shape, not the multiplier: both directories are commented heavily for teaching, so those counts include javadoc. The seam doubled the file count for behaviour that did not change.
It costs more than no seam when the change moves the other way. Also measured, in contrast/curveball.md:
| Design A, no seam | Design B, seam on (type, duration) | |
|---|---|---|
| tiered pricing — along the seam | 22 | 2 |
| overnight rate — across it | 14 | 27 |
The 27 is the number that matters here. A seam is a bet on a direction, and a losing bet is not neutral. Widening PricingPolicy.feeMinor to take the entry instant touched the interface, its implementation and its call site — three files, where the design with no seam at all touched one.
It costs the ability to change shared code later. PricingPolicy.startedHours is a static interface method, so a tariff that bills by the minute cannot override it. Every implementation inherits the rounding whether it suits them or not. That is a fine trade while every tariff bills in hours, and it is a rewrite the day one does not.
It also costs clock
An interface, an implementation and a factory is roughly fifteen minutes of a forty-five-minute round. STANDARD v1.0 caps D2 at 0 when no main exists, on the grounds that "interviewers run it first", and D2 is 25% of the score against D3's 20%. A seam nobody asked for, paid for with the driver, loses on both dimensions at once.
The threshold, from both sides
Open the seam when you can name the second implementation and write the one signature both would satisfy. Two implementations that exist, or one that exists plus one a requirement sentence names, is enough. corpus/parking-lot/reference/src/PricingPolicy.java passes this test on the strength of its own javadoc: "free hours, weekend rates, EV discounts" are three named tariffs, and the curveball directory then ships one of them.
Leave it concrete when either half is missing. Naming the dimension without the signature means you do not know the axis yet. The cheap move then is a small concrete class you are willing to rewrite: contrast/a/ParkingLot.java is 22 lines to change, and it is a known 22.
Never seam a set the compiler already protects. Footprint.of(VehicleType) in worked/src is an exhaustive switch, so a fourth vehicle type is a build failure at the one place a decision is owed. An interface there would accept the new type in silence and return whatever its default did.
What this file is not saying
It is not saying prefer the smaller diff. corpus/middleware-router/curveballs/02-method-aware-routing/reference-patch/PATCH.md records a draft that "measured smaller" and was rejected for coupling Router to one concrete MatchPolicy. That absorption cost 47 lines, the most expensive in the corpus, and the author chose the larger number knowingly.
The diff is a check on a claim about a seam. It is not the reason to have one.
The contrast pair
The measured pair: one seam, two requirement changes, four numbers
Two designs of the same lot. a/ puts the tariff in a switch inside ParkingLot. b/ puts it behind PricingPolicy. Both compile, and both pass BaseTest.java — the comparison is between two working designs, not between good code and code nobody would write.
Then two requirement changes arrive. The first travels along b/'s seam. The second does not.
Change one, in the interviewer's words
Taken from corpus/parking-lot/curveballs/02-tiered-pricing/REQUIREMENT-CHANGE.md:
Finance signed off on a new tariff this morning and it is live from now on. The first hour is free, for every vehicle. The second and third started hours are charged at the standard rate: motorbike 500, car 1000, truck 2000 per started hour. From the fourth started hour onward it is the long-stay rate: motorbike 1500, car 3000, truck 6000. Worked example, so we agree on the arithmetic: a car staying three hours and one minute has started four hours. Free, then 1000, then 1000, then 3000. It pays 5000.
CurveballTest.java asserts that 5000, and it passes against a-tiered/ and b-tiered/.
Change two, in the interviewer's words
Night shift changed. A vehicle that entered between 22:00 and 06:00 pays a flat 3000, whatever it is and however long it stayed. Daytime arrivals are unaffected.
OvernightTest.java asserts that, and it passes against a-overnight/ and b-overnight/. Note what it needs and PricingPolicy does not have: the entry time. Elapsed duration cannot answer "did this vehicle arrive at half past eleven at night".
The numbers
Run it yourself:
node lessons/C2/contrast/measure.mjs
Real output, from exactly these directories:
tiered pricing (on B's axis) a -> a-tiered diffLines 22 touched 1 new 0 [ParkingLot.java +19/-3]
tiered pricing (on B's axis) b -> b-tiered diffLines 2 touched 1 new 1 [ParkingLot.java +1/-1]
overnight rate (off B's axis) a -> a-overnight diffLines 14 touched 1 new 0 [ParkingLot.java +13/-1]
overnight rate (off B's axis) b -> b-overnight diffLines 27 touched 3 new 0 [FlatHourlyPricing.java +13/-1, ParkingLot.java +3/-2, PricingPolicy.java +6/-2]
size of a 2 file(s) 33 normalised lines
size of b 4 file(s) 66 normalised lines
| Change | Design A, no seam | Design B, seam on (type, duration) |
|---|---|---|
| tiered pricing — a new function of type and duration | 22 lines, 1 file | 2 lines, 1 file, 1 new file |
| overnight rate — needs a third input | 14 lines, 1 file | 27 lines, 3 files |
measureChange is the same function that scores D4 in a graded attempt, so these are the numbers the grader would produce. New files cost nothing; edits to files that already existed are charged, additions included. See the header of server/lib/diff.mjs for why.
What each number means
B's 2 is the corpus's own 2. corpus/parking-lot/curveballs/02-tiered-pricing/budget.json records reference_diff: 2 for this requirement against the real reference solution. Its note: "adds TieredPricing.java (a new file, free) and rewrites the one line of ParkingLot's convenience constructor that names the default tariff". One added, one removed. This contrast reproduces that number independently, on smaller code.
Those 2 lines are avoidable, and worked/ avoids them. Move the default out of ParkingLot and into an entry point and the tariff change touches nothing that was already there. corpus/lru-cache/curveballs/01-least-frequently-used/budget.json is the corpus's `reference_diff: 0` for exactly that reason. The new policy is a new file, and the line naming it lives in contract-delta/Entry.java, which the note calls "contract, not reference/src, so not measured at all".
A's 22 is not incompetence. a/ParkingLot.java is shorter than b/, passes the same base suite, and has an exhaustive switch so a new vehicle type is a compile error. It is what gets written with twelve minutes left, and the 22 is the interest payment, not the principal.
B's 27 against A's 14 is the finding this lesson exists for. The overnight rule needs the entry instant, which PricingPolicy.feeMinor(VehicleType, Duration) does not carry. Widening the signature costs the interface, every implementation of it, and every call site — three files instead of one. The seam did not merely fail to help. It roughly doubled the price of a change that moved the other way.
The alternatives, so the choice is a choice
Could b/ have absorbed the overnight rule by adding a class? Only if the entry time were already in the signature. A decorator implementing PricingPolicy and reading a clock does not work: the rule depends on when the vehicle arrived, and only the lot knows that. Reading "now" at exit time gives a different answer for a car that came in at 23:00 and left at 09:00.
Could the signature take a Stay value object instead, so the next new input is free? Yes, and it is the trap when-not.md is about. That widens the seam for inputs nobody has asked for, which is speculative generality with a record in front of it. It was not measured here, because it is a redesign of the seam rather than an absorption of the change, and a number for it would compare two different questions.
Is the smallest diff always the right answer? No, and the corpus has the counter-example. corpus/middleware-router/curveballs/02-method-aware-routing/reference-patch/PATCH.md records an earlier draft that "measured smaller" and was rejected, because it made Router reach past MatchPolicy into one concrete implementation. That absorption cost 47 lines, the most expensive in the corpus, and the author took the larger number on purpose. Use the diff to check a claim about a seam. Do not use it to choose a design.
What this pair does not show
The overnight change is 27 against 14 on 66 normalised lines of code. In a real problem the numbers are larger. corpus/trip-state-machine/curveballs/03-no-show-timeout is the full-scale version: 28 lines, 19 of them in one class. That rule depends on a third input too, which is why reference-patch/PATCH.md says no table of rows absorbs it at any price. Same shape, at the size of a real problem.
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/Entry.java33 linesworked/src/FlatHourlyPricing.java24 linesworked/src/Footprint.java34 linesworked/src/ParkingLot.java77 linesworked/src/PricingPolicy.java34 linesworked/src/TieredPricing.java48 linesworked/src/VehicleType.java13 linesworked/src/Main.java68 lines
worked/src/Entry.java33 lines
/**
* The composition root: the only file in this design that names a tariff class.
*
* Its counterpart in the corpus is corpus/parking-lot/contract/Entry.java, and where that file
* lives decides what a new tariff costs. Contract files are given to the candidate and excluded
* from the diff — the budget note for the cheapest curveball in the corpus says so in as many
* words: "contract, not reference/src, so not measured at all"
* (corpus/lru-cache/curveballs/01-least-frequently-used/budget.json).
*
* Do not read that as a way to hide lines from the instrument. The reason to keep concrete
* tariff names out of ParkingLot is that ParkingLot then cannot bypass the seam even by
* accident, and STANDARD v1.0's D4 level 3 is "absorbed by adding files only". The zero is a
* by-product of a decision that stands on its own.
*/
public final class Entry {
private Entry() {
}
/** The lot the grader builds: today's tariff, chosen in one place. */
public static ParkingLot create(int standardSpots) {
return new ParkingLot(standardSpots, new TieredPricing());
}
/**
* Last month's tariff, kept because last month's revenue is still a fact somebody reports
* on. Deleting FlatHourlyPricing would have been a larger diff than keeping it, which is the
* same call corpus/parking-lot/curveballs/02-tiered-pricing/reference-patch/PATCH.md makes.
*/
public static ParkingLot createFlat(int standardSpots) {
return new ParkingLot(standardSpots, new FlatHourlyPricing());
}
}
worked/src/FlatHourlyPricing.java24 lines
import java.time.Duration;
import java.util.Map;
/** Last month'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/Footprint.java34 lines
/**
* How many standard spots a kind of vehicle physically occupies.
*
* This is the axis deliberately left un-seamed. A truck taking two spots is a fact about
* trucks, not a policy somebody can revise, so there is no second implementation to name and
* an interface here would be the over-engineered tag with extra steps.
*
* The switch in of() is exhaustive on purpose. A fourth VehicleType stops compiling here, and
* here is where somebody has to decide how much room the new thing takes.
*/
public enum Footprint {
MOTORBIKE(1),
CAR(1),
TRUCK(2);
private final int spots;
Footprint(int spots) {
this.spots = spots;
}
public int spots() {
return spots;
}
public static Footprint of(VehicleType type) {
return switch (type) {
case MOTORBIKE -> MOTORBIKE;
case CAR -> CAR;
case TRUCK -> TRUCK;
};
}
}
worked/src/ParkingLot.java77 lines
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Coordinates the lot: it owns the open stays and nothing else.
*
* Read this class looking for a rule a requirement could reach, and there is one — the capacity
* check — and it is here because running out of room is the lot's own business. What a stay costs
* is not here at all, and neither is the name of any tariff class. That second absence is the
* one this lesson is about: no method in this file mentions FlatHourlyPricing or TieredPricing,
* so no tariff change can force this file open.
*
* Compare corpus/parking-lot/reference/src/ParkingLot.java, which keeps a convenience
* constructor naming the default tariff. That single line is the whole of the 2 in
* corpus/parking-lot/curveballs/02-tiered-pricing/budget.json.
*/
public final class ParkingLot {
/** One open stay: what parked, and how many standard spots it took while it was here. */
private record Open(VehicleType type, int spots) {}
private final int standardSpots;
private final PricingPolicy pricing;
private final Map<String, Open> open = new LinkedHashMap<>();
private int occupied;
private long issued;
/**
* The only constructor, and it takes the tariff. There is no no-argument convenience
* constructor on purpose: a default here would be this class naming a concrete tariff.
*/
public ParkingLot(int standardSpots, PricingPolicy pricing) {
if (standardSpots <= 0) {
throw new IllegalArgumentException("a lot needs at least one spot, got " + standardSpots);
}
this.standardSpots = standardSpots;
this.pricing = Objects.requireNonNull(pricing, "pricing");
}
public String park(VehicleType type) {
int needed = Footprint.of(type).spots();
if (occupied + needed > standardSpots) {
throw new IllegalStateException("no spot available for " + type);
}
occupied += needed;
String ticketId = "T" + (++issued);
open.put(ticketId, new Open(type, needed));
return ticketId;
}
/** @return the fee in minor units for a stay of this length. */
public long unpark(String ticketId, Duration stay) {
Open stayed = open.remove(ticketId);
if (stayed == null) {
throw new IllegalArgumentException("unknown or already-used ticket: " + ticketId);
}
occupied -= stayed.spots();
return pricing.feeMinor(stayed.type(), stay);
}
/**
* What a stay would cost, for a sign at the barrier, without parking anything.
*
* The second call site is where a seam gets bypassed. Re-deriving the number here, from a
* rate table of its own, is the seam-bypassed defect tag: the barrier would quote one price
* and the receipt would charge another, and only one of them changes when the tariff does.
*/
public long quote(VehicleType type, Duration stay) {
return pricing.feeMinor(type, stay);
}
public int availableSpots(VehicleType type) {
return (standardSpots - occupied) / Footprint.of(type).spots();
}
}
worked/src/PricingPolicy.java34 lines
import java.time.Duration;
/**
* What a stay costs.
*
* The two arguments are the bet. Any requirement that is a function of (vehicle type, elapsed
* time) is a new class implementing this and no edit to any existing file. Any requirement that
* needs a third input is not, and no amount of care here makes it cheap — see
* corpus/trip-state-machine/curveballs/03-no-show-timeout/reference-patch/PATCH.md, where a rule
* that depended on a third input measured 28 lines against a table that absorbed the two-input
* rules for 8 and 3.
*/
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 as a static because every tariff in this problem bills in the same unit. What
* varies between tariffs is the rate, not the clock. Note the price of that decision: a
* later tariff billing by the minute cannot fix this by adding a class, because this method
* is not overridable. Shared code on an interface is the one thing a new implementation
* inherits whether it wants to or not.
*/
static long startedHours(Duration stay) {
long seconds = Math.max(0L, stay.toSeconds());
return Math.max(1L, (seconds + 3599L) / 3600L);
}
}
worked/src/TieredPricing.java48 lines
import java.time.Duration;
import java.util.Map;
/**
* This morning's tariff, from corpus/parking-lot/curveballs/02-tiered-pricing: the first started
* hour free, the next two at the standard rate, everything from the fourth onward at the
* long-stay rate.
*
* The file you are reading is the entire absorption of that requirement. It is a new file, and
* measureChange charges nothing for new files, which is why the corpus records
* reference_diff = 2 rather than 20 — and the 2 is the one line elsewhere that names this class.
*
* Billed hour by hour rather than by an algebraic rearrangement, so the boundaries in the code
* are the requirement's own sentences and a rate change stays a one-number edit.
*/
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 hours = PricingPolicy.startedHours(stay);
long fee = 0;
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/VehicleType.java13 lines
/**
* The kinds of vehicle this lot admits.
*
* A closed set, and the corpus says so: corpus/parking-lot/contract/VehicleType.java is
* read-only and names exactly these three. That closure is the reason Footprint is allowed to
* be a switch and PricingPolicy is not. The set of vehicle types does not move on its own; the
* price of parking one moves whenever finance sends an email.
*/
public enum VehicleType {
MOTORBIKE,
CAR,
TRUCK
}
worked/src/Main.java68 lines
import java.time.Duration;
/** Runs the design and prints the numbers NOTES.md quotes. Nothing here is a test. */
public final class Main {
private static final Duration THREE_HOURS_ONE_MINUTE = Duration.ofMinutes(181);
public static void main(String[] args) {
sameLotTwoTariffs();
theHourBoundary();
whatBypassingTheSeamLooksLike();
theAxisThatIsNotSeamed();
}
/** 1 — one line in Entry decides the tariff, and nothing in ParkingLot knows which won. */
private static void sameLotTwoTariffs() {
System.out.println("== 1 - the same lot, the same stay, two tariffs ==");
for (VehicleType type : VehicleType.values()) {
ParkingLot flat = Entry.createFlat(8);
ParkingLot tiered = Entry.create(8);
long flatFee = flat.unpark(flat.park(type), THREE_HOURS_ONE_MINUTE);
long tieredFee = tiered.unpark(tiered.park(type), THREE_HOURS_ONE_MINUTE);
System.out.printf(" %-10s 3h01m flat %5d tiered %5d%n", type, flatFee, tieredFee);
}
System.out.println(" ParkingLot.java mentions neither tariff class. Entry.java names both.");
}
/** 2 — the shared static every tariff inherits, at its boundaries. */
private static void theHourBoundary() {
System.out.println();
System.out.println("== 2 - started hours: the unit both tariffs bill in ==");
long[] minutes = {0, 1, 59, 60, 61, 180, 181};
for (long m : minutes) {
System.out.printf(" %3d min -> %d started hour(s)%n",
m, PricingPolicy.startedHours(Duration.ofMinutes(m)));
}
}
/** 3 — the second call site that re-derives a price, and the two numbers that disagree. */
private static void whatBypassingTheSeamLooksLike() {
System.out.println();
System.out.println("== 3 - a barrier sign that re-derives the price instead of asking ==");
ParkingLot lot = Entry.create(8);
long asked = lot.quote(VehicleType.CAR, THREE_HOURS_ONE_MINUTE);
long rederived = new FlatHourlyPricing().feeMinor(VehicleType.CAR, THREE_HOURS_ONE_MINUTE);
System.out.printf(" quote() asks the injected policy %d%n", asked);
System.out.printf(" a second call site with its own table %d%n", rederived);
System.out.println(" Both compile. The tariff moved once and only one of them followed.");
}
/** 4 — the axis left as a switch, and the reason it is safe to leave it there. */
private static void theAxisThatIsNotSeamed() {
System.out.println();
System.out.println("== 4 - Footprint is a fact, not a policy ==");
ParkingLot lot = Entry.create(4);
System.out.printf(" 4 standard spots: room for %d trucks or %d cars%n",
lot.availableSpots(VehicleType.TRUCK), lot.availableSpots(VehicleType.CAR));
lot.park(VehicleType.TRUCK);
System.out.printf(" one truck parked: room for %d trucks or %d cars%n",
lot.availableSpots(VehicleType.TRUCK), lot.availableSpots(VehicleType.CAR));
try {
lot.park(VehicleType.TRUCK);
lot.park(VehicleType.TRUCK);
} catch (IllegalStateException e) {
System.out.printf(" third truck: %s: %s%n", e.getClass().getName(), e.getMessage());
}
}
}
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.