Syllabus · B6
Explicit state machine with a transition table as data
The idea
A table holds every rule that is a function of (state, event)
A trip goes REQUESTED, MATCHED, DRIVER_ARRIVED, IN_PROGRESS, COMPLETED. A switch (state) inside handle says that, and it is what most people write. Whether it works is not the question. What happens when the rules move is, because the rules always move.
corpus/trip-state-machine puts them in a list of Edge values instead, each one a (state, event kind) -> state row. Three real requirement changes were then measured against it. A shortcut edge, so a driver who forgot to tap arrive can start anyway: reference_diff: 3, and one of the three lines is a comma. A whole new event, a driver cancelling, across four files: 8. Then a no-show timeout: 28.
The third is a different kind of requirement. Whether a wait has expired depends on the state, the event, and how long the trip has sat at the kerb. Edge carries two inputs, so no number of rows expresses it. PATCH.md says it in one line: "The table is a function of two values, and this rule is a function of three."
That decision comes before the shape. Write the requirement as a signature and count its inputs. Two, and it is a row. Three, and it belongs in a guard, asked after the table and before the log append.
contrast/ measures both designs on three changes. The table wins the one where the rules have to differ per market, at 4 lines against 10, and loses the other two on line count. Run node lessons/B6/contrast/measure.mjs.
Worked walkthrough
NOTES — twelve files, and the four lines that decide what a new rule 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 rules are a list of values ==
6 states x 6 event kinds = 36 cells, 8 filled, 28 refused by silence
REQUESTED Matched -> MATCHED
MATCHED DriverArrived -> DRIVER_ARRIVED
DRIVER_ARRIVED TripStarted -> IN_PROGRESS
IN_PROGRESS TripCompleted -> COMPLETED
REQUESTED RiderCancelled -> CANCELLED
MATCHED RiderCancelled -> CANCELLED
DRIVER_ARRIVED RiderCancelled -> CANCELLED
DRIVER_ARRIVED WaitExpired -> CANCELLED
== 2 - the log is the trip; the state is its last row ==
09:00:00 REQUESTED -> MATCHED driver d-7 assigned
09:03:00 MATCHED -> DRIVER_ARRIVED driver at the kerb
09:06:00 DRIVER_ARRIVED -> IN_PROGRESS rider in, moving
09:09:00 IN_PROGRESS -> COMPLETED dropped off
state() = COMPLETED, read off the end of the log
== 3 - three requirements enforced by rows that do not exist ==
no self-loop: IllegalTransition: a trip in MATCHED cannot handle Matched
no cancel row: IllegalTransition: a trip in IN_PROGRESS cannot handle RiderCancelled
terminal, not table: Finished: the trip is already CANCELLED; Matched can never be accepted
Neither an if nor a switch was written for any of the three.
== 4 - the same code, two rule sets ==
8 rows: a driver who forgot to tap arrive starts anyway: refused
9 rows: a driver who forgot to tap arrive starts anyway: accepted -> IN_PROGRESS
The difference is one Edge in a list. No method was opened.
== 5 - the rule the table cannot hold ==
row alone, 1s after arrival: CANCELLED <- wrong, and the table agreed
row plus guard, 299s after arrival: refused: a trip in DRIVER_ARRIVED cannot handle WaitExpired
row plus guard, 300s after arrival: accepted -> CANCELLED
clock reads: 1 after one accepted event, 1 after a WaitExpired the table refused
Time is only consulted for events the table already permitted.
Two lines there are the whole lesson. Block 4 shows a rule change costing one list element. Block 5 shows a rule the same list cannot express at any length, accepting a no-show one second after the driver pulled up.
TripRules.java — the requirement, transcribed, and nothing else
new Edge(TripState.MATCHED, TripEvent.RiderCancelled.class, TripState.CANCELLED),
new Edge(TripState.DRIVER_ARRIVED, TripEvent.RiderCancelled.class, TripState.CANCELLED)
These two rows, and the third one above them, are the entire cancellation policy. There are three RiderCancelled rows and no fourth. "A trip cannot be cancelled once it is under way" is not a condition anywhere in this design. It is the row that is absent.
The same trick does two more jobs. No row is a self-loop, so a duplicate Matched is refused with no de-duplication code. COMPLETED and CANCELLED appear only as destinations, so a terminal state needs no marker to be dead.
Block 1 counts the consequence: 36 cells, 8 filled. The 28 empty cells are 28 refusals nobody wrote, and each one is a line of code that cannot have a bug in it.
public static List<Edge> standard() {
return List.of(
The closing bracket sits on its own line so that appending a rule adds rows instead of restructuring the call. Not quite free, and the honest version is worth stating: Java forbids a trailing comma in an argument list, so an append also adds a comma to the row above. A line diff charges that as a removal and a re-addition. One appended rule therefore measures 3 lines, not 2.
That is not a rounding error in this repo. It is exactly the number corpus/trip-state-machine/curveballs/02-start-without-the-tap/budget.json records for a real requirement change: reference_diff: 3, and its note says "one of them is a comma".
TransitionTable.java — the mechanism, which will not change again
TripState to = edges.getOrDefault(from, Map.of()).get(event.getClass());
if (to == null) {
throw new TripException.IllegalTransition(from, event);
}
getOrDefault rather than get is what makes an unreachable state safe. A state with no outgoing rows at all has no entry in the outer map. get(from).get(...) would throw NullPointerException there, and a caller catching TripException would not catch it. The refusal would look like a crash instead of a rule.
This method is pure, and the whole ordering in Trip depends on it. It looks a value up and changes nothing. So a caller can find out whether an event is legal before touching the log, the clock, or anything outside. That is why Trip has no rollback path.
if (clash != null) {
throw new IllegalArgumentException("two targets for " + rule.from() + " + "
+ rule.on().getSimpleName() + "; the table must be a function");
}
This line says the table is a function, and enforces it at construction. Without it, two rows for one pair mean the last one silently wins. The bug that produces is a machine that transitions correctly until somebody reorders the list in TripRules, and then a trip goes somewhere else with no code change anywhere.
built.replaceAll((state, row) -> Map.copyOf(row));
this.edges = Map.copyOf(built);
A trip's rules cannot change while it is running. Both copies are needed: Map.copyOf on the outer map alone would still share the inner rows with whoever built them.
Edge.java — the signature that decides everything else
public record Edge(TripState from, Class<? extends TripEvent> on, TripState to) {
Read this as a function type: (TripState, event kind) -> TripState. That signature is the bet. Any requirement expressible as a function of those two inputs is a row and no edit anywhere. Any requirement that needs a third input is not a row, however the table is indexed.
The Class key is not a trick. TripEvent is sealed rather than an enum, because a Matched has to carry which driver, so there is no constant to key on. What identifies the kind of event is its type.
The cost, stated once and paid in the corpus: a Class key is not exhaustiveness-checked. Nothing forces a new event to have a row. corpus/trip-state-machine/curveballs/01-driver-cancels/reference-patch/PATCH.md verified this against the real reference — three files stopped compiling and TripRules was not one of them. A sixth event with no row compiles and is refused at runtime. That is a safe default, and it is the honest limit of "make it a compile error".
Trip.java — four lines, in this order, and the order is the design
TripState to = table.next(from, event);
Instant now = clock.instant();
guard.check(from, event, history(), now);
log.add(new Transition(from, to, event, now));
Line 1 asks the table, and it is first because it is free. No clock, no log write, nothing outside.
Line 2 reads the clock exactly once, and it sits below line 1 on purpose. Move it to the top of the method and every refusal costs a clock read. Block 5 measures that: a WaitExpired refused by the table leaves the read count at 1. With the read at the top it would be 2, and a later transition's timestamp would be a minute out. The corpus asserts this directly, in corpus/trip-state-machine/curveballs/03-no-show-timeout — the test is called refusedByTheTableCostsNoClockRead.
Line 3 is where the third input lives, and it comes after the table for a reason. The guard is handed the pair the table has already accepted, so a guard never has to ask whether a transition exists. It answers a narrower question: not yet, or now.
Line 4 is the commit, and it is one append. Every refusal above it leaves the trip untouched, so there is no rollback path in this class. There is nothing to roll back.
public TripState state() {
return log.isEmpty() ? TripState.REQUESTED : log.get(log.size() - 1).to();
}
No state field. The state is read off the end of the log, so no line here keeps two representations of one fact in step, and no line can fail to. It also pays for itself in block 5: "when did the driver arrive" is already recorded, so the no-show rule needs no new field.
WaitTimeout.java — the rule that is not a row
Instant waitingSince = log.get(log.size() - 1).at();
if (Duration.between(waitingSince, now).compareTo(MINIMUM_WAIT) < 0) {
Line 1 reads the last entry rather than searching for the last DriverArrived. The trip's state is the last entry's to, and the table gives WaitExpired a cell only from DRIVER_ARRIVED. So the last entry is necessarily the transition that put the driver at the kerb, whichever event did it. Written this way it needs no change if a second row into DRIVER_ARRIVED is ever added. It also cannot pick up the timestamp of an arrival the trip has since left and come back to.
Line 2 is < 0 and not <= 0, because the requirement says "at least five minutes". Both readings compile. Block 5 pins the boundary: 299 seconds is refused, 300 accepted.
The refusal is IllegalTransition, not Finished. The trip is live, and the very same event will be accepted a few minutes later once time has passed. A caller deciding whether to retry needs that distinction, which is the reason there are two exception types at all.
The rule to take away
Before choosing between a table and a switch, write the requirement as a function signature and count its inputs.
(state, event) -> state. It is a row. Cancellation from three states, a shortcut edge, a backward edge into a state the trip has already been in, a whole new terminal state: all of them are rows. Measured in the corpus at 3 and 8 lines.- Anything else. It is not a row at any price. A five-minute wait depends on the state, the event and how long the trip has sat there, which the table's signature never carried. Measured at 28.
The third input has a tell in the requirement's own words. "At least five minutes", "only during happy hour", "if the purse has enough change", "no more than three times" — each of those names something the pair (state, event) does not contain. corpus/vending-machine splits along the same line. Its Transitions.java is an enum-keyed table of 7 rows, and the price check is not one of them. The comment on the IDLE + SELECT row says the machine has no objection to selecting with no money in. Then: "the price objects, later, and that is a different concern in a different class."
What the compiler catches when a seventh event arrives
Adding a variant to TripEvent and compiling without touching anything else. Real javac output:
Main.java:166: error: the switch expression does not cover all possible input values
return switch (event) {
^
1 error
That is Main.narrate, the exhaustive switch with no default. The compiler names the file and the line before a test runs.
What it does not catch is the missing row in TripRules, because Edge keys on a Class. The new event compiles, and every trip refuses it. Push on this point if somebody tells you a table makes new events a compile error — half of it is true.
The one place this design's own documentation drifted
corpus/trip-state-machine/reference/src/TripRules.java claims in its javadoc that a driver-side cancellation, a re-match, a no-show timeout and a scheduled trip "are rows here and nothing else." Curveball 03 is the counter-example to its own example. A no-show timeout is a row and a guard: the row makes the pair legal in principle, and the guard decides whether it is legal yet.
The sentence is still there, and 03-no-show-timeout/reference-patch/PATCH.md explains why. Both earlier curveballs ship a copy of that file, so correcting the base would re-measure two verified budgets. The note calls the fix more expensive in trust than the sentence is.
Worth sitting with, because it is the normal case rather than an oddity. The javadoc was written by somebody who had thought hard about the design, and it was true of every requirement they had seen. The design was fine. The sentence about the design was what aged.
When not to
When not to make the table data
Start with what STANDARD v1.0 actually penalises at D3 level 0: "Behaviour selected by if/switch on a type field, in more than one place". Read the last four words. One switch (state) inside one method is not the defect the standard names. It becomes one when a second method has to agree with it.
Level 3 is the other end: "The seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". The standard then says the symmetry out loud, that level 3 "penalises over-abstraction as much as level 0 penalises none", and names the failure tag over-engineered (premature interface). That tag routes to lessons/B1 at its when-not stage.
So this file is not a caveat bolted onto the lesson. Three files of table machinery for a machine that has one rule is the level 3 failure, exactly.
The concrete bad example
A document is a draft until somebody publishes it. That is the whole requirement, and there is no second one coming. Here it is behind the apparatus this lesson teaches. Nested in one file so it can be compiled as it stands; javac 21 -Xlint:all prints nothing and exits 0.
public final class DocFlow {
public enum DocState { DRAFT, PUBLISHED }
public sealed interface DocEvent {
record Publish() implements DocEvent { }
}
public record DocEdge(DocState from, Class<? extends DocEvent> on, DocState to) { }
public static final class DocRules {
public static List<DocEdge> standard() {
return List.of(new DocEdge(DocState.DRAFT, DocEvent.Publish.class, DocState.PUBLISHED));
}
private DocRules() { }
}
public static final class DocTransitionTable {
private final Map<DocState, Map<Class<? extends DocEvent>, DocState>> edges;
public DocTransitionTable(Collection<DocEdge> rules) { /* index them */ }
public DocState next(DocState from, DocEvent event) { /* look one up, or throw */ }
}
}
Two states, one event, one row, four types.
What a reviewer sees. A Map of Maps to hold a single fact. DocRules.standard() has no second rule set and no second caller, so the indirection buys nothing that if (state == DRAFT) state = PUBLISHED; did not already have. The next person adds a DocRulesProvider so the flow can be configured, and now there is a configuration format for a machine with one rule.
The version worth writing instead. A field, an enum, and a method that refuses when the document is already published. Then a third state arrives, say review or scheduled publication. The table is a twenty-minute change from there, and it will be shaped by three real requirements rather than by a guess about one.
The other overuse: a table with the types taken out
The second way this goes wrong looks more sophisticated and is worse.
Map<String, String> transitions = Map.of("DRIVER_ARRIVED:trip_started", "IN_PROGRESS");
"Rules as data" reads as an invitation to make them strings, loadable from a config file, editable without a deploy. What it costs is every check the compiler was doing. "DRIVER_ARRVIED:trip_started" compiles, and so does a target state that no longer exists. The defect tag for this is stringly-typed, from rubric.mjs's closed vocabulary, and it routes to lessons/A1.
Edge keeps the enum and the Class for exactly this reason. Rules being data does not mean rules being text.
What the table costs, measured
Three prices, and the middle one is the expensive one.
It costs code before any requirement arrives. From node lessons/B6/contrast/measure.mjs:
size of a 4 file(s) 100 normalised lines
size of b 7 file(s) 140 normalised lines
Both directories are commented for teaching, so those counts include javadoc. Read the shape: three more files, for behaviour that is identical.
It costs more than no table when the change needs a third input. Also measured, in contrast/curveball.md:
| Change | A, rules in control flow | B, rules as data |
|---|---|---|
one (state, event) pair becomes legal | 1 | 3 |
| the rules vary by market | 10 | 4 |
| a rule that depends on how long the trip has waited | 10 | 15 |
The 15 against 10 is the number that matters here, and the 3 against 1 is the one that keeps you honest. A table is a bet that the rules will move along (state, event). A losing bet is not neutral.
It costs the compiler's help on new events. Edge keys on Class<? extends TripEvent>, which is not exhaustiveness-checked, so nothing forces a new event to have a row. Verified in the corpus: corpus/trip-state-machine/curveballs/01-driver-cancels/reference-patch/PATCH.md records that adding a sixth event broke three files by name, and TripRules was not one of them. The missing rows were caught by the suite. An exhaustive switch (state) over the enum, by contrast, stops compiling when a state is added.
It also costs clock
Edge, TransitionTable and TripRules are about fifteen minutes of a forty-five-minute round, and the interviewer sees no behaviour for the first ten of them. 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%. Machinery nobody asked for, paid for with the driver, loses on both dimensions at once.
The axis this lesson deliberately leaves alone
Two rules in worked/ are not rows, and neither should be.
The five-minute wait. It lives in WaitTimeout, asked after the table and before the log append. Putting it on Edge as a fourth component would make every other row grow a no-op guard, and it would hand TransitionTable.next a clock. contrast/curveball.md prices that trade at 15 lines against 10. It is still the right one: the second time-dependent rule is then a new implementation and no edit.
"A finished trip is finished". That is TripState.terminal(), a boolean on the enum, checked before the table. It could have been expressed as the absence of outgoing rows, which is what it also is. It stays explicit because callers need to tell "not yet" from "never again", and a missing row cannot say which of the two it means.
And one from the second table in the corpus. corpus/vending-machine/reference/src/Transitions.java allows IDLE + SELECT, with no money in the machine, and its own comment explains why: "the price objects, later, and that is a different concern in a different class." Money is a third input. The table stays a table.
The threshold, from both sides
Make the rules data when the machine has at least three states and more than one entry point, or when a requirement already names a second rule set. Two of those hold for corpus/trip-state-machine: six states, and market rules the moment it ships in a second city. corpus/vending-machine has four entry points. Its TransitionTable javadoc says the switch version would spread the rules across four methods. Then "each method grows a guard clause that has to be kept consistent with three others by hand".
Leave it as control flow when the machine is a straight line with one entry point and no second rule set in sight. contrast/a/ is 4 files and passes the same seven tests in BaseTest.java. When the table becomes right, converting is mechanical and the rows are already written down in the switch.
Never put a rule in the table that is not a function of (state, event). Not because it is expensive, but because it does not fit. A WaitExpired row says the pair is legal in principle. Only the guard knows whether it is legal yet, and a design that pretends otherwise cancels trips one second after the driver pulls up. worked/ prints that, in block 5.
The contrast pair
The measured pair: one machine, three requirement changes, six numbers
Two designs of the same trip machine. a/ puts the rules in the control flow of Trip.handle, as a switch (state) with a pattern switch over the event inside each arm. b/ puts the rules in a list of Edge values and looks them up. Both compile with -Xlint:all clean, and both pass all seven tests in BaseTest.java, before and after every change here.
a/ is not a straw man. It is 4 files against b/'s 7, it refuses illegal events with the right exception, and a duplicate case label in one of its arms is a compile error. Under a forty-five-minute clock it is what a competent engineer writes.
Then three requirements arrive. The first is cheap for both. The second travels along b/'s axis. The third does not.
Change one, in the interviewer's words
From corpus/trip-state-machine/curveballs/02-start-without-the-tap/REQUIREMENT-CHANGE.md:
Drivers forget to tap "I've arrived". It happens constantly — they pull up, the rider is already standing there, they both get on with it, and nobody touches the phone until the trip is over. So: a trip may be started directly from
MATCHED.
KerbSkipTest.java asserts it, and passes against a-kerbskip/ and b-kerbskip/.
Change two, in the interviewer's words
We are live in two more cities and each regulator wants something different. In London a rider cannot cancel once the driver is at the kerb; they call support instead. In São Paulo drivers never tap arrive, so a trip has to be startable from
MATCHEDthere. One codebase, and a trip is built knowing its market.
MarketsTest.java asserts it, and passes against a-markets/ and b-markets/.
Change three, in the interviewer's words
From corpus/trip-state-machine/curveballs/03-no-show-timeout/REQUIREMENT-CHANGE.md:
A new event,
WaitExpired, raised by a timer when the driver's wait runs out. It takes the trip fromDRIVER_ARRIVEDtoCANCELLED. The new part: it is only legal once the driver has actually been waiting for at least five minutes. Measure from the timestamp of the transition that put the trip intoDRIVER_ARRIVED, to now, and take now from the injected clock.
NoShowTest.java asserts it, and passes against a-noshow/ and b-noshow/.
The numbers
Run it yourself:
node lessons/B6/contrast/measure.mjs
Real output, from exactly these directories:
one pair becomes legal (cheap for both) a -> a-kerbskip diffLines 1 touched 1 new 0 [Trip.java +1/-0]
one pair becomes legal (cheap for both) b -> b-kerbskip diffLines 3 touched 1 new 0 [TripRules.java +2/-1]
rules vary by market (on B's axis) a -> a-markets diffLines 10 touched 1 new 1 [Trip.java +9/-1]
rules vary by market (on B's axis) b -> b-markets diffLines 4 touched 1 new 2 [Trip.java +4/-0]
no-show timeout (off B's axis) a -> a-noshow diffLines 10 touched 2 new 0 [Trip.java +8/-0, TripEvent.java +2/-0]
no-show timeout (off B's axis) b -> b-noshow diffLines 15 touched 3 new 2 [Trip.java +7/-3, TripEvent.java +2/-0, TripRules.java +2/-1]
size of a 4 file(s) 100 normalised lines
size of b 7 file(s) 140 normalised lines
| Change | A, rules in control flow | B, rules as data |
|---|---|---|
one (state, event) pair becomes legal | 1 line | 3 lines |
| the rules themselves vary by market | 10 lines, and every one inside handle | 4 lines, all in a constructor |
| a rule that depends on a third input | 10 lines | 15 lines, plus two new 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.
What each number means
B's 3 is the corpus's own 3. corpus/trip-state-machine/curveballs/02-start-without-the-tap records reference_diff: 3 for this requirement against the real reference solution, and its note says "one of them is a comma". This contrast reproduces that number independently, on smaller code. One appended Edge, plus the comma the row above it now needs, charged as a removal and a re-addition because Java forbids a trailing comma in an argument list.
A's 1 beats it, and pretending otherwise would be the easiest way to lose an interviewer. One case label in one arm. If your argument for a table is "fewer lines when a rule changes", change one is where somebody who has written both will stop believing you. The table's 3 is two lines of punctuation, and the argument has to be about something else.
Change two is what the something else is. A's rules are code, so "which rules" cannot be a parameter. Making it one means a Market field, a second constructor, and a conditional inside two arms of the switch. That is 9 added lines and 1 rewritten, all inside the method that decides legality. B's rules are a value, so a second rule set is a new file, and the only edit is a constructor that names it. MarketRules.java is free because it is new, and it is new because rules that are data can live somewhere that did not exist yesterday. A has no equivalent move available at any price.
Change three is the one this lesson exists for, and B loses it by 5 lines. The five-minute rule is a function of the state, the event, and how long the trip has sat at the kerb. Edge is (TripState, event kind) -> TripState, so the table cannot express it however it is indexed. B pays for a guard collaborator: a field, a widened constructor, a rewritten happy path, one row for the pair, and the new event. A puts an if inside the arm that already handles DRIVER_ARRIVED, and it is cheaper.
The corpus records the full-scale version of that same defeat. corpus/trip-state-machine/curveballs/03-no-show-timeout/budget.json measures 28 for this requirement against a reference whose other two curveballs cost 8 and 3. Nineteen of the 28 are in one class, Trip. Nine of those nineteen are javadoc that had to be corrected. The class had claimed that a refusal happens "before the clock has been read", and after this change that is false.
The alternatives, so the choice is a choice
Could b/ have absorbed the five-minute rule as a row? No, at any price. That is the whole point, and 03-no-show-timeout/reference-patch/PATCH.md states the reason in one line: "The table is a function of two values, and this rule is a function of three."
Could the rule have gone on Edge as a fourth component? record Edge(from, on, to, guard) puts the rule where the rules are, and it costs more rather than less. Every existing row grows a no-op guard, which is a batch of edits that all say "nothing changed". Worse, TransitionTable.next would have to be handed the clock and the trip's history, which turns a pure lookup into something that reads the world. The table's purity is what lets a refusal happen before the append, and that is what means there is no rollback path anywhere.
Could A's if have been the right answer all along? It measures fewer lines here, and it is the answer the corpus rejects on grounds that are not about line count. Trip holds the sequence, not one rule a requirement could reach. Now imagine the second time-dependent rule, say a scheduled trip that may not be matched before its pickup window. In A it is a second if in the same method, so the method that decides legality is also the method that timestamps and records. b-noshow/'s TripGuard pays the plumbing once, and that second rule is then a new implementation and no edit.
Is the smallest diff the right answer? 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 for coupling Router to one concrete MatchPolicy. That absorption cost 47 lines, the most expensive in the corpus, and the author took the larger number knowingly. Use the diff to check a claim. Do not use it to choose a design.
What this pair does not show
The compiler helping. Neither design here notices the new event. A's arms have default branches, and B's Edge keys on a Class, which is not exhaustiveness-checked. The real reference has three exhaustive switches over TripEvent: a projection, a notification and a narrator. Adding WaitExpired to the sealed interface breaks all three by name before a test runs, as verified in 01-driver-cancels/reference-patch/PATCH.md. lessons/B6/worked/ keeps one of those switches, in Main.narrate, and NOTES.md quotes the javac message.
The four-entry-point machine, where A is much worse. corpus/vending-machine has insertCoin, select, refund and collect. Its TransitionTable javadoc names the cost of the switch version directly. The rules end up spread across four methods, and "each method grows a guard clause that has to be kept consistent with three others by hand". Its maintenance-mode curveball adds a whole state that refuses every customer operation, and measures 8. Six of those are the price of admission: 4 lines to declare two new contract methods, and 2 to add two words to the event enum. This trip machine has one entry point, which is the shape most favourable to A. The comparison above is the table's hardest case, on purpose.
How the rules read. b/TripRules.java answers "what can a trip do at the kerb?" by grep. In a/Trip.java the same question means reading nested control flow and trusting that no arm disagrees with another. That is worth something in a design round, and it is not a number.
Worked source
The 12 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/Edge.java31 linesworked/src/MovableClock.java54 linesworked/src/Transition.java26 linesworked/src/TransitionTable.java63 linesworked/src/Trip.java73 linesworked/src/TripEvent.java47 linesworked/src/TripException.java77 linesworked/src/TripGuard.java46 linesworked/src/TripRules.java59 linesworked/src/TripState.java46 linesworked/src/WaitTimeout.java35 linesworked/src/Main.java178 lines
worked/src/Edge.java31 lines
import java.util.Objects;
/**
* One row of the transition table: from here, on an event of this kind, go there.
*
* A value, and a whole rule. The requirement's table becomes a list of these, which means the rules
* can be read, printed, counted and diffed — and extended by appending, which is the property that
* decides what a new kind of event costs.
*
* <h2>Why the key is a {@code Class} and not an enum constant</h2>
* {@link TripEvent} is a sealed hierarchy rather than an enum, because its variants carry data that
* differs per occurrence. So there is no enum constant to key on, and the thing that identifies
* "what kind of event is this" is its type.
*
* <p>What it costs, stated plainly: a {@code Class} key is not exhaustiveness-checked. Nothing
* forces the table to have a row for a new event, so a seventh event with no row compiles fine and
* is refused at runtime. That is a safe default and usually the correct one, and it is the honest
* limit of "make it a compile error" — see {@code when-not.md}.
*
* @param from the state the trip is in
* @param on the kind of event that arrived
* @param to the state the trip lands in
*/
public record Edge(TripState from, Class<? extends TripEvent> on, TripState to) {
public Edge {
Objects.requireNonNull(from, "from");
Objects.requireNonNull(on, "on");
Objects.requireNonNull(to, "to");
}
}
worked/src/MovableClock.java54 lines
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Objects;
/**
* A clock that stays where it is put and counts how often it was read.
*
* Two jobs, both of them design points rather than testing conveniences. Being movable is how the
* five-minute rule can be exercised in a millisecond. Counting reads is how
* {@code faded/GapTest.java} asserts <i>where</i> the clock is read: a design that reads it at the
* top of {@code handle} to have a timestamp ready gives the same answers as one that reads it after
* the table, until the day you count.
*
* <p>Nothing in {@link Trip} constructs a clock or calls {@code Instant.now()}. It takes the one it
* is handed, which is item C3 in the syllabus and the reason any of this is testable.
*/
public final class MovableClock extends Clock {
private Instant now;
private int reads;
public MovableClock(Instant start) {
this.now = Objects.requireNonNull(start, "start");
}
public void advance(Duration by) {
now = now.plus(by);
}
/** How many times the clock has been read. One per event the table permitted. */
public int reads() {
return reads;
}
@Override
public Instant instant() {
reads++;
return now;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
/** Returns this clock unchanged. Nothing here reads a zone, so this says so rather than pretend. */
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
worked/src/Transition.java26 lines
import java.time.Instant;
import java.util.Objects;
/**
* One entry in a trip's history: an accepted event, and what it did.
*
* Only accepted events become entries. A refused event produces an exception and no entry, which is
* why {@link Trip} needs no rollback path.
*
* <p>{@code at} is what makes the no-show rule possible without a second field anywhere: the trip
* knows when it entered its current state because the last entry says so.
*
* @param from the state the trip was in when the event arrived
* @param to the state it was in immediately afterwards
* @param event the event that caused it
* @param at the single clock read for this event
*/
public record Transition(TripState from, TripState to, TripEvent event, Instant at) {
public Transition {
Objects.requireNonNull(from, "from");
Objects.requireNonNull(to, "to");
Objects.requireNonNull(event, "event");
Objects.requireNonNull(at, "at");
}
}
worked/src/TransitionTable.java63 lines
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* The state machine, as a lookup rather than as control flow.
*
* It is handed the rules and indexes them. It does not know what any of them mean, so a change to
* what a trip may do is a change to a list of values and never to this class. The rules live in
* {@link TripRules}.
*
* <h2>What a missing cell means</h2>
* The refusal. A state with no row for an event rejects it, and no code was written anywhere to
* reject it. Two requirements in this problem are enforced by nothing at all: "a trip cannot be
* cancelled once it is under way" is the absence of an {@code IN_PROGRESS + RiderCancelled} row,
* and "a duplicate event is an error" is the absence of every self-loop.
*
* <h2>Why the whole thing is copied at construction</h2>
* A trip's rules cannot change while it is running. Copying the outer map and every row means the
* list handed in cannot be edited underneath a live trip.
*/
public final class TransitionTable {
private final Map<TripState, Map<Class<? extends TripEvent>, TripState>> edges;
public TransitionTable(Collection<Edge> rules) {
Objects.requireNonNull(rules, "rules");
Map<TripState, Map<Class<? extends TripEvent>, TripState>> built = new EnumMap<>(TripState.class);
for (Edge rule : rules) {
TripState clash = built
.computeIfAbsent(rule.from(), state -> new HashMap<>())
.put(rule.on(), rule.to());
// Two targets for one pair is not a rule, it is a coin toss decided by list order.
// Rejected at construction, because the alternative is a trip that transitions
// correctly until someone reorders TripRules.standard().
if (clash != null) {
throw new IllegalArgumentException("two targets for " + rule.from() + " + "
+ rule.on().getSimpleName() + "; the table must be a function");
}
}
built.replaceAll((state, row) -> Map.copyOf(row));
this.edges = Map.copyOf(built);
}
/**
* Where this event leads from this state.
*
* Pure: it looks the answer up and changes nothing. So a caller can find out whether an event
* is legal before touching the log, the clock or the outside world, which is what lets
* {@link Trip} refuse without a rollback path.
*
* @throws TripException.IllegalTransition if the table has no cell for this pair
*/
public TripState next(TripState from, TripEvent event) {
TripState to = edges.getOrDefault(from, Map.of()).get(event.getClass());
if (to == null) {
throw new TripException.IllegalTransition(from, event);
}
return to;
}
}
worked/src/Trip.java73 lines
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* One trip. Holds the <i>sequence</i> of handling an event, and not one rule a requirement could
* reach.
*
* <h2>Ask, then record</h2>
* Every event goes through the same steps, in this order and for a reason.
* <ol>
* <li><b>Is the trip over?</b> A terminal state answers before the table is consulted, because
* the two refusals mean different things to a caller.</li>
* <li><b>Does the table have a cell for this?</b> Pure — {@link TransitionTable#next} looks a
* value up and changes nothing.</li>
* <li><b>Does the guard allow it yet?</b> This is the only step that needs the clock, so the clock
* is read here and nowhere else. An event the table refuses never reaches this line, so a
* {@code WaitExpired} arriving while the trip is still {@code MATCHED} costs no clock read at
* all.</li>
* <li><b>Record.</b> One append, which cannot half-happen.</li>
* </ol>
*
* <p><b>There is no rollback path in this class because there is nothing to roll back.</b> Every
* refusal happens before the append.
*
* <p>Note there is no {@code state} field. {@link #state()} reads the end of the log, so no line
* here has to keep two representations of the same fact in step, and there is no line that can fail
* to. It is also what makes the no-show rule answerable: "when did the driver arrive" is already
* recorded.
*
* <p>Single-threaded on purpose. If a trip were reachable from a request thread and a driver-app
* thread at once, the lock would go around the whole of {@link #handle(TripEvent)} — because "look
* the rule up, then append" has to be one step, or two events can both be accepted from the same
* state.
*/
public final class Trip {
private final Clock clock;
private final TransitionTable table;
private final TripGuard guard;
private final List<Transition> log = new ArrayList<>();
public Trip(Clock clock, TransitionTable table, TripGuard guard) {
this.clock = Objects.requireNonNull(clock, "clock");
this.table = Objects.requireNonNull(table, "table");
this.guard = Objects.requireNonNull(guard, "guard");
}
/** Read off the end of the log, so it cannot disagree with the history. */
public TripState state() {
return log.isEmpty() ? TripState.REQUESTED : log.get(log.size() - 1).to();
}
public TripState handle(TripEvent event) {
Objects.requireNonNull(event, "event");
TripState from = state();
if (from.terminal()) {
throw new TripException.Finished(from, event);
}
TripState to = table.next(from, event);
Instant now = clock.instant();
guard.check(from, event, history(), now);
log.add(new Transition(from, to, event, now));
return to;
}
/** A copy, so a caller cannot append a transition that never happened. */
public List<Transition> history() {
return List.copyOf(log);
}
}
worked/src/TripEvent.java47 lines
/**
* Something that happened to a trip. The alphabet of the machine.
*
* The set is closed, which is what {@code sealed} says out loud: an exhaustive {@code switch} over
* this type needs no {@code default}, and it stops compiling the day a seventh kind of event
* appears. That compile error is the one part of a new event the compiler does catch, and
* {@code NOTES.md} quotes the message it produces.
*
* <p>Why a sealed hierarchy and not {@code enum EventKind}: these events carry data that differs
* per occurrence. {@code Matched} has to say which driver; {@code RiderCancelled} has to say why.
* An enum constant is a singleton and can carry neither, and the usual patch — an enum plus a
* nullable {@code driverId} plus a nullable {@code reason} — permits instances that cannot mean
* anything, such as a {@code TRIP_STARTED} carrying a driver.
*
* <p>The corpus gives each variant its own file, in
* {@code corpus/trip-state-machine/contract/}. They are nested here so the alphabet of the machine
* is one screen.
*/
public sealed interface TripEvent {
/** Dispatch assigned a driver. */
record Matched(String driverId) implements TripEvent { }
/** The driver is at the kerb. Carries nothing: who arrived is already in the log. */
record DriverArrived() implements TripEvent { }
/** The rider is in and the car is moving. */
record TripStarted() implements TripEvent { }
/** Dropped off. */
record TripCompleted() implements TripEvent { }
/** The rider called it off, and said why. */
record RiderCancelled(String reason) implements TripEvent { }
/**
* A timer says the driver's wait is up.
*
* Carries nothing on purpose, and that is a design decision rather than an omission. It could
* have carried the {@code Duration} the timer set itself for, and then the five-minute rule
* would be a comparison against a number the caller supplied. The trip already knows when the
* driver arrived, because it is in the log, and it already has a clock. Two sources for one
* fact is how a trip gets cancelled thirty seconds after the driver pulled up, because a timer
* somewhere had a skewed clock.
*/
record WaitExpired() implements TripEvent { }
}
worked/src/TripException.java77 lines
/**
* One root, two refusals.
*
* A caller catching {@code TripException} catches every way a trip can say no. A caller that has
* to decide whether to retry catches one of the two subtypes instead, because the two answer that
* question differently.
*
* <p>The corpus splits these into {@code IllegalTripTransitionException} and
* {@code TripFinishedException}, each in its own file. Same two types, nested here to keep the
* lesson's file count on the design rather than on the exception hierarchy.
*/
public abstract class TripException extends RuntimeException {
private static final long serialVersionUID = 1L;
TripException(String message) {
super(message);
}
/**
* This event is not legal from this state, and the trip is still live.
*
* The retry question has answer "maybe later": the table may well have a cell for this pair
* once something else has happened. The no-show guard throws this one for exactly that reason
* — the thing that has to happen next is time passing.
*
* <p>It carries the pair rather than only prose, because a dispatcher counts refusals by
* {@code (state, event)} and parsing that back out of a message is how the count drifts.
*/
public static final class IllegalTransition extends TripException {
private static final long serialVersionUID = 1L;
private final TripState from;
// transient because every Throwable is Serializable whether you asked for it or not, and
// TripEvent is not. Nothing here is ever written to a wire; `-Xlint:all` says so anyway.
private final transient TripEvent event;
public IllegalTransition(TripState from, TripEvent event) {
super("a trip in " + from + " cannot handle " + event.getClass().getSimpleName());
this.from = from;
this.event = event;
}
public TripState from() {
return from;
}
public TripEvent event() {
return event;
}
}
/**
* The trip is over, so nothing will ever be accepted again.
*
* The retry question has answer "no, and it will still be no tomorrow". A retry loop that
* cannot tell this from the other one either gives up too early or hammers a trip that
* finished last week.
*/
public static final class Finished extends TripException {
private static final long serialVersionUID = 1L;
private final TripState state;
public Finished(TripState state, TripEvent event) {
super("the trip is already " + state + "; " + event.getClass().getSimpleName()
+ " can never be accepted");
this.state = state;
}
public TripState state() {
return state;
}
}
}
worked/src/TripGuard.java46 lines
import java.time.Instant;
import java.util.List;
/**
* May this transition happen <i>yet</i>?
*
* The second half of the "ask" step, and a genuinely new dimension in the machine. The table answers
* "is this event legal from this state", which is a function of two things. A no-show timeout is a
* function of a third — how long the trip has been sitting where it is — and no
* {@code (state, event) -> state} table expresses that, however well it is indexed.
*
* <h2>Why this is a collaborator and not a fourth component on {@link Edge}</h2>
* A guard on {@code Edge} would mean every existing row grows a no-op guard, which is a batch of
* edits that all say "nothing changed", and {@link TransitionTable#next} would have to be threaded
* the log and the clock. The table's purity is what lets a refusal leave the trip untouched with no
* rollback path, and that is not for sale for one row.
*
* <h2>Where it sits</h2>
* After the table, before the log append. So a guard that refuses is exactly as harmless as any
* other refusal: no entry, no state change, nothing to undo.
*
* <p>It throws rather than returning a boolean, because the refusal carries the pair a caller needs
* and because a boolean would put the choice of exception back into {@link Trip} — the one class
* this design keeps free of rules.
*/
public interface TripGuard {
/**
* The guard for a machine whose rules are all functions of {@code (state, event)}.
*
* Not a placeholder. It is what {@link Trip} is handed when every rule really does fit in the
* table, and {@link Main} uses it to show what the row alone does without the five-minute rule.
*/
TripGuard NONE = (from, event, log, now) -> { };
/**
* @param from the state the trip is in; the table has already accepted this pair, so this
* transition is legal in principle
* @param event what happened
* @param log the history, oldest first — where "how long has this been going on" is answered
* @param now the single clock read for this event, handed in rather than read here, so a guard
* cannot judge the wait against a different moment from the one the entry carries
* @throws TripException.IllegalTransition if this transition is legal in principle but not yet
*/
void check(TripState from, TripEvent event, List<Transition> log, Instant now);
}
worked/src/TripRules.java59 lines
import java.util.ArrayList;
import java.util.List;
/**
* The rules themselves — the requirement's table, transcribed.
*
* Separate from {@link TransitionTable} because the two change for different reasons. The table is
* a lookup mechanism and will not change again. These rows change every time a trip learns a new
* way to behave.
*
* <p>Read the gaps as carefully as the rows. Cancellation appears three times and never from
* {@code IN_PROGRESS}, so "cancellable only before the trip starts" is expressed by which rows
* exist rather than by a condition anywhere. There is not one self-loop, which is what makes a
* duplicate event an error. {@code COMPLETED} and {@code CANCELLED} appear only as destinations,
* because a terminal state is one with no outgoing row.
*/
public final class TripRules {
/**
* The eight legal transitions, and by their absence the twenty-eight illegal ones.
*
* <p>The closing bracket sits on its own line so that appending a rule adds rows instead of
* restructuring the call. It is not quite free, and the honest version is worth stating: Java
* forbids a trailing comma in an argument list, so an append also puts a comma on the row above
* it. A line-level diff charges that as a removal and a re-addition, so one appended rule
* measures three lines rather than two. That is the floor here, and it is one line of
* punctuation rather than a branch anyone has to reason about.
*/
public static List<Edge> standard() {
return List.of(
new Edge(TripState.REQUESTED, TripEvent.Matched.class, TripState.MATCHED),
new Edge(TripState.MATCHED, TripEvent.DriverArrived.class, TripState.DRIVER_ARRIVED),
new Edge(TripState.DRIVER_ARRIVED, TripEvent.TripStarted.class, TripState.IN_PROGRESS),
new Edge(TripState.IN_PROGRESS, TripEvent.TripCompleted.class, TripState.COMPLETED),
new Edge(TripState.REQUESTED, TripEvent.RiderCancelled.class, TripState.CANCELLED),
new Edge(TripState.MATCHED, TripEvent.RiderCancelled.class, TripState.CANCELLED),
new Edge(TripState.DRIVER_ARRIVED, TripEvent.RiderCancelled.class, TripState.CANCELLED),
new Edge(TripState.DRIVER_ARRIVED, TripEvent.WaitExpired.class, TripState.CANCELLED)
);
}
/**
* The rules after {@code corpus/trip-state-machine/curveballs/02-start-without-the-tap}, where
* drivers who forget to tap "I've arrived" have to be able to start anyway.
*
* <p>The real change appends one {@code Edge} to {@link #standard()} and measures 3 lines. It
* is a second method here only so {@link Main} can build both rule sets in one run and show the
* same code answering differently. Reading the two lists side by side is the point; the append
* is what you would actually write.
*/
public static List<Edge> startableFromMatched() {
List<Edge> rules = new ArrayList<>(standard());
rules.add(new Edge(TripState.MATCHED, TripEvent.TripStarted.class, TripState.IN_PROGRESS));
return List.copyOf(rules);
}
private TripRules() {
}
}
worked/src/TripState.java46 lines
/**
* Where a trip is right now.
*
* Six constants, and the interesting property is {@link #terminal()}: it is behaviour on the enum
* rather than a set kept somewhere else, because "a trip that is over is over" is a fact about the
* state and not about any particular trip.
*
* <p>The two refusals a caller can get are different answers to the same question, which is
* "should I retry?". An event refused from a live state may well be accepted later. An event
* refused by a terminal state never will be.
*/
public enum TripState {
/** The rider has asked for a trip. Nothing assigned yet, and this is where a trip starts. */
REQUESTED(false),
/** A driver is assigned and on the way to the pickup point. */
MATCHED(false),
/** The driver is at the kerb, waiting for the rider. */
DRIVER_ARRIVED(false),
/** The rider is in the car. */
IN_PROGRESS(false),
/** Dropped off. Terminal. */
COMPLETED(true),
/** Called off before it started. Terminal. */
CANCELLED(true);
private final boolean terminal;
TripState(boolean terminal) {
this.terminal = terminal;
}
/**
* @return true for {@code COMPLETED} and {@code CANCELLED} and nothing else. A terminal state
* is one with no outgoing row in the table, so nothing in the rules has to mark it as
* dead — but a caller still wants the distinction, and this is where it lives.
*/
public boolean terminal() {
return terminal;
}
}
worked/src/WaitTimeout.java35 lines
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/**
* A driver does not wait forever, but they do wait five minutes.
*
* The only rule in this design that is a function of time rather than of the log's shape, and it is
* confined here so that nothing else has to know such a rule exists.
*
* <p>The requirement is "at least five minutes", not "more than", so exactly five minutes is long
* enough. Both readings compile and only one is right, which is why {@code faded/GapTest.java}
* asserts the boundary at 4:59, 5:00 and 5:01.
*/
public final class WaitTimeout implements TripGuard {
/** How long a driver waits before they are allowed to give up. */
public static final Duration MINIMUM_WAIT = Duration.ofMinutes(5);
@Override
public void check(TripState from, TripEvent event, List<Transition> log, Instant now) {
if (!(event instanceof TripEvent.WaitExpired)) {
return;
}
// The trip's state IS the last entry's `to`, and the table gives WaitExpired a cell only
// from DRIVER_ARRIVED. So the last entry is necessarily the transition that put the driver
// at the kerb, whichever event did it, and its timestamp is when the waiting started.
// Written this way rather than as "search back for the last DriverArrived", it needs no
// change if a second row into DRIVER_ARRIVED is ever added.
Instant waitingSince = log.get(log.size() - 1).at();
if (Duration.between(waitingSince, now).compareTo(MINIMUM_WAIT) < 0) {
throw new TripException.IllegalTransition(from, event);
}
}
}
worked/src/Main.java178 lines
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Five things worth seeing run. The output is pasted into {@code NOTES.md} exactly as printed.
*
* An interviewer runs this first, which is why it exists at all: STANDARD v1.0 caps D2 at 0 when
* there is no {@code main}.
*/
public final class Main {
private static final Instant NINE_AM = Instant.parse("2026-08-18T09:00:00Z");
private static final DateTimeFormatter HMS =
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC);
public static void main(String[] args) {
theRulesAreData();
theHappyPath();
refusalBySilence();
oneAppendedRow();
theRuleThatIsNotARow();
}
private static void theRulesAreData() {
List<Edge> rules = TripRules.standard();
// The alphabet is closed, so it can be counted rather than asserted. getPermittedSubclasses
// is the sealed hierarchy answering "how many kinds of event are there" at runtime.
int events = TripEvent.class.getPermittedSubclasses().length;
int cells = TripState.values().length * events;
System.out.println("== 1 - the rules are a list of values ==");
System.out.printf(" %d states x %d event kinds = %d cells, %d filled, %d refused by silence%n",
TripState.values().length, events, cells, rules.size(), cells - rules.size());
for (Edge rule : rules) {
System.out.printf(" %-15s %-15s -> %s%n",
rule.from(), rule.on().getSimpleName(), rule.to());
}
System.out.println();
}
private static void theHappyPath() {
MovableClock clock = new MovableClock(NINE_AM);
Trip trip = new Trip(clock, new TransitionTable(TripRules.standard()), new WaitTimeout());
for (TripEvent event : List.of(new TripEvent.Matched("d-7"), new TripEvent.DriverArrived(),
new TripEvent.TripStarted(), new TripEvent.TripCompleted())) {
trip.handle(event);
clock.advance(Duration.ofMinutes(3));
}
System.out.println("== 2 - the log is the trip; the state is its last row ==");
for (Transition entry : trip.history()) {
System.out.printf(" %s %-15s -> %-15s %s%n",
HMS.format(entry.at()), entry.from(), entry.to(), narrate(entry.event()));
}
System.out.println(" state() = " + trip.state() + ", read off the end of the log");
System.out.println();
}
private static void refusalBySilence() {
System.out.println("== 3 - three requirements enforced by rows that do not exist ==");
Trip duplicate = liveTrip();
duplicate.handle(new TripEvent.Matched("d-7"));
System.out.println(" no self-loop: " + refusal(duplicate, new TripEvent.Matched("d-9")));
Trip moving = liveTrip();
moving.handle(new TripEvent.Matched("d-7"));
moving.handle(new TripEvent.DriverArrived());
moving.handle(new TripEvent.TripStarted());
System.out.println(" no cancel row: "
+ refusal(moving, new TripEvent.RiderCancelled("changed my mind")));
Trip done = liveTrip();
done.handle(new TripEvent.RiderCancelled("too expensive"));
System.out.println(" terminal, not table: " + refusal(done, new TripEvent.Matched("d-9")));
System.out.println(" Neither an if nor a switch was written for any of the three.");
System.out.println();
}
private static void oneAppendedRow() {
System.out.println("== 4 - the same code, two rule sets ==");
for (List<Edge> rules : List.of(TripRules.standard(), TripRules.startableFromMatched())) {
Trip trip = new Trip(new MovableClock(NINE_AM), new TransitionTable(rules), new WaitTimeout());
trip.handle(new TripEvent.Matched("d-7"));
String outcome;
try {
outcome = "accepted -> " + trip.handle(new TripEvent.TripStarted());
} catch (TripException refused) {
outcome = "refused";
}
System.out.printf(" %d rows: a driver who forgot to tap arrive starts anyway: %s%n",
rules.size(), outcome);
}
System.out.println(" The difference is one Edge in a list. No method was opened.");
System.out.println();
}
private static void theRuleThatIsNotARow() {
System.out.println("== 5 - the rule the table cannot hold ==");
MovableClock rowOnly = new MovableClock(NINE_AM);
Trip noGuard = new Trip(rowOnly, new TransitionTable(TripRules.standard()), TripGuard.NONE);
atTheKerb(noGuard);
rowOnly.advance(Duration.ofSeconds(1));
System.out.printf(" %-36s %s%n", "row alone, 1s after arrival:",
noGuard.handle(new TripEvent.WaitExpired()) + " <- wrong, and the table agreed");
for (int seconds : new int[] { 299, 300 }) {
MovableClock clock = new MovableClock(NINE_AM);
Trip trip = new Trip(clock, new TransitionTable(TripRules.standard()), new WaitTimeout());
atTheKerb(trip);
clock.advance(Duration.ofSeconds(seconds));
String outcome;
try {
outcome = "accepted -> " + trip.handle(new TripEvent.WaitExpired());
} catch (TripException.IllegalTransition refused) {
outcome = "refused: " + refused.getMessage();
}
System.out.printf(" %-36s %s%n",
"row plus guard, " + seconds + "s after arrival:", outcome);
}
MovableClock counted = new MovableClock(NINE_AM);
Trip early = new Trip(counted, new TransitionTable(TripRules.standard()), new WaitTimeout());
early.handle(new TripEvent.Matched("d-7"));
int afterOneAccepted = counted.reads();
try {
early.handle(new TripEvent.WaitExpired());
} catch (TripException.IllegalTransition refused) {
// Refused by the table: MATCHED has no WaitExpired cell.
}
System.out.printf(" clock reads: %d after one accepted event, %d after a WaitExpired the "
+ "table refused%n", afterOneAccepted, counted.reads());
System.out.println(" Time is only consulted for events the table already permitted.");
}
private static Trip liveTrip() {
return new Trip(new MovableClock(NINE_AM), new TransitionTable(TripRules.standard()),
new WaitTimeout());
}
private static void atTheKerb(Trip trip) {
trip.handle(new TripEvent.Matched("d-7"));
trip.handle(new TripEvent.DriverArrived());
}
private static String refusal(Trip trip, TripEvent event) {
try {
trip.handle(event);
return "accepted, which is a bug";
} catch (TripException refused) {
return refused.getClass().getSimpleName() + ": " + refused.getMessage();
}
}
/**
* The exhaustive switch that makes a seventh kind of event a compile error.
*
* No {@code default} branch, deliberately. {@code NOTES.md} quotes the {@code javac} message
* this line produces when a variant is added to {@link TripEvent} and this method is not
* updated.
*/
private static String narrate(TripEvent event) {
return switch (event) {
case TripEvent.Matched matched -> "driver " + matched.driverId() + " assigned";
case TripEvent.DriverArrived arrived -> "driver at the kerb";
case TripEvent.TripStarted started -> "rider in, moving";
case TripEvent.TripCompleted completed -> "dropped off";
case TripEvent.RiderCancelled cancelled -> "rider called it off: " + cancelled.reason();
case TripEvent.WaitExpired expired -> "driver could not wait";
};
}
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.