LLD Dojo

Java Bridge · J9

Exceptions and cleanup — checked vs unchecked, and try-with-resources instead of RAII

The idea

The signature carries the failure

In file-system, read fails two ways that have nothing in common. The path might be "/notes//todo.txt", which no tree could ever satisfy. Or /notes might not exist yet. C++ gives you one category for both.

The obvious Java move: stop anyone ignoring the second. Declare NoSuchPathException extends Exception and it becomes checked: the signature reads String read(String path) throws NoSuchPathException, and so does every caller up the chain. Miss one and the build stops:

error: unreported exception NoSuchPathException; must be caught or declared to be thrown

That is a contract enforced, not a bug caught. A checked exception is a second return type, and every caller states its answer. noexcept never asked that.

Then the fourth failure type arrives. Four exceptions over eight methods puts a throws clause on nearly every signature, and pushes callers into catch (Exception e) {}.

So file-system sorts on a different axis first: whose mistake was it. A malformed path is IllegalArgumentException, unchecked and outside the hierarchy, because no catch would repair it. The four domain outcomes share an abstract FileSystemException root, so a caller branches on which one happened.

All of them are unchecked. The rule that decided it: unchecked for what the caller should have prevented, checked for what a reasonable caller must handle and could not have checked first. exists() is that check, which is why a missing path earned no clause.

Cleanup follows the same idea. With no destructors, a class that must be closed says so by implementing AutoCloseable, and the caller says it back in a try-with-resources header.


Coming from C++

From C++ — two categories of exception, and no destructors

You already know what an exception is, and you know RAII better than most Java developers ever will. Two things here have no counterpart in C++ at all. Java sorts exceptions into two categories and enforces one of them at compile time. And Java has no destructors, so the cleanup you get for free in C++ is something you and your caller both have to write down.

The category C++ does not have

Every Java throwable is one of three things.

KindRootCompiler enforces a throws clause
ErrorErrorno
Unchecked exceptionRuntimeExceptionno
Checked exceptionException, excluding the RuntimeException subtreeyes

That third row is the new idea. A checked exception is part of the method's declared type. If a method can throw one, the signature says so, and every caller has to either catch it or repeat the clause.

C++ — a signature says almost nothing about failure

std::string read(const std::string& path);                 // may throw anything
std::string read(const std::string& path) noexcept;        // throws nothing, or terminate()

noexcept is one bit, and it is checked at run time. Violate it and you get std::terminate, after the program has already started failing.

Java — the signature lists the failure modes, and javac checks the callers

String read(String path) throws NoSuchPathException;

Write a caller that ignores it and the build stops. This is the error you will meet inside an hour of writing Java, and it is worth being able to read at a glance:

> javac -d out P1.java
P1.java:11: error: unreported exception ExportFailedException; must be caught or declared to be thrown
        flush("/notes/todo.txt");
             ^
1 error

The caret is on the call, not on the throw. javac is telling you which caller has not said what it does about this failure. Two ways to satisfy it: catch it here, or add throws ExportFailedException to the enclosing method and pass the decision upward.

The clause is a real part of the type, and narrowing rules apply the way you would expect from overriding a virtual function. An implementation cannot add a checked exception the interface did not declare:

P6.java:11: error: export(String) in SinkExporter cannot implement export(String) in Exporter
    public void export(String path) throws ExportFailedException {
                ^
  overridden method does not throw ExportFailedException
1 error

That message is the whole argument for why this feels different from C++. A throws clause is a promise the interface made to its callers, so an implementation cannot break it. noexcept never constrained you this way, because it was one bit and it was checked far too late.

The rule, applied to file-system's own choices

corpus/file-system/contract/ has five ways to fail. Here is where each landed, and why.

ConditionTypeCategoryWhy there
"/notes//todo.txt", "notes", nullIllegalArgumentExceptionuncheckedNo tree contents could make it valid. The caller has a bug
mv("/a", "/a/b/c")IllegalArgumentExceptionuncheckedMoving a directory into itself is structurally impossible
A segment names nothingNoSuchPathExceptionuncheckedA domain outcome, and exists() lets a caller check first
A file where a directory was neededNotADirectoryExceptionuncheckedSame, and a caller acts differently on it than on "missing"
The destination already existsPathAlreadyExistsExceptionuncheckedSame again

The four domain types share an abstract root, FileSystemException extends RuntimeException. The malformed-path case sits outside that hierarchy on purpose. A caller catching "path problems" as one group would otherwise have to sort its own bugs from the tree's answers by hand.

The rule to carry into a round. Unchecked for what the caller should have prevented. Checked for a condition a reasonable caller must handle and could not have checked for first.

Both clauses matter, and the second is what sent every file-system failure to unchecked. exists(path) and kindOf(path) are cheap, and one caller at a time is guaranteed by the contract, so a caller who cannot cope with a throw asks before it reads. Nothing here is unpredictable in the way a network or a disk is.

Contrast ExportSession.close() in worked/. It writes to a sink outside the process, the sink can refuse at any moment, and there is no canFlush() to ask first. That is the shape checked was built for, so ExportFailedException extends Exception.

When a failure earns its own type

Four classes for four conditions is not free. Each is a file to write and a name to learn.

Give a failure its own type when a caller will branch on it: a distinct catch, a distinct recovery, a distinct message to a user. file-system has four because ls on a file leads somewhere different from ls on a missing path. One offers to create it, the other tells you to stop walking. worked/Main prints both branches.

Use IllegalArgumentException with the value in the message when every caller's response is identical, or when the only consumer is a log line. Malformed paths got no type for this reason. There is nothing to do about "/notes//todo.txt" except fix the call site, so a catch clause for it would be dead code. PathSyntax throws IllegalArgumentException("malformed path (empty segment): /notes//todo.txt"), and that message is the entire recovery plan.

The failing middle option is one type with a Reason enum inside it. Callers then parse a field to work out what happened, which is the string-matching the hierarchy exists to remove.

try-with-resources is not RAII, and the differences bite

C++ — the destructor runs, and you cannot forget it

{
    ExportSession session{sink};              // header written
    session.entry("/notes/todo.txt", body);   // may throw
}                                             // ~ExportSession() runs on every exit path

Java — the resource is named in the header, and the block is the scope

try (ExportSession session = ExportSession.open("nightly", sink, false)) {
    session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
}   // session.close() runs here, on every exit path out of the block

Four differences, and each one costs something.

C++ RAIIJava try-with-resources
Triggerend of the enclosing scopeend of the try block
Opting inautomatic, every object with a destructorexplicit, per try statement
Applies toany typeonly AutoCloseable implementations
Forgetting itimpossiblecompiles silently and closes nothing

The type restriction is checked, at least:

P2.java:7: error: incompatible types: try-with-resources not applicable to variable type
        try (ExportSession session = new ExportSession()) {
                           ^
    (ExportSession cannot be converted to AutoCloseable)
1 error

The resource has to be declared in the parentheses

This is the one that loses a handle in real code. Move the declaration one line up and everything still compiles:

ExportSession session = ExportSession.open("leaked", sink, false);
try {
    session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
} catch (NoSuchPathException missing) {
    System.out.println("  " + missing.getMessage());
}

Real output from worked/Main, section 4:

== 4. the resource declared outside the parentheses ==
  session.isClosed() -> false
  | BEGIN EXPORT leaked
  |   /notes/todo.txt -> renew passport
  no END EXPORT line, so nothing may read this export

close() never ran, so the footer was never written, so the export is unreadable. Plain javac exits 0. javac -Xlint:all on the same file reports the finally problem in section 6 and says nothing whatever about this. There is no flag that finds it.

Java 9 onward, the header can also name a local that is already effectively final, which is the fix when the resource is built by a long expression:

ExportSession session = ExportSession.open("nightly", sink, false);
try (session) {
    session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
}

Two resources in one header close in reverse declaration order, matching destructor order. From worked/Main, section 3, where all was declared first and notes second:

  | BEGIN EXPORT all
  | BEGIN EXPORT notes
  |   /notes/todo.txt -> renew passport
  |   /notes/2026/plan.txt -> ship the trainer
  | END EXPORT notes (1 entries)
  | END EXPORT all (1 entries)

Suppressed exceptions, where C++ has nothing to compare

In C++ a destructor that throws while the stack is unwinding calls std::terminate. Since C++11 destructors are implicitly noexcept, so the language's answer is that this situation must not arise.

Java lets it arise and keeps both exceptions. If the body throws and close() also throws, the body's exception is the one that propagates, and close()'s is attached to it as suppressed. worked/Main section 5 does exactly that: the body reads a missing path, and the session's sink refuses the flush.

  caught: NoSuchPathException
  suppressed: ExportFailedException: sink refused the flush of export nightly after 1 entries
  its cause: java.io.IOException: no space left on device
  --- printStackTrace() ---
NoSuchPathException: no such path: /notes/absent.txt
	at TinyFileSystem.read(TinyFileSystem.java:71)
	at Main.suppressedException(Main.java:100)
	at Main.main(Main.java:20)
	Suppressed: ExportFailedException: sink refused the flush of export nightly after 1 entries
		at ExportSession.close(ExportSession.java:62)
		at Main.suppressedException(Main.java:98)
		... 1 more
	Caused by: java.io.IOException: no space left on device
		... 3 more

Read the indentation. Suppressed: is nested under the exception that won, with its own frames and its own Caused by: chain. Nothing was lost, and nothing called terminate.

Two consequences worth holding on to. e.getSuppressed() returns an array, so code that has to report every failure can walk it; the loop that printed the two suppressed: lines above is four lines long. And if you hand-roll the cleanup with finally instead, close()'s exception replaces the body's and the original is gone. That is the mistake try-with-resources exists to stop you making.

finally, and the two traps

finally runs on every exit path out of the try: normal completion, a return, a break, or an exception on its way out. C++ has no equivalent because RAII covers the cases you would use it for, so both of these are new.

Trap one: a return inside finally discards the exception in flight.

private static String swallow() {
    try {
        throw new NoSuchPathException("/notes/absent.txt");
    } finally {
        return "looked fine to me";
    }
}
== 6. a return inside finally discards the exception ==
  swallow() returned: looked fine to me
  the NoSuchPathException it threw is gone; nothing recorded it

The exception did not propagate and nothing logged it. return in a finally block completes the method normally, and a normal completion has no exception. A break, a continue, or a fresh throw in there does the same thing. One flag catches it:

> javac -Xlint:finally -d out P3.java
P3.java:11: warning: [finally] finally clause cannot complete normally
        }
        ^
1 warning

A warning, not an error, and off by default. It is in -Xlint:all, which is the argument for running with -Xlint:all on any code you are being graded on.

Trap two: finally runs after the return expression is evaluated, and cannot change it.

private static int entriesWritten() {
    int written = 0;
    try {
        return written;
    } finally {
        written = 99;
    }
}
== 7. finally runs even after return, and cannot alter it ==
  body: the return expression is evaluated here
  finally: runs now, after that, before the caller is resumed
  returned: the body's value
  entriesWritten() returned: 0   (finally set the local to 99 after the value was taken)

The order is: evaluate the return expression, stash the value, run finally, then hand the stashed value to the caller. So finally does run after return, and assigning to the returned local afterwards changes nothing. Mutating a returned object through the same reference does change what the caller sees, which is the version of this bug that is hard to find.

Given both traps, the rule is narrow. Use finally for a resource that is not AutoCloseable and for restoring state you changed on the way in. Never put a control-flow statement in one.

No destructors, so cleanup is in the type system

There is no deterministic destruction in Java, and the pieces that look like it are not it.

Which leaves one real mechanism, and one design consequence. Cleanup is the caller's job, and you state that in the type system by implementing AutoCloseable. That is what ExportSession implements AutoCloseable buys in worked/: the compiler will not let a caller write try (ExportSession s = ...) unless the interface is there, and a reviewer reading the class declaration knows a close() is owed. A class that needs closing and does not implement AutoCloseable is a class whose caller has to read the javadoc to find out.

The threshold, since not everything wants this. Implement AutoCloseable when the object holds something the process must give back, and the giving back can fail or has to happen at a known point. A file, a socket, a lock, a transaction, a batch awaiting a commit. Do not implement it so a plain value object can sit in a try header. An ExportSession earns it, and a Tariff does not.

What an interviewer reads as carelessness

Exception design is API design, and it is visible in a way most decisions are not. Three habits cost credit, and each has a cheap fix.

Swallowing. catch (NoSuchPathException e) {} compiles and hides a real failure. If a caller genuinely has nothing to do, say so where it is written down, the way TreeResolver.find does:

try {
    return get(path);
} catch (FileSystemException notThere) {
    return null;
}

The named variable and the comment above it turn a swallow into a decision. The rule: an empty catch needs a comment saying why nothing is the correct response, or it is a bug.

Catching Exception. It catches your own NullPointerException too, so a bug in the try block gets reported as a domain failure. TinyFileSystem.exists catches FileSystemException and lets IllegalArgumentException through, because a malformed path is still a caller bug even inside exists. When two unrelated types need the same handling, use multi-catch, catch (NoSuchPathException | NotADirectoryException e), rather than widening. Ordering is checked, so a broad clause first is a compile error:

P7.java:11: error: exception NoSuchPathException has already been caught
        } catch (NoSuchPathException e) {
          ^
1 error

throw new RuntimeException() with no message. The stack trace names your line and nothing about the data. Compare the corpus, where every constructor builds its own message: new NoSuchPathException("/notes/absent.txt") prints no such path: /notes/absent.txt. The rule is one you can apply without thinking: the message names the value that was wrong. got 0 beats invalid argument at 1am, and it is the difference between one second of diagnosis and reading the call site.

The delta table

C++Java
one category of exceptiontwo: checked (Exception) and unchecked (RuntimeException)
a signature carries noexcept and nothing elsethrows A, B names each checked failure mode
violating noexcept calls std::terminate at run timeomitting a throws clause is a compile error
catch (const std::exception& e), e.what()catch (FileSystemException e), e.getMessage()
catch (...)catch (Exception e), and it reads as carelessness
an override may throw anythingan override may not add a checked exception
RAII: destructor at scope exit, automatictry-with-resources: close() at block exit, opt-in per statement
any type can manage a resourceonly an AutoCloseable may sit in a try header
destructors run in reverse construction orderresources close in reverse declaration order
a destructor throwing while unwinding calls std::terminateclose()'s exception is attached as Suppressed: and the body's wins
catch (...) { cleanup(); throw; }finally, and it can discard the exception if you return in it
std::unique_ptr makes ownership deterministicGC makes memory automatic and cleanup nobody's job until you declare it
destructor is invisible in the interfaceimplements AutoCloseable is the interface saying it

The habit that will cost you time today

Writing throws Exception on a method to make the compiler quiet. It compiles, and it deletes the information the clause exists to carry. Every caller now sees one opaque failure, and no caller can catch one condition without catching all of them. main is the one place it is defensible, because there is nobody above it to inform.


Worked walkthrough

NOTES — seven files, three failure kinds, and one resource that has to be closed

Compile and run from the directory holding the sources:

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

Plain javac exits 0 with no output. Real output, from exactly this code, unedited:

== 1. which condition wins when several apply ==
  IllegalArgumentException: malformed path (empty segment): /notes//missing.txt
  NotADirectoryException: /notes/todo.txt is not a directory
  NoSuchPathException: no such path: /notes/absent.txt

== 2. the caller branches on which domain failure it was ==
  read /notes/todo.txt -> renew passport
  read /notes/absent.txt -> offer to create it at /notes/absent.txt
  read /notes/todo.txt/x -> /notes/todo.txt is a file; stop walking
  exists("/notes/absent.txt") -> false   (the caller who cannot handle a throw asks first)

== 3. try-with-resources: both close, in reverse order ==
  | BEGIN EXPORT all
  | BEGIN EXPORT notes
  |   /notes/todo.txt -> renew passport
  |   /notes/2026/plan.txt -> ship the trainer
  | END EXPORT notes (1 entries)
  | END EXPORT all (1 entries)

== 4. the resource declared outside the parentheses ==
  session.isClosed() -> false
  | BEGIN EXPORT leaked
  |   /notes/todo.txt -> renew passport
  no END EXPORT line, so nothing may read this export

== 5. the body's exception wins; close()'s is attached ==
  caught: NoSuchPathException
  suppressed: ExportFailedException: sink refused the flush of export nightly after 1 entries
  its cause: java.io.IOException: no space left on device
  --- printStackTrace() ---
NoSuchPathException: no such path: /notes/absent.txt
	at TinyFileSystem.read(TinyFileSystem.java:71)
	at Main.suppressedException(Main.java:100)
	at Main.main(Main.java:20)
	Suppressed: ExportFailedException: sink refused the flush of export nightly after 1 entries
		at ExportSession.close(ExportSession.java:62)
		at Main.suppressedException(Main.java:98)
		... 1 more
	Caused by: java.io.IOException: no space left on device
		... 3 more
  | BEGIN EXPORT nightly
  |   /notes/todo.txt -> renew passport
  | END EXPORT nightly (1 entries)

== 6. a return inside finally discards the exception ==
  swallow() returned: looked fine to me
  the NoSuchPathException it threw is gone; nothing recorded it

== 7. finally runs even after return, and cannot alter it ==
  body: the return expression is evaluated here
  finally: runs now, after that, before the caller is resumed
  returned: the body's value
  entriesWritten() returned: 0   (finally set the local to 99 after the value was taken)

Four lines in there are the lesson. session.isClosed() -> false, the Suppressed: line, swallow() returned: looked fine to me, and entriesWritten() returned: 0.

One more result worth having, because it says where the compiler can and cannot help:

> javac -Xlint:all -d out *.java
Main.java:128: warning: [finally] finally clause cannot complete normally
        }
        ^

That is section 6, found. Section 4 is the resource that never closes, and it draws no warning from -Xlint:all and no error from anything. The trap the compiler catches is the smaller of the two.


FileSystemException.java

public abstract class FileSystemException extends RuntimeException {

abstract means nobody can throw the root itself. So every throw site has to pick which of the four conditions it is reporting, and a catch (FileSystemException e) is always catching a choice somebody made rather than a shrug. Drop abstract and throw new FileSystemException("something went wrong") becomes available, which is where an exception hierarchy starts collapsing back into one type.

extends RuntimeException, so every domain failure here is unchecked. That is a decision, not a default, and it is the one to be able to defend. A caller can rule out all four conditions first with exists() or kindOf(). The contract guarantees one caller at a time, so no window opens between the check and the call. Make this extends Exception instead and all eight FileSystemApi methods grow a throws clause. Nothing is safer, and every caller is noisier.

The root exists so a caller can catch one thing. TreeResolver.find in the reference does exactly that: one catch (FileSystemException notThere) turns any of the four into null. Without the root that is a four-way multi-catch that has to be edited whenever the hierarchy grows, and the shortcuts curveball does grow it, with SymlinkLoopException.

NoSuchPathException.java

    public NoSuchPathException(String path) {
        super("no such path: " + path);
        this.path = path;
    }

The constructor builds the message, so no throw site can forget it. Every one of these is throw new NoSuchPathException(path), and the message is right by construction. The alternative, throw new RuntimeException("not found") at each site, is where messages drift and stop naming the value.

this.path is kept as a field, not only interpolated into the message. That is what makes missing.path() usable in section 2's catch, so the caller offers to create that exact path. Without the field a caller wanting the path has to parse getMessage(), and a hierarchy that forces callers to parse strings has failed at the thing it was for.

PathSyntax.java

        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
        }

IllegalArgumentException, not a FileSystemException. This is the caller-bug half of the lesson. The test is one question you can ask about any failure: could any state of the system have made this call succeed? For "notes" the answer is no, whatever the tree holds. So there is nothing for a caller to catch and no recovery to write, and the exception belongs outside the hierarchy a caller branches on.

The message carries the offending path. path must be absolute (start with '/'): notes needs no debugger. IllegalArgumentException("bad path") sends you to the call site to find out which one.

This class never looks at the tree. That is what keeps the distinction free rather than a discipline. Fold these checks into the walk and every early return is answering two questions at once, and the exception it throws stops following from the question it answered.

TinyFileSystem.java

    public String read(String path) {
        List<String> segments = PathSyntax.segments(path);
        requireWalkable(segments);
        String content = files.get(PathSyntax.join(segments));
        if (content == null) {
            throw new NoSuchPathException(path);
        }
        return content;
    }

Three statements, three conditions, and the order is the contract. read("/notes//missing.txt") is malformed and missing. Line one wins, so the tree is never consulted, and section 1 prints IllegalArgumentException rather than NoSuchPathException. Move requireWalkable above segments and a malformed path starts being reported as a domain outcome, which is a contract change the tests will catch.

**read("/notes/todo.txt/deeper.txt") is blocked and missing.** requireWalkable runs first, so the caller is told /notes/todo.txt is not a directory and gets the blocking segment rather than the whole path. That is the more useful of the two answers, which is why it is checked first.

content == null is the only place "missing" is decided. One condition, one throw, one message. Returning null here instead would push a null check into every caller and lose the path.

    public boolean exists(String path) {
        try {
            read(path);
            return true;
        } catch (FileSystemException notThere) {

Catching the root, not Exception. A malformed path throws IllegalArgumentException, which is not a FileSystemException, so it travels straight out of exists. That is deliberate: asking whether "/notes//x" exists is still a caller bug. Widen this to catch (Exception e) and exists("/notes//x") starts answering false, which hides the bug and reports a lie.

exists is why every failure in this hierarchy could stay unchecked. A caller that cannot cope with a throw has a cheap way to avoid one. Take exists out of the contract and the argument for unchecked gets much weaker, because there is no longer a check to have made first.

The variable is named notThere, not e. The name is the only place the reason for an otherwise-empty branch can live. A reviewer reading catch (FileSystemException notThere) knows this is a decision.

ExportSession.java

public final class ExportSession implements AutoCloseable {

implements AutoCloseable is the whole design statement. Java has no destructor to write, so this interface is the only way the class can say "somebody owes me a close()". Two things follow mechanically. The compiler permits try (ExportSession s = ...), and it refuses it otherwise: error: incompatible types: try-with-resources not applicable to variable type. Take the interface off and the class still compiles, still works, and now nothing in its declaration warns a caller.

    public static ExportSession open(String name, StringBuilder sink, boolean sinkRefusesFlush) {
        sink.append("BEGIN EXPORT ").append(name).append('\n');
        return new ExportSession(name, sink, sinkRefusesFlush);
    }

The header is written during open, which is what makes an unclosed session detectable. The export is half-written, and section 4 shows the consequence: a BEGIN EXPORT with no END EXPORT. An open that wrote nothing would leave a missing close() invisible, and the bug would surface somewhere else entirely.

    @Override
    public void close() throws ExportFailedException {
        if (closed) {
            return;
        }
        closed = true;

if (closed) return; makes close() idempotent. try-with-resources calls it once, but a caller that also calls it by hand, or a nested session, must not write a second footer. AutoCloseable's javadoc asks for this and does not enforce it, so it is on you.

closed = true comes before anything that can throw. A failed close is still a close. Without this line, the throw below leaves closed false, a retry writes a second footer, and session.entry(...) is still accepted on a session whose footer is already out.

        if (sinkRefusesFlush) {
            throw new ExportFailedException(
                    "sink refused the flush of export " + name + " after " + entries + " entries",
                    new java.io.IOException("no space left on device"));
        }

throws ExportFailedException on close() is legal because AutoCloseable.close() declares throws Exception. That is the difference from java.io.Closeable, whose close() narrows to throws IOException. If you implement Closeable you cannot throw your own checked type from close().

Checked, and this is the case that earns it. No canFlush() exists to ask first, and the sink is outside the process. Make it unchecked and a caller can ignore a failed export with no acknowledgement anywhere. Section 5's catch clause exists because javac demanded it: error: unreported exception ExportFailedException; must be caught or declared to be thrown, reported at the try line with exception thrown from implicit call to close() on resource variable 'session'.

The message names the export and the count; the cause is the real reason. getCause() keeps the IOException reachable, and section 5 prints it. Wrapping without a cause is the version of this that loses the diagnosis.

Main.java

        try (ExportSession outer = ExportSession.open("all", sink, false);
             ExportSession inner = ExportSession.open("notes", sink, false)) {

Two resources in one header, closing in reverse declaration order. The output shows END EXPORT notes before END EXPORT all, matching C++ destructor order. Two nested try statements would do the same; one header is the shorter version, and the order is specified rather than incidental.

        ExportSession session = ExportSession.open("leaked", sink, false);
        try {
            session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));

Section 4 exists to be wrong, and to be wrong silently. One line moved out of the parentheses, and session.isClosed() prints false. No error, no warning, not from -Xlint:all either. This is the price of try-with-resources being opt-in per statement instead of automatic per scope, and it is the single most useful thing to have seen once.

        } catch (ExportFailedException | FileSystemException e) {

Multi-catch, because these two need identical handling and share no useful supertype. Their nearest common ancestor is Exception, and catching that would also catch a NullPointerException from the body. The e in a multi-catch is implicitly final, so you cannot reassign it.

            for (Throwable suppressed : e.getSuppressed()) {

getSuppressed() is where the exception close() threw actually lives. The body's NoSuchPathException propagated and won. Without this loop the ExportFailedException is present in the object and absent from your logs, because most logging calls print only getMessage(). Hand-rolling the same cleanup in a finally that calls close() is worse: close()'s exception would replace the body's, and the original would be gone with nothing attached.

    private static String swallow() {
        try {
            throw new NoSuchPathException("/notes/absent.txt");
        } finally {
            return "looked fine to me";
        }
    }

The return in the finally block is what discards the exception. finally completing by return means the method completed normally, and a normal completion carries no exception. The caller sees a plausible string and no failure was recorded anywhere. Delete the return and the NoSuchPathException propagates as written.

javac -Xlint:finally finds this one, with warning: [finally] finally clause cannot complete normally pointed at the closing brace. It is off by default and it is a warning, so nothing fails.

    private static int entriesWritten() {
        int written = 0;
        try {
            return written;
        } finally {
            written = 99;
        }
    }

The returned value is taken before finally runs, so this returns 0. The sequence is: evaluate the return expression, keep the value, run finally, hand the kept value back. Assigning to the local afterwards has nothing to assign to. The same code returning a mutable object would let finally change what the caller sees, and that is the version worth being careful about.


Worked source

The 8 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/ExportFailedException.java17 lines

// ExportFailedException.java
//
// The one CHECKED exception in this lesson, and the only one that earns it.
//
// It extends Exception rather than RuntimeException, so every caller of anything that can
// throw it either catches it or repeats it in its own throws clause. That cost is paid on
// purpose: an export writes to a sink outside this process, the sink can refuse at any
// moment, and no caller can ask in advance whether the next flush will succeed. Compare
// NoSuchPathException, which a caller can rule out with exists() before it calls read().

/** An export could not be completed because the sink refused it. */
public class ExportFailedException extends Exception {

    public ExportFailedException(String message, Throwable cause) {
        super(message, cause);
    }
}

worked/src/ExportSession.java67 lines

// ExportSession.java
//
// The RAII shape, written the way Java makes you write it.
//
// A session holds something that has to be given back: here a sink that needs a footer before
// anyone can read the export, in a real design a file handle, a socket, or a lock. C++ would
// put the giving-back in a destructor and stop thinking about it. Java has no destructors, so
// the class states "I need closing" the only way the language provides — by implementing
// AutoCloseable — and the caller states "I will close you" by naming it in a try-with-resources
// header. Both halves are visible in the source, and neither is automatic.

/** An in-progress export. Closing it writes the footer; nothing can read the export before that. */
public final class ExportSession implements AutoCloseable {

    private final String name;
    private final StringBuilder sink;
    private final boolean sinkRefusesFlush;
    private int entries;
    private boolean closed;

    private ExportSession(String name, StringBuilder sink, boolean sinkRefusesFlush) {
        this.name = name;
        this.sink = sink;
        this.sinkRefusesFlush = sinkRefusesFlush;
    }

    /** Opens a session and writes the header. The caller now owes a close(). */
    public static ExportSession open(String name, StringBuilder sink, boolean sinkRefusesFlush) {
        sink.append("BEGIN EXPORT ").append(name).append('\n');
        return new ExportSession(name, sink, sinkRefusesFlush);
    }

    /** Appends one path and its content. Rejected after close, because the footer is written. */
    public void entry(String path, String content) {
        if (closed) {
            throw new IllegalStateException("session " + name + " is closed; cannot export " + path);
        }
        sink.append("  ").append(path).append(" -> ").append(content).append('\n');
        entries++;
    }

    public boolean isClosed() {
        return closed;
    }

    /**
     * Writes the footer, then reports a refusing sink.
     *
     * <p>Two things are load-bearing here. The flag is set before anything can throw, so a
     * failed close is still a close and a second call does nothing. And the exception is
     * checked, which is what makes the compiler refuse a caller that ignores a failed export.
     */
    @Override
    public void close() throws ExportFailedException {
        if (closed) {
            return;
        }
        closed = true;
        sink.append("END EXPORT ").append(name)
                .append(" (").append(entries).append(" entries)\n");
        if (sinkRefusesFlush) {
            throw new ExportFailedException(
                    "sink refused the flush of export " + name + " after " + entries + " entries",
                    new java.io.IOException("no space left on device"));
        }
    }
}

worked/src/FileSystemException.java16 lines

// FileSystemException.java
//
// Copied from corpus/file-system/contract/FileSystemException.java, javadoc trimmed.
//
// The root of every DOMAIN failure: the path was a legal sentence, and the tree's actual
// contents decided the answer. Structural misuse — a malformed path, or touching the root
// where the root cannot be touched — is IllegalArgumentException instead, deliberately
// outside this hierarchy.

/** Root of every domain-specific failure this file system reports. */
public abstract class FileSystemException extends RuntimeException {

    protected FileSystemException(String message) {
        super(message);
    }
}

worked/src/NoSuchPathException.java19 lines

// NoSuchPathException.java
//
// Copied from corpus/file-system/contract/NoSuchPathException.java.

/** Some segment of a path — possibly the last one — names nothing that exists. */
public final class NoSuchPathException extends FileSystemException {

    private final String path;

    public NoSuchPathException(String path) {
        super("no such path: " + path);
        this.path = path;
    }

    /** The path that was asked for. Not necessarily the exact missing segment. */
    public String path() {
        return path;
    }
}

worked/src/NotADirectoryException.java18 lines

// NotADirectoryException.java
//
// Copied from corpus/file-system/contract/NotADirectoryException.java.

/** A path names a file where the operation, or a segment along the way, needed a directory. */
public final class NotADirectoryException extends FileSystemException {

    private final String path;

    public NotADirectoryException(String path) {
        super(path + " is not a directory");
        this.path = path;
    }

    public String path() {
        return path;
    }
}

worked/src/PathSyntax.java49 lines

// PathSyntax.java
//
// Trimmed from corpus/file-system/reference/src/PathSyntax.java.
//
// Everything about a path that can be decided WITHOUT looking at the tree. Every failure in
// here is IllegalArgumentException, because no arrangement of files could ever make "/a//b"
// well-formed. That is the whole reason this class is separate from the walk: one class asks
// "is this a legal sentence", the other asks "what does the sentence point at", and the
// exception each one throws follows from which question it answers.
import java.util.List;

final class PathSyntax {

    /**
     * The path's named segments, root-to-leaf. The root is the empty list.
     *
     * @throws IllegalArgumentException if the path is null, not absolute, or has an empty
     *                                  segment. All three are caller bugs.
     */
    static List<String> segments(String path) {
        if (path == null) {
            throw new IllegalArgumentException("path must not be null");
        }
        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
        }
        String trimmed = path;
        if (trimmed.length() > 1 && trimmed.endsWith("/")) {
            trimmed = trimmed.substring(0, trimmed.length() - 1);
        }
        if (trimmed.equals("/")) {
            return List.of();
        }
        String[] parts = trimmed.substring(1).split("/", -1);
        for (String part : parts) {
            if (part.isEmpty()) {
                throw new IllegalArgumentException("malformed path (empty segment): " + path);
            }
        }
        return List.of(parts);
    }

    /** The '/'-joined path naming this list of segments; the empty list is the root. */
    static String join(List<String> segments) {
        return segments.isEmpty() ? "/" : "/" + String.join("/", segments);
    }

    private PathSyntax() {}
}

worked/src/TinyFileSystem.java146 lines

// TinyFileSystem.java
//
// Four of the eight operations in corpus/file-system/contract/FileSystemApi.java, over a flat
// map instead of a node tree. The storage is not the lesson; which exception comes out of
// which condition, in which order, is.
//
// Three failure kinds, and each one is a deliberate choice about who made the mistake:
//
//   IllegalArgumentException  the caller handed over a string that could never have named
//                             anything. Unchecked, and outside the FileSystemException
//                             hierarchy, because nothing about the tree's contents is relevant.
//   NoSuchPathException       the path was legal and the tree did not contain it.
//   NotADirectoryException    the path was legal and the tree contained a file where the walk
//                             needed a directory.
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

public final class TinyFileSystem {

    private final Map<String, String> files = new HashMap<>();
    private final Set<String> directories = new HashSet<>();

    public TinyFileSystem() {
        directories.add("/");
    }

    /** Recursive, like mkdir -p. A file partway along the path is NotADirectoryException. */
    public void mkdir(String path) {
        List<String> segments = PathSyntax.segments(path);
        List<String> walked = new ArrayList<>();
        for (String segment : segments) {
            walked.add(segment);
            String prefix = PathSyntax.join(walked);
            if (files.containsKey(prefix)) {
                throw new NotADirectoryException(prefix);
            }
            directories.add(prefix);
        }
    }

    /** Replaces the content. The immediate parent must already be a directory. */
    public void write(String path, String content) {
        List<String> segments = PathSyntax.segments(path);
        if (segments.isEmpty()) {
            throw new IllegalArgumentException("the root cannot hold content");
        }
        String parent = PathSyntax.join(segments.subList(0, segments.size() - 1));
        requireDirectory(parent);
        files.put(PathSyntax.join(segments), content);
    }

    /**
     * The content stored at this path.
     *
     * <p>The order of the checks below is the contract, not an accident. A malformed path is
     * rejected before the tree is consulted at all; a file blocking the walk is reported as
     * NotADirectoryException naming that segment, not as NoSuchPathException naming the whole
     * path. When two conditions hold at once, the first one checked is the one the caller sees.
     */
    public String read(String path) {
        List<String> segments = PathSyntax.segments(path);
        requireWalkable(segments);
        String content = files.get(PathSyntax.join(segments));
        if (content == null) {
            throw new NoSuchPathException(path);
        }
        return content;
    }

    /** The immediate child names of a directory, sorted. */
    public List<String> ls(String path) {
        List<String> segments = PathSyntax.segments(path);
        requireWalkable(segments);
        String dir = PathSyntax.join(segments);
        if (files.containsKey(dir)) {
            throw new NotADirectoryException(dir);
        }
        if (!directories.contains(dir)) {
            throw new NoSuchPathException(path);
        }
        String prefix = dir.equals("/") ? "/" : dir + "/";
        Set<String> names = new TreeSet<>();
        for (String candidate : allPaths()) {
            if (candidate.startsWith(prefix) && candidate.length() > prefix.length()) {
                String rest = candidate.substring(prefix.length());
                int slash = rest.indexOf('/');
                names.add(slash < 0 ? rest : rest.substring(0, slash));
            }
        }
        return new ArrayList<>(names);
    }

    /**
     * Yes or no, and never a throw for "not there" — a missing path is the answer to this
     * question rather than a failure of it.
     *
     * <p>The catch is FileSystemException, not Exception. A malformed path is still a caller
     * bug even when the caller only wanted to know whether something exists, so
     * IllegalArgumentException is deliberately left to escape.
     */
    public boolean exists(String path) {
        try {
            read(path);
            return true;
        } catch (FileSystemException notThere) {
            return directories.contains(PathSyntax.join(PathSyntax.segments(path)));
        }
    }

    /** Every file path, sorted, so an export is reproducible. */
    public List<String> paths() {
        List<String> out = new ArrayList<>(files.keySet());
        Collections.sort(out);
        return out;
    }

    private void requireDirectory(String path) {
        if (files.containsKey(path)) {
            throw new NotADirectoryException(path);
        }
        if (!directories.contains(path)) {
            throw new NoSuchPathException(path);
        }
    }

    /** Every segment but the last has to be a directory that exists. */
    private void requireWalkable(List<String> segments) {
        List<String> walked = new ArrayList<>();
        for (int i = 0; i < segments.size() - 1; i++) {
            walked.add(segments.get(i));
            requireDirectory(PathSyntax.join(walked));
        }
    }

    private Set<String> allPaths() {
        Set<String> out = new HashSet<>(files.keySet());
        out.addAll(directories);
        return out;
    }
}

worked/src/Main.java184 lines

// Main.java
//
// Seven demonstrations. Every line of output in NOTES.md came from running this file
// unedited; nothing here is illustrative.
import java.util.ArrayList;
import java.util.List;

public final class Main {

    public static void main(String[] args) {
        TinyFileSystem fs = new TinyFileSystem();
        fs.mkdir("/notes/2026");
        fs.write("/notes/todo.txt", "renew passport");
        fs.write("/notes/2026/plan.txt", "ship the trainer");

        whichFailureWins(fs);
        branchingOnTheType(fs);
        happyPathAndClosingOrder(fs);
        theResourceThatNeverCloses(fs);
        suppressedException(fs);
        finallyDiscardsTheException();
        finallyRunsAfterReturn();
    }

    /** Three reads, three different exceptions, and the order of the checks decides which. */
    private static void whichFailureWins(TinyFileSystem fs) {
        System.out.println("== 1. which condition wins when several apply ==");

        // Malformed AND missing. Syntax is checked first, so the tree is never consulted.
        report(() -> fs.read("/notes//missing.txt"));

        // A file blocks the walk AND the leaf is missing. The blocked segment is named.
        report(() -> fs.read("/notes/todo.txt/deeper.txt"));

        // Legal path, walkable parent, nothing at the leaf.
        report(() -> fs.read("/notes/absent.txt"));
        System.out.println();
    }

    /**
     * What a caller does with the two kinds. A domain failure is branched on; a caller bug is
     * not caught at all, because there is no code that would fix it.
     */
    private static void branchingOnTheType(TinyFileSystem fs) {
        System.out.println("== 2. the caller branches on which domain failure it was ==");
        for (String path : List.of("/notes/todo.txt", "/notes/absent.txt", "/notes/todo.txt/x")) {
            try {
                System.out.println("  read " + path + " -> " + fs.read(path));
            } catch (NoSuchPathException missing) {
                System.out.println("  read " + path + " -> offer to create it at " + missing.path());
            } catch (NotADirectoryException blocked) {
                System.out.println("  read " + path + " -> " + blocked.path() + " is a file; stop walking");
            }
        }
        System.out.println("  exists(\"/notes/absent.txt\") -> " + fs.exists("/notes/absent.txt")
                + "   (the caller who cannot handle a throw asks first)");
        System.out.println();
    }

    /** Two resources in one header. Both close, in reverse declaration order. */
    private static void happyPathAndClosingOrder(TinyFileSystem fs) {
        System.out.println("== 3. try-with-resources: both close, in reverse order ==");
        StringBuilder sink = new StringBuilder();
        try (ExportSession outer = ExportSession.open("all", sink, false);
             ExportSession inner = ExportSession.open("notes", sink, false)) {
            outer.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
            inner.entry("/notes/2026/plan.txt", fs.read("/notes/2026/plan.txt"));
        } catch (ExportFailedException e) {
            System.out.println("  export failed: " + e.getMessage());
        }
        System.out.print(indent(sink.toString()));
        System.out.println();
    }

    /**
     * The same code with the session created one line above the try. It compiles, javac says
     * nothing, and close() never runs.
     */
    private static void theResourceThatNeverCloses(TinyFileSystem fs) {
        System.out.println("== 4. the resource declared outside the parentheses ==");
        StringBuilder sink = new StringBuilder();
        ExportSession session = ExportSession.open("leaked", sink, false);
        try {
            session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
        } catch (NoSuchPathException missing) {
            System.out.println("  " + missing.getMessage());
        }
        System.out.println("  session.isClosed() -> " + session.isClosed());
        System.out.print(indent(sink.toString()));
        System.out.println("  no END EXPORT line, so nothing may read this export");
        System.out.println();
    }

    /** The body throws, close() also throws, and only one of them can be the exception. */
    private static void suppressedException(TinyFileSystem fs) {
        System.out.println("== 5. the body's exception wins; close()'s is attached ==");
        StringBuilder sink = new StringBuilder();
        try (ExportSession session = ExportSession.open("nightly", sink, true)) {
            session.entry("/notes/todo.txt", fs.read("/notes/todo.txt"));
            session.entry("/notes/absent.txt", fs.read("/notes/absent.txt"));
        } catch (ExportFailedException | FileSystemException e) {
            System.out.println("  caught: " + e.getClass().getSimpleName());
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("  suppressed: " + suppressed.getClass().getSimpleName()
                        + ": " + suppressed.getMessage());
                System.out.println("  its cause: " + suppressed.getCause());
            }
            System.out.println("  --- printStackTrace() ---");
            e.printStackTrace(System.out);
        }
        System.out.print(indent(sink.toString()));
        System.out.println();
    }

    /** A return inside finally throws the in-flight exception away. */
    private static void finallyDiscardsTheException() {
        System.out.println("== 6. a return inside finally discards the exception ==");
        System.out.println("  swallow() returned: " + swallow());
        System.out.println("  the NoSuchPathException it threw is gone; nothing recorded it");
        System.out.println();
    }

    private static String swallow() {
        try {
            throw new NoSuchPathException("/notes/absent.txt");
        } finally {
            return "looked fine to me";
        }
    }

    /** finally runs after the return expression is evaluated, and cannot change its value. */
    private static void finallyRunsAfterReturn() {
        System.out.println("== 7. finally runs even after return, and cannot alter it ==");
        List<String> log = new ArrayList<>();
        String returned = returnsThenFinally(log);
        log.forEach(line -> System.out.println("  " + line));
        System.out.println("  returned: " + returned);
        System.out.println("  entriesWritten() returned: " + entriesWritten()
                + "   (finally set the local to 99 after the value was taken)");
    }

    private static String returnsThenFinally(List<String> log) {
        try {
            log.add("body: the return expression is evaluated here");
            return "the body's value";
        } finally {
            log.add("finally: runs now, after that, before the caller is resumed");
        }
    }

    private static int entriesWritten() {
        int written = 0;
        try {
            return written;
        } finally {
            written = 99;
        }
    }

    /** Runs a read and prints the exception's simple name and message, or the content. */
    private static void report(Read read) {
        try {
            System.out.println("  -> " + read.run());
        } catch (RuntimeException e) {
            System.out.println("  " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }

    private interface Read {
        String run();
    }

    private static String indent(String block) {
        StringBuilder out = new StringBuilder();
        for (String line : block.split("\n", -1)) {
            if (!line.isEmpty()) {
                out.append("  | ").append(line).append('\n');
            }
        }
        return out.toString();
    }

    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.

← J8 · Generics vs templates — erasure, wildcards, and no List<int> J10 · Enums as real classes — constants that carry state and behaviour →

← all lessons