Java Guild course · Lesson 0004

Lesson 0004 · the first real red bar

Exceptions That Mean Something

Three unused classes, three required scenarios, and the highest-value commit in the whole submission.

This is the lesson where TDD actually starts. Everything so far has characterised behaviour that existed. Now you are going to write a test for behaviour that does not exist, watch it fail for the right reason, and make it pass.

Write the failing test first

Task · write this before touching Library

Add to LibraryTest. Note that each test asserts the exception type and its message — the message is where the two conflated cases get pulled apart.

@Test
@DisplayName("refuses to borrow a book that is already out")
void refusesToBorrowABookAlreadyOut() throws Exception {
    library.addBook(new Book("Dune", "Frank Herbert"));
    library.borrowBook("Dune");

    AlreadyBorrowedException thrown = assertThrows(AlreadyBorrowedException.class,
            () -> library.borrowBook("Dune"));
    assertEquals("'Dune' is already borrowed", thrown.getMessage());
}

Write the sibling tests for BookNotFoundException (borrowing something absent) and NotBorrowedException (returning something never borrowed). Then run mvn test.

The red bar, and why it is the right one

[ERROR] Tests run: 10, Failures: 3, Errors: 0, Skipped: 0

LibraryTest.refusesToBorrowABookAlreadyOut:31
  Unexpected exception type thrown,
  expected: <...exceptions.AlreadyBorrowedException> but was: <java.io.IOException>
LibraryTest.refusesToBorrowAnUnknownBook:20
  Unexpected exception type thrown,
  expected: <...exceptions.BookNotFoundException> but was: <java.io.IOException>
LibraryTest.refusesToReturnABookNeverBorrowed:41
  Unexpected exception type thrown,
  expected: <...exceptions.NotBorrowedException> but was: <java.io.IOException>
Read the failure, not just the colour

“expected AlreadyBorrowedException but was IOException” is a red bar failing for exactly the reason you intended. It is pointing at the design defect, in words, in the build output.

Compare with the red bars that do not count: a compile error in an unrelated file, a NullPointerException from a fixture you forgot to set up, or a test that fails on an assertion you did not mean to make. Red is not the goal — red for the stated reason is.

Now the design decisions

Getting to green is ten minutes of work. The marks are in three choices you make on the way, each of which an interviewer can push on.

1. Checked or unchecked?

The supplied classes extends Exception — checked. You may change that. Should you?

Checked (extends Exception)Unchecked (extends RuntimeException)
ForThe caller can recover — the console prints a message and carries on. Bloch Item 70: use checked for recoverable conditions. It is also what you were handed.No throws clauses cluttering signatures. Arguably these are avoidable by asking first.
Againstthrows propagates up through every caller.“Avoidable by asking first” is false in general — between the check and the borrow, another thread could take the book.
The call, and how to say it

Keep them checked. These are recoverable conditions with an obvious recovery, which is Oracle's own stated rule of thumb. And the classes were handed to you as checked exceptions — changing that is a claim that needs a reason, and there isn't one here.

In interview: “Checked, because the caller has a real recovery — the menu prints the message and loops. If this were a library API used by code that couldn't do anything useful about it, I'd reconsider.”

2. Give them a common supertype

borrowBook can now throw two different exceptions. returnBook, two more. The console's response to all of them is identical: print the message, keep going. Without a supertype:

try {
    library.borrowBook(title);
} catch (BookNotFoundException e) {
    out.println(e.getMessage());
} catch (AlreadyBorrowedException e) {
    out.println(e.getMessage());   // ...identical. And it grows.
}

So introduce LibraryException extends Exception and have the three extend it. The console catches one type; code that needs to distinguish them still can.

} catch (LibraryException e) {
    out.println(e.getMessage());
}
Know the counterargument

A common supertype makes it easy to catch too broadly and lose the distinction you just built. If some caller genuinely needs to treat “absent” differently from “on loan”, a blanket catch (LibraryException) hides that. The defence: the subtypes still exist and are still thrown, so any caller that needs the distinction can have it. You have added an option, not removed one.

3. Delete the dead boolean

Once the method throws on failure, boolean is unreachable — it can only ever return true. Two ways to report one outcome is one too many.

public boolean borrowBook(String title) throws IOException
public void    borrowBook(String title)
        throws BookNotFoundException, AlreadyBorrowedException

The signature now is the documentation: this either works, or it tells you precisely which of two things went wrong.

This deviates from the brief — say so

The README specifies boolean borrowBook(String title). You are changing the return type. That is defensible and probably wanted, but it must be declared, not smuggled. Put a short “Deviations from the brief” section in your README:

borrowBook/returnBook return void rather than boolean. With the required exception handling in place the boolean is unreachable — the methods can only return true — so it would be a return value no caller could act on.

An assessor who sees a deviation with a reason reads judgement. One who sees a deviation with no reason reads carelessness. Same diff, opposite marks.

4. While you are here: kill the null

searchBook returns null when nothing matches, and the menu duly does foundBook != null ? …. Return Optional<Book> instead:

public Optional<Book> searchBook(final String title) { ... }

// and the absence check becomes impossible to forget:
out.println(library.searchBook(title).map(Object::toString).orElse("Book not found."));

It also gives you a clean way to remove the duplicated “is it there?” check from both borrowBook and returnBook:

private Book requireBook(final String title) throws BookNotFoundException {
    return searchBook(title).orElseThrow(() ->
            new BookNotFoundException("No book titled '" + title + "'"));
}
Know the limits of Optional too

The javadoc's own API Note says Optional is intended for return types where absence must be handled. Not fields, not parameters, not collections — an empty List already expresses emptiness. If you say “Optional everywhere” in an interview you will be corrected; say “Optional on a return type where absence is a normal outcome” and you will not.

Green

[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

And your golden master from Lesson 0002 now fails — correctly. Diff it. You should see three stack traces become three sentences:

-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed

Re-approve it in the same commit as the fix, so the diff and its cause travel together.

Drill

Why is assertThrows preferable to a try/fail/catch block?

  1. It runs measurably faster because no stack trace needs to be filled in
  2. It is the only construct able to assert on a checked exception in JUnit
  3. It returns the exception, so type and message are asserted separately
  4. It automatically fails any test where no exception at all gets thrown

The stack trace is filled in either way. Speed is not a consideration in a test that runs in two milliseconds.

The old try/fail/catch idiom handles checked exceptions perfectly well. It is just verbose and easy to get wrong.

This is the practical win. assertThrows hands back the thrown exception, so you can go on to assert the message — which is what separates “it threw something” from “it correctly distinguished already borrowed from not found”. That distinction is the entire defect.

True, and a real advantage over a hand-written try/catch that forgets its fail() — but it is not the reason to reach for it here.

What is the most precise criticism of throw new IOException("Book not found: " + title) when the book is present but on loan?

  1. The exception type belongs to java.io and so implies a filesystem access
  2. The message asserts something that is false, so it misdirects the reader
  3. The string concatenation should be deferred until the message is requested
  4. Checked exceptions oblige every caller to write handling code to compile it

True, and a real defect — but it is the criticism of the type. The question asks about this specific case, where something worse is happening.

This is the sharpest version. The type is merely wrong; the message is actively false. It says the library has no such book while holding it. Someone debugging a support ticket will search the catalogue, find Dune, and conclude the logs are unreliable. Wrong types cost minutes; lying messages cost hours.

A micro-optimisation with no bearing on correctness, and irrelevant on an exception path that runs once per user mistake.

That is a property of every checked exception including the ones you are replacing it with, so it cannot be the criticism.

You change borrowBook from boolean to void. The brief specified boolean. What is the right move?

  1. Revert it, because a stated signature in the brief is a hard requirement
  2. Keep both, adding an overload so that either calling style keeps working
  3. Change it silently, since the reasoning is evident from reading the code
  4. Change it and record the deviation and its reason in the project README

The brief says “visibility at candidate discretion” throughout and marks “proper use of OOP principles”. It is inviting judgement, not compliance. A boolean no caller can act on is worse code.

Two ways to do one thing, one of which is dead. This doubles the API surface to avoid a sentence in a README.

The reasoning is evident to you. To an assessor with forty submissions and a checklist, an unexplained deviation from a stated signature is indistinguishable from not having read the brief.

Right. The deviation is good; the silence would be the mistake. One sentence converts “did not follow the spec” into “read the spec, disagreed for a stated reason” — and the second is what “proper use of OOP principles” is asking to see.

Commit it

Replace IOException with the supplied domain exceptions

borrowBook and returnBook signalled every failure with IOException,
although neither performs any I/O, and collapsed two distinct cases
into one branch: borrowing an already-borrowed book reported
"Book not found" for a book the library was holding.

Wires up AlreadyBorrowedException, BookNotFoundException and
NotBorrowedException, which were present in the repository but
unreferenced, and adds LibraryException as a common supertype so the
console can report any domain failure uniformly.

The boolean return values are dropped: with failure signalled by an
exception, both methods could only ever return true.

Golden master re-approved in this commit: three stack traces become
three messages, and the application now survives to save its state.

Read this

Primary source

Oracle Java Tutorial — “Unchecked Exceptions: The Controversy”. Two pages. It ends with the rule of thumb you should be able to quote: “If a client can reasonably be expected to recover from an exception, make it a checked exception.” Being able to attribute that to Oracle rather than to your own taste is what makes the answer land.

Then the Optional javadoc, class-level text only — especially the API Note on intended use.

Carry on

Push back on any of these calls if you disagree — especially checked-versus-unchecked. An interviewer may well argue the other side to see whether you fold, and rehearsing that argument with me is cheaper than discovering your position is thin on the day.