Syllabus · A6
Domain exception hierarchy and error signalling — what is actually yours to design
The idea
The hierarchy is handed to you
Three problems in this corpus tag this item: file-system, library, vending-machine. In every one, the exception classes sit in contract/, marked GIVEN. Do not edit. You never write class SoldOutException extends VendingMachineException. parking-lot and snake-and-ladder dropped the item for that exact reason. So what is A6 actually asking you to do, if not that?
Three things, and none of them is naming a class.
Where a refusal is thrown. SlotRack throws SoldOutException, not the machine that runs the sale, because the rack is the collaborator that knows the count. That placement is yours even when the type is not.
The line between a refusal and your own bug. A blank slot code is not a fact about the machine's stock, so it stays outside the hierarchy. IllegalTransitionException is the sharper case: the vending machine's decision log calls a button press the machine will not honour a domain refusal, not IllegalStateException. A different problem here draws that line the other way for a similar mistake. Both defend the choice, and you have to be ready to argue yours.
What you do once the exception exists. swallowed-exception is the only failure tag routed here, and it has nothing to do with shape. An empty catch is a decision made in silence.
The corpus gives you the noun. Where it is thrown, which side of the line a new failure falls on, and whether you swallow it: that part stays yours.
Worked walkthrough
NOTES — a machine with three domain refusals, one caller bug, and one swallowed exception
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 reports only the [serial] warning every RuntimeException subclass gets without a serialVersionUID — the same warning corpus/vending-machine/contract/ and every exception in lessons/J9 carry. Nothing else. Real output, from exactly this code, unedited:
== 1. three domain refusals, and their real messages ==
UnknownSlotException: no such slot: A9
SoldOutException: slot A2 is empty
InsufficientFundsException: slot A1 costs 150, inserted 50, short 100 (shortfall 100)
== 2. a display branches on which one; an operator alert catches the root ==
display for A9: NO SUCH SLOT
display for A2: SOLD OUT
display for A1: INSERT 100 MORE
catch (SnackMachineException e) caught all 3 of them
== 3. a blank slot code is a caller bug, and the domain catch does not see it ==
IllegalArgumentException escaped the domain catch: slot code must not be blank
== 4. swallowed-exception: two versions of the same catch block ==
quietBuyBad("A2", 200) returned: sorry
log after quietBuyBad: [] (nothing recorded which slot, or why)
quietBuyGood("A2", 200) returned: sorry: slot A2 is empty
log after quietBuyGood: [slot A2 is empty]
quietBuyGood("", 200) let the caller bug through: slot code must not be blank
== 5. a leaf the corpus never gave you, caught by code that already exists ==
MachineJammedException: slot A1 jammed while dispensing
caught by the same catch (SnackMachineException) as section 2, no edit made there: true
Three lines matter more than the rest: quietBuyBad("A2", 200) returned: sorry, the empty log after it, and the last line of section 5.
SnackMachineException.java
public abstract class SnackMachineException extends RuntimeException {
This is the one piece of the hierarchy this lesson does not ask you to invent. In every corpus problem that tags A6, a class shaped exactly like this sits in contract/, marked GIVEN. J9 already covers unchecked versus checked, so the four words `abstract class ... extends RuntimeException` are not what is worth studying here. Everything below is: which collaborator throws which leaf, and what a caller does when it catches one.
SlotRack.java
public Slot available(String slotCode) {
Slot slot = slots.get(slotCode);
if (slot == null) {
throw new UnknownSlotException(slotCode);
}
if (slot.quantity() <= 0) {
throw new SoldOutException(slotCode);
}
return slot;
}
The rack throws, not the machine that orchestrates the sale. That placement is a design decision the contract does not make for you. TinySnackMachine.buy could have asked rack.contains(slotCode) and rack.quantityOf(slotCode) and thrown both exceptions itself. Every future collaborator that needs to check stock would then either duplicate that logic or go through the machine for a fact that belongs to the rack. Put the throw where the fact lives instead, and a second caller of SlotRack gets the same refusal for free.
Two different conditions, two different types, in the same method. A slot that does not exist and a slot that exists but is empty are not the same fact, even though both start at slots.get(slotCode). Section 2's displayFor prints NO SUCH SLOT for one and SOLD OUT for the other. Collapsing them into one type would mean parsing a message to tell those two apart, which is the string-matching a hierarchy exists to remove.
TinySnackMachine.java
public Receipt buy(String slotCode, long insertedMinor) {
if (slotCode == null || slotCode.isBlank()) {
throw new IllegalArgumentException("slot code must not be blank");
}
This is the line this lesson is actually about. No arrangement of the rack's stock could make an empty string name a slot. This is never a fact the machine discovers about itself; it is a fact about the call. That is why it is IllegalArgumentException, outside SnackMachineException. Section 3 shows the consequence: a catch (SnackMachineException e) wrapped around this call does not run, and the caller's own mistake keeps propagating. Make this throw UnknownSlotException("") instead, and a defensive catch (SnackMachineException e) upstream starts swallowing bugs in the caller's own code along with the machine's honest refusals.
long priceMinor = slot.priceMinor();
if (insertedMinor < priceMinor) {
throw new InsufficientFundsException(slotCode, priceMinor, insertedMinor);
}
rack.sell(slotCode);
The check runs before rack.sell, not after. This is the same ask-act-commit discipline corpus/vending-machine/reference/src/VendingMachine.java uses: work out whether the whole operation can succeed, and only then change anything. Move rack.sell above this check, and a refused sale still empties a slot nobody paid for. A test only catches that bug if it checks the stock count after a refusal, which is exactly what GapTest's aRefusedSaleDoesNotChangeStock does.
InsufficientFundsException.java
public InsufficientFundsException(String slotCode, long priceMinor, long insertedMinor) {
super(...);
this.shortfallMinor = priceMinor - insertedMinor;
}
The shortfall is computed once, in the constructor, not by whoever catches this. Section 1's e.shortfallMinor() and the message's own short 100 come from the same subtraction. Leave this out, and every display that wants "insert N more" recomputes priceMinor - insertedMinor itself. The day someone changes that arithmetic in one of the two places, the receipt and the log disagree.
Main.java
private static String quietBuyBad(TinySnackMachine machine, String slotCode, long insertedMinor, List<String> log) {
try {
Receipt r = machine.buy(slotCode, insertedMinor);
return "dispensed " + r.productName();
} catch (RuntimeException e) {
return "sorry";
}
}
This compiles, runs without ever crashing, and is the bug swallowed-exception is named for. catch (RuntimeException e) is wide enough to catch IllegalArgumentException too. A blank slot code, the caller's own mistake, gets the identical silent "sorry" as an honest sold-out refusal, and the log never learns which slot or why. Section 4's first two lines show it: the returned string carries no information, and log stays [].
private static String quietBuyGood(TinySnackMachine machine, String slotCode, long insertedMinor, List<String> log) {
try {
Receipt r = machine.buy(slotCode, insertedMinor);
return "dispensed " + r.productName();
} catch (SnackMachineException refused) {
log.add(refused.getMessage());
return "sorry: " + refused.getMessage();
}
}
The fix is not a bigger catch block, it is a narrower catch clause. SnackMachineException catches exactly the three domain refusals and nothing else, so the blank-slot-code call at the end of section 4 still throws past this method. The variable is named refused, not e, for the same reason J9's TinyFileSystem.exists names its catch variable notThere. The name is where the reason for catching lives.
public final class MachineJammedException extends SnackMachineException {
Added after every other file in this lesson, and nothing above it changed. Section 5 throws it and catches it with the exact catch (SnackMachineException anyRefusal) written for section 2. corpus/vending-machine/contract/VendingMachineException.java says the same thing out loud: "you may add further subclasses of your own if your design wants them." That sentence answers this lesson's own question. The shape the contract hands you is a floor, not a ceiling, on what there is left to design.
When not to
When a fourth leaf is not worth the file
library's decision log names the threshold directly. Give a failure its own type when a caller will branch on it: a distinct catch, a distinct recovery, or a distinct message shown to somebody. UnknownSlotException and SoldOutException clear that bar in this lesson's own TinySnackMachine, because displayFor prints a different string for each.
Here is the version that does not clear it. Say a later requirement adds "the coin slot is stuck open," reported by hardware and unrelated to any product code:
public final class CoinSlotStuckException extends SnackMachineException {
public CoinSlotStuckException() {
super("coin slot is stuck open");
}
}
Say nothing in the codebase ever catches this one specifically: no display branch, no operator alert, no retry logic that behaves differently for it. Then this is a fifth file that earns nothing. The message alone was already the whole plan: log it and page someone. A catch clause for it would be dead code. file-system makes the same call about IllegalArgumentException — there is nothing to do about a malformed path except fix the call site, so it gets no type either.
The failing middle option is worse than either extreme: one type with a Reason enum field inside it. catch (SnackMachineException e) { if (e.reason() == Reason.STUCK) ... } is the same string-matching a hierarchy exists to remove, now hiding behind a field instead of a message. If a new failure needs its own recovery, it earns its own class. If it only needs its own sentence in a log, a constructor argument to an existing type, or plain IllegalArgumentException, does the same job for one file instead of two.
Worked source
The 10 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/InsufficientFundsException.java41 linesworked/src/MachineJammedException.java20 linesworked/src/Receipt.java5 linesworked/src/Slot.java11 linesworked/src/SlotRack.java36 linesworked/src/SnackMachineException.java14 linesworked/src/SoldOutException.java16 linesworked/src/TinySnackMachine.java39 linesworked/src/UnknownSlotException.java16 linesworked/src/Main.java159 lines
worked/src/InsufficientFundsException.java41 lines
// InsufficientFundsException.java
/**
* Not enough was inserted for this slot.
*
* Carries the price and what was inserted, and derives the shortfall once, here, rather than
* leaving every catch site to subtract the two numbers itself and risk two different answers.
*/
public final class InsufficientFundsException extends SnackMachineException {
private final String slotCode;
private final long priceMinor;
private final long insertedMinor;
private final long shortfallMinor;
public InsufficientFundsException(String slotCode, long priceMinor, long insertedMinor) {
super("slot " + slotCode + " costs " + priceMinor + ", inserted " + insertedMinor
+ ", short " + (priceMinor - insertedMinor));
this.slotCode = slotCode;
this.priceMinor = priceMinor;
this.insertedMinor = insertedMinor;
this.shortfallMinor = priceMinor - insertedMinor;
}
public String slotCode() {
return slotCode;
}
public long priceMinor() {
return priceMinor;
}
public long insertedMinor() {
return insertedMinor;
}
/** How much more is needed. Computed once, here, so a display never has to do the subtraction. */
public long shortfallMinor() {
return shortfallMinor;
}
}
worked/src/MachineJammedException.java20 lines
// MachineJammedException.java
//
// Not in corpus/vending-machine/contract/ at all. Every GIVEN hierarchy in this corpus says the
// same thing: "you may add further subclasses of your own if your design wants them." This is
// one of those — added after the fact, in section 5 of Main, with no edit to any file above.
/** The motor stalled partway through a dispense. Nothing about the slot or the money was wrong. */
public final class MachineJammedException extends SnackMachineException {
private final String slotCode;
public MachineJammedException(String slotCode) {
super("slot " + slotCode + " jammed while dispensing");
this.slotCode = slotCode;
}
public String slotCode() {
return slotCode;
}
}
worked/src/Receipt.java5 lines
// Receipt.java
/** What a completed sale handed back: the product, what it cost, and the change left over. */
public record Receipt(String slotCode, String productName, long priceMinor, long changeMinor) {
}
worked/src/Slot.java11 lines
// Slot.java
//
// An immutable snapshot, the same shape as corpus/vending-machine/contract/Slot.java. Selling
// one item replaces the value stored under the code; it never mutates one in place.
public record Slot(String slotCode, String productName, long priceMinor, int quantity) {
public Slot sellOne() {
return new Slot(slotCode, productName, priceMinor, quantity - 1);
}
}
worked/src/SlotRack.java36 lines
// SlotRack.java
//
// The rack is the one collaborator that knows what is on the shelf and how much of it. That is
// why it throws the two refusals that are facts about the shelf, rather than the machine that
// orchestrates a sale asking the rack a question and translating the answer itself.
import java.util.LinkedHashMap;
import java.util.Map;
public final class SlotRack {
private final Map<String, Slot> slots = new LinkedHashMap<>();
public void stock(Slot slot) {
slots.put(slot.slotCode(), slot);
}
/**
* The slot named, if it exists and has stock. Throws the two refusals that belong to this
* class: nobody has heard of the code, or the code exists and there is nothing left.
*/
public Slot available(String slotCode) {
Slot slot = slots.get(slotCode);
if (slot == null) {
throw new UnknownSlotException(slotCode);
}
if (slot.quantity() <= 0) {
throw new SoldOutException(slotCode);
}
return slot;
}
public void sell(String slotCode) {
slots.put(slotCode, slots.get(slotCode).sellOne());
}
}
worked/src/SnackMachineException.java14 lines
// SnackMachineException.java
//
// The root of every way this machine refuses a sale. Modelled on
// corpus/vending-machine/contract/VendingMachineException.java, which is GIVEN there — the
// point of this lesson is the reasoning that produces a hierarchy like this one, not the class
// names themselves.
/** Abstract on purpose: "something went wrong" is never the actual failure, one of the leaves is. */
public abstract class SnackMachineException extends RuntimeException {
protected SnackMachineException(String message) {
super(message);
}
}
worked/src/SoldOutException.java16 lines
// SoldOutException.java
/** The slot exists. It is empty. A display says SOLD OUT for this one and nothing for the other. */
public final class SoldOutException extends SnackMachineException {
private final String slotCode;
public SoldOutException(String slotCode) {
super("slot " + slotCode + " is empty");
this.slotCode = slotCode;
}
public String slotCode() {
return slotCode;
}
}
worked/src/TinySnackMachine.java39 lines
// TinySnackMachine.java
//
// One method, and the three-step discipline from corpus/vending-machine/reference/src/
// VendingMachine.java: ask whether the sale is possible, work out the whole outcome, then
// commit. Nothing here decides class names — SnackMachineException and its three leaves already
// exist. What this class decides is which fact belongs to which check, and in what order.
public final class TinySnackMachine {
private final SlotRack rack;
public TinySnackMachine(SlotRack rack) {
this.rack = rack;
}
/**
* Sell one item from the named slot, if the code is real, the slot has stock, and enough
* was inserted. The three refusals below are checked in the order a real machine would
* need to know them: is this even a request that could name something, does the shelf have
* it, can it be paid for.
*/
public Receipt buy(String slotCode, long insertedMinor) {
if (slotCode == null || slotCode.isBlank()) {
// Not a fact about the machine's stock. No arrangement of the rack could make an
// empty string name a slot, so this is the caller's mistake, not the rack's answer —
// it stays outside SnackMachineException on purpose.
throw new IllegalArgumentException("slot code must not be blank");
}
Slot slot = rack.available(slotCode);
long priceMinor = slot.priceMinor();
if (insertedMinor < priceMinor) {
throw new InsufficientFundsException(slotCode, priceMinor, insertedMinor);
}
rack.sell(slotCode);
return new Receipt(slotCode, slot.productName(), priceMinor, insertedMinor - priceMinor);
}
}
worked/src/UnknownSlotException.java16 lines
// UnknownSlotException.java
/** Nobody has heard of this slot code. Nothing to do with whether it happens to have stock. */
public final class UnknownSlotException extends SnackMachineException {
private final String slotCode;
public UnknownSlotException(String slotCode) {
super("no such slot: " + slotCode);
this.slotCode = slotCode;
}
public String slotCode() {
return slotCode;
}
}
worked/src/Main.java159 lines
// Main.java
//
// Five sections. The first two show the hierarchy working the way it is supposed to. The third
// draws the line the contract does not draw for you: which failures are the machine's and which
// are the caller's. The fourth is the one failure tag routed to this lesson, swallowed-exception,
// shown as a real bug rather than described. The fifth adds a class the corpus never gave you.
import java.util.ArrayList;
import java.util.List;
public final class Main {
public static void main(String[] args) {
section1ThreeRefusals();
section2CatchOneOrCatchTheRoot();
section3ACallerBugIsNotADomainRefusal();
section4Swallowing();
section5AddingYourOwnLeaf();
}
private static TinySnackMachine freshMachine() {
SlotRack rack = new SlotRack();
rack.stock(new Slot("A1", "Chips", 150, 2));
rack.stock(new Slot("A2", "Water", 100, 0));
return new TinySnackMachine(rack);
}
private static void section1ThreeRefusals() {
System.out.println("== 1. three domain refusals, and their real messages ==");
TinySnackMachine machine = freshMachine();
try {
machine.buy("A9", 200);
} catch (UnknownSlotException e) {
System.out.println(" UnknownSlotException: " + e.getMessage());
}
try {
machine.buy("A2", 200);
} catch (SoldOutException e) {
System.out.println(" SoldOutException: " + e.getMessage());
}
try {
machine.buy("A1", 50);
} catch (InsufficientFundsException e) {
System.out.println(" InsufficientFundsException: " + e.getMessage()
+ " (shortfall " + e.shortfallMinor() + ")");
}
}
private static void section2CatchOneOrCatchTheRoot() {
System.out.println("== 2. a display branches on which one; an operator alert catches the root ==");
TinySnackMachine machine = freshMachine();
System.out.println(" display for A9: " + displayFor(machine, "A9", 200));
System.out.println(" display for A2: " + displayFor(machine, "A2", 200));
System.out.println(" display for A1: " + displayFor(machine, "A1", 50));
int refusalsSeenByTheRoot = 0;
for (String[] attempt : new String[][]{{"A9", "200"}, {"A2", "200"}, {"A1", "50"}}) {
try {
machine.buy(attempt[0], Long.parseLong(attempt[1]));
} catch (SnackMachineException anyRefusal) {
refusalsSeenByTheRoot++;
}
}
System.out.println(" catch (SnackMachineException e) caught all " + refusalsSeenByTheRoot + " of them");
}
/** What a caller writes when it cares which refusal happened. */
private static String displayFor(TinySnackMachine machine, String slotCode, long insertedMinor) {
try {
Receipt r = machine.buy(slotCode, insertedMinor);
return "DISPENSING " + r.productName();
} catch (UnknownSlotException e) {
return "NO SUCH SLOT";
} catch (SoldOutException e) {
return "SOLD OUT";
} catch (InsufficientFundsException e) {
return "INSERT " + e.shortfallMinor() + " MORE";
}
}
private static void section3ACallerBugIsNotADomainRefusal() {
System.out.println("== 3. a blank slot code is a caller bug, and the domain catch does not see it ==");
TinySnackMachine machine = freshMachine();
try {
try {
machine.buy("", 200);
} catch (SnackMachineException domainRefusal) {
System.out.println(" (this line does not run: IllegalArgumentException is not a SnackMachineException)");
}
} catch (IllegalArgumentException e) {
System.out.println(" IllegalArgumentException escaped the domain catch: " + e.getMessage());
}
}
private static void section4Swallowing() {
System.out.println("== 4. swallowed-exception: two versions of the same catch block ==");
TinySnackMachine machine = freshMachine();
List<String> log = new ArrayList<>();
String badResult = quietBuyBad(machine, "A2", 200, log);
System.out.println(" quietBuyBad(\"A2\", 200) returned: " + badResult);
System.out.println(" log after quietBuyBad: " + log + " (nothing recorded which slot, or why)");
String goodResult = quietBuyGood(machine, "A2", 200, log);
System.out.println(" quietBuyGood(\"A2\", 200) returned: " + goodResult);
System.out.println(" log after quietBuyGood: " + log);
try {
quietBuyGood(machine, "", 200, log);
} catch (IllegalArgumentException e) {
System.out.println(" quietBuyGood(\"\", 200) let the caller bug through: " + e.getMessage());
}
}
/**
* The bug. It compiles, it never crashes, and it is wrong: catching RuntimeException means
* a blank slot code — the caller's own mistake — gets the same silent "sorry" as a sold-out
* slot, and the log never learns which slot or why.
*/
private static String quietBuyBad(TinySnackMachine machine, String slotCode, long insertedMinor, List<String> log) {
try {
Receipt r = machine.buy(slotCode, insertedMinor);
return "dispensed " + r.productName();
} catch (RuntimeException e) {
return "sorry";
}
}
/**
* Catches exactly the hierarchy this machine owns, records the real reason, and lets
* anything else — a caller bug — propagate instead of being absorbed by the same catch.
*/
private static String quietBuyGood(TinySnackMachine machine, String slotCode, long insertedMinor, List<String> log) {
try {
Receipt r = machine.buy(slotCode, insertedMinor);
return "dispensed " + r.productName();
} catch (SnackMachineException refused) {
log.add(refused.getMessage());
return "sorry: " + refused.getMessage();
}
}
private static void section5AddingYourOwnLeaf() {
System.out.println("== 5. a leaf the corpus never gave you, caught by code that already exists ==");
int refusalsSeenByTheRoot = 0;
try {
throw new MachineJammedException("A1");
} catch (SnackMachineException anyRefusal) {
refusalsSeenByTheRoot++;
System.out.println(" MachineJammedException: " + anyRefusal.getMessage());
}
System.out.println(" caught by the same catch (SnackMachineException) as section 2, no edit made there: "
+ (refusalsSeenByTheRoot == 1));
}
}
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.