LLD Dojo

Syllabus · D2

Error signalling choice — exception vs result vs Optional

The idea

What a method hands back when it cannot give its normal answer

Three real choices, used on purpose across the corpus, and never null. corpus/parking-lot's SpotAllocator.allocate returns Optional<List<String>>, because "no room" is an ordinary search outcome. corpus/rate-limiter's denial is a Decision for the same reason, not an exception: "the single most ordinary thing that happens to a rate-limited caller." A search finding nothing has not failed.

An exception belongs at the edge, where a caller wants the thing or a reason, not a value to unwrap. park() converts that empty Optional into IllegalStateException: "optionals for expected absence, exceptions for a broken request."

A third case hides in plain sight: not every refusal is a domain outcome. corpus/file-system throws IllegalArgumentException for a malformed path, deliberately outside its FileSystemException hierarchy — "a string that could never have named anything" is a caller bug, not a fact the tree produced. Its decision log calls this split "the D2 decision worth naming out loud," and corpus/rate-limiter titles a section "Errors: three mechanisms, on purpose (D2)."

The same-shaped refusal gets opposite treatment by contract. A bad parking ticket is IllegalArgumentException; a bad vending-machine button press is a domain exception — "these five refusals ... belong in one hierarchy where a caller can handle them as a set." Neither is wrong: the question is whether the caller branches on which thing happened, or only needs to know something did.

worked/ runs one "find a spot" search through Optional, an exception, and a boundary translating one into the other, with the real printed output for each.


Worked walkthrough

NOTES — one search, signalled three ways, and the boundary where the choice changes

Run it first

.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main

javac prints two [serial] warnings — NoFreeSpotException and SpotsFullException have no serialVersionUID — and nothing else. corpus/file-system/contract/FileSystemException.java carries the identical warning; the corpus does not fix it either, because a serialVersionUID buys nothing for an exception nobody serialises. Real output, verbatim:

--- 1. Optional-based search: the ordinary case and the empty case
  first free, empty lot   : Optional[A1]
  first free, full lot    : Optional.empty
--- 2. Exception-based search: the same two cases, the same finder logic
  first free, empty lot   : Optional[A1]
  first free, full lot    : rejected with NoFreeSpotException: no free spot among 2
--- 3. SpotDesk: Optional inside, exception at the edge
  park car1 -> A1
  park car2 -> A2
  park car3 into a full desk: rejected with SpotsFullException: no free spot among 2
--- 4. release(): a caller bug is not a domain outcome
  release a spot never issued: rejected with IllegalArgumentException: spot not occupied: nope
  free count after release : 1
  park car4 -> A1

The question every one of these four blocks answers

A method cannot produce its normal answer. What does it hand back? `corpus/rate-limiter/reference/ DECISION_LOG.md` names this decision by its syllabus id, in its own heading: "Errors: three mechanisms, on purpose (D2)". corpus/file-system/reference/DECISION_LOG.md does the same for a single method: "exists answering false instead of throwing is the D2 decision worth naming out loud." This lesson is that decision, worked twice on one small domain.


Block 1 — FirstFreeFinder: absence as an ordinary result

    for (String id : spotIds) {
        if (!occupied.contains(id)) {
            return Optional.of(id);
        }
    }
    return Optional.empty();

This is corpus/parking-lot/reference/src/FirstFitAllocator.java's own shape, simplified to one spot instead of a truck's footprint. Nothing throws for a full lot, because nothing has gone wrong — a search that looked and found nothing is a completed search, not a failed one. Block 1's output makes the case literal: Optional.empty prints and the program moves straight to the next line. No catch, no early return, no branch the caller was forced into.

Block 2 — ThrowingFinder: the same fact, forced through an exception

    throw new NoFreeSpotException("no free spot among " + spotIds.size());

SpotFinder.firstFree still declares Optional<String> — the interface did not stop this. That is worth sitting with: choosing Optional as a return type does not choose the design for you. An implementation can always throw instead of returning the empty case, and the call site in block 2 has to wrap a search — the single most ordinary thing that happens to it — in a try/catch to get the identical information block 1 printed in one line. `corpus/rate-limiter/ reference/DECISION_LOG.md` names exactly this cost: "Being rate limited is the single most ordinary thing that happens to a rate-limited caller. An exception for it would make the normal path the exceptional one and cost a stack trace per denied request at exactly the moment the system is busiest." A full parking spot is no different: it will happen on nearly every busy afternoon, not on a bad day.

Block 3 — SpotDesk.park(): the boundary, translating one into the other

    Optional<String> spot = finder.firstFree(spotIds, occupied);
    String id = spot.orElseThrow(() -> new SpotsFullException("no free spot among " + spotIds.size()));

Two facts, not a contradiction. Internally, firstFree answers with Optional because a search finding nothing is ordinary. At park(), the same empty result becomes SpotsFullException, because a caller who calls park() wants a parked vehicle or a reason, not an Optional to unwrap at every call site. corpus/parking-lot/reference/DECISION_LOG.md states the boundary in one line: "optionals for expected absence, exceptions for a broken request" — and its park() throws the unchecked IllegalStateException for exactly this reason, which is why SpotsFullException extends it here rather than starting a hierarchy of its own. One edge case does not need a hierarchy; when-not.md covers what changes once there is more than one.

Block 4 — release(): a caller bug is not a domain outcome

    if (!occupied.remove(spotId)) {
        throw new IllegalArgumentException("spot not occupied: " + spotId);
    }

A full desk and an unknown spot id look like siblings — both are "the operation could not proceed" — and treating them the same way is the mistake to catch here. A full desk is a fact about the world that changes minute to minute; releasing a spot that was never issued is a bug in the caller, the same shape as corpus/file-system's malformed path. Its own decision log draws the line: "IllegalArgumentException is a caller bug — a string that could never have named anything, no matter what the tree contained — while every member of FileSystemException is an outcome of walking an otherwise well-formed path through a tree whose actual contents decided the answer." release("nope") is that same caller bug, one domain over, which is why it throws the plain, unhierarchied IllegalArgumentException rather than SpotsFullException.

The choice, stated once

SituationSignalWhy
A search that may reasonably find nothingOptional<T>Absence is a normal result, and the caller usually branches on it immediately
One well-known refusal at a public edgeAn unchecked exception (IllegalStateException/IllegalArgumentException if the contract allows it)The caller wants the thing or a reason, not a value to unwrap everywhere
A caller-side mistake — bad input, wrong state a check would have caughtIllegalArgumentException/IllegalStateException, never a domain typeIt is not an outcome the domain produced; folding it into a domain hierarchy makes a caller sort bug from outcome by hand

when-not.md covers the fourth case this table leaves out: what happens once a refusal has more than one reason the caller must tell apart.


When not to

Optional loses the reason. Sometimes the reason is the whole answer

worked/ argues for Optional on a search that either finds a spot or does not. The overuse risk is reaching for it again once a refusal grows a second reason, because by then Optional is the familiar tool.

The concrete cost

corpus/vending-machine could have had select return Optional<DispenseResult> for "the sale did not happen." Its decision log rejects that directly: "there are five distinct reasons for failure and an empty Optional can carry none of them." Sold out, wrong code, no funds, no change, and a state refusal all collapse into one Optional.empty(). A caller that needs to tell a customer which one happened has to re-derive it by re-checking the machine's state after the fact, the exact information the call discarded already.

corpus/library's borrowing rules make the same point from the return-value side: "a boolean loses which rule refused; an Optional<String> loses the numbers." Only a named exception per rule, each carrying its own fields, keeps the fine amount and rule name attached to the refusal rather than forced into a second lookup.

corpus/shopping-cart's setQuantity(code, 0) is the same shape once more: it returns a zero-quantity LineItem, not Optional or null. A caller updating a row on screen always has something to render, and "Optional.empty() would force every caller to write the same else branch."

The threshold

Optional is right when the caller's next step is identical no matter why the answer is missing. There was no free spot, full stop, and every caller does the same thing next. The moment a refusal has more than one reason the caller must act on differently, Optional has already thrown that fact away before the caller ever sees it. A named exception, or a richer result carrying the reason as data the way InsufficientFundsException carries the shortfall, is the one that still answers the question actually asked.


Worked source

The 7 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/FirstFreeFinder.java21 lines

import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * The corpus's own answer, simplified: {@code FirstFitAllocator.allocate} returns
 * {@code Optional.empty()} when nothing fits, never throws for it. "No room" is a fact the
 * search discovers, not a rule the caller broke.
 */
public final class FirstFreeFinder implements SpotFinder {

    @Override
    public Optional<String> firstFree(List<String> spotIds, Set<String> occupied) {
        for (String id : spotIds) {
            if (!occupied.contains(id)) {
                return Optional.of(id);
            }
        }
        return Optional.empty();
    }
}

worked/src/NoFreeSpotException.java9 lines

/**
 * Exists only so {@code ThrowingFinder} can demonstrate the wrong shape: this is the exact same
 * fact {@code Optional.empty()} already carries, wearing a stack trace it does not need.
 */
public final class NoFreeSpotException extends RuntimeException {
    public NoFreeSpotException(String message) {
        super(message);
    }
}

worked/src/SpotDesk.java53 lines

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * The boundary a real caller calls, as opposed to {@link SpotFinder}, the search behind it.
 * Same fact — "is a spot free" — two different callers, two different right answers: the search
 * hands back {@code Optional}, the desk hands back a spot or throws.
 */
public final class SpotDesk {

    private final List<String> spotIds;
    private final Set<String> occupied = new HashSet<>();
    private final SpotFinder finder;

    public SpotDesk(List<String> spotIds, SpotFinder finder) {
        this.spotIds = List.copyOf(spotIds);
        this.finder = finder;
    }

    public int freeCount() {
        return spotIds.size() - occupied.size();
    }

    /**
     * Converts the finder's {@code Optional} into a spot or an exception. A caller who calls
     * {@code park()} wants a parked vehicle, full stop — unwrapping an {@code Optional} at every
     * call site would just move the same {@code if (present)} branch into a hundred callers
     * instead of one.
     *
     * @return the spot id occupied
     * @throws SpotsFullException if nothing is free right now
     */
    public String park() {
        Optional<String> spot = finder.firstFree(spotIds, occupied);
        String id = spot.orElseThrow(() -> new SpotsFullException("no free spot among " + spotIds.size()));
        occupied.add(id);
        return id;
    }

    /**
     * @throws IllegalArgumentException if spotId is not currently occupied — this is a caller
     *         bug (releasing a ticket that was never issued), never a domain outcome, so it is
     *         deliberately {@code IllegalArgumentException} and not {@link SpotsFullException}
     *         or any member of a domain hierarchy
     */
    public void release(String spotId) {
        if (!occupied.remove(spotId)) {
            throw new IllegalArgumentException("spot not occupied: " + spotId);
        }
    }
}

worked/src/SpotFinder.java14 lines

import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Modelled on {@code corpus/parking-lot/reference/src/SpotAllocator.java}: deciding where a
 * vehicle goes is a search, and a search that finds nothing is an ordinary result, not a
 * failure.
 */
public interface SpotFinder {

    /** @return the first free spot id, lowest first, or empty if none are free right now. */
    Optional<String> firstFree(List<String> spotIds, Set<String> occupied);
}

worked/src/SpotsFullException.java14 lines

/**
 * The one exception a caller of {@link SpotDesk#park()} has to handle. Modelled on
 * {@code corpus/parking-lot}'s own choice: {@code park()} throws {@code IllegalStateException}
 * when nothing suitable is free, because the desk's own {@code freeCount()} is right there to
 * check first — the caller could have asked before acting. That is why this extends
 * {@code IllegalStateException} rather than joining a hand-rolled domain hierarchy: the contract
 * fixes this one because there is only one edge case here to fix, not five the way
 * vending-machine or file-system have.
 */
public final class SpotsFullException extends IllegalStateException {
    public SpotsFullException(String message) {
        super(message);
    }
}

worked/src/ThrowingFinder.java22 lines

import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * A competent engineer's other instinct: "nothing found" feels like a failure, so signal it like
 * one. The interface still declares {@code Optional<String>} — nothing stops an implementation
 * from throwing instead of ever returning {@code Optional.empty()}. That is the point: the
 * return type does not enforce the choice, the implementation does.
 */
public final class ThrowingFinder implements SpotFinder {

    @Override
    public Optional<String> firstFree(List<String> spotIds, Set<String> occupied) {
        for (String id : spotIds) {
            if (!occupied.contains(id)) {
                return Optional.of(id);
            }
        }
        throw new NoFreeSpotException("no free spot among " + spotIds.size());
    }
}

worked/src/Main.java55 lines

import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Four short runs. Nothing here asserts anything — {@code javac -Xlint:all} is silent, and the
 * point is to read the printed output, which is transcribed verbatim into worked/NOTES.md.
 */
public final class Main {

    public static void main(String[] args) {
        List<String> spots = List.of("A1", "A2");

        System.out.println("--- 1. Optional-based search: the ordinary case and the empty case");
        SpotFinder finder = new FirstFreeFinder();
        Set<String> none = Set.of();
        Set<String> allTaken = Set.of("A1", "A2");
        System.out.println("  first free, empty lot   : " + finder.firstFree(spots, none));
        System.out.println("  first free, full lot    : " + finder.firstFree(spots, allTaken));

        System.out.println("--- 2. Exception-based search: the same two cases, the same finder logic");
        SpotFinder throwing = new ThrowingFinder();
        System.out.println("  first free, empty lot   : " + throwing.firstFree(spots, none));
        try {
            throwing.firstFree(spots, allTaken);
            throw new AssertionError("expected a refusal");
        } catch (NoFreeSpotException e) {
            System.out.println("  first free, full lot    : rejected with "
                    + e.getClass().getSimpleName() + ": " + e.getMessage());
        }

        System.out.println("--- 3. SpotDesk: Optional inside, exception at the edge");
        SpotDesk desk = new SpotDesk(spots, new FirstFreeFinder());
        System.out.println("  park car1 -> " + desk.park());
        System.out.println("  park car2 -> " + desk.park());
        print(desk::park, "park car3 into a full desk");

        System.out.println("--- 4. release(): a caller bug is not a domain outcome");
        print(() -> desk.release("nope"), "release a spot never issued");
        desk.release("A1");
        System.out.println("  free count after release : " + desk.freeCount());
        System.out.println("  park car4 -> " + desk.park());
    }

    /** Runs something expected to fail and prints how it failed, because that is the point. */
    private static void print(Runnable attempt, String what) {
        try {
            attempt.run();
            System.out.println("  " + what + ": UNEXPECTEDLY ALLOWED");
        } catch (RuntimeException e) {
            System.out.println("  " + what + ": rejected with "
                    + e.getClass().getSimpleName() + ": " + 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.

← D1 · Method signature and naming design — what the call site can read E1 · Identifying shared mutable state — the two-question census →

← all lessons