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.

Step 1 · Write the failing tests

Add these three to LibraryTest. Each asserts the exception type and its message — the message is where the two conflated cases get pulled apart, so type alone is not enough.

First the imports at the top of the file:

import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;

Then the tests:

@Test
@DisplayName("refuses to borrow a book it does not have")
void refusesToBorrowAnUnknownBook() {
    BookNotFoundException thrown = assertThrows(BookNotFoundException.class,
            () -> library.borrowBook("Gone With The Wind"));
    assertEquals("No book titled 'Gone With The Wind'", thrown.getMessage());
}

@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());
}

@Test
@DisplayName("refuses to return a book that was never borrowed")
void refusesToReturnABookNeverBorrowed() throws Exception {
    library.addBook(new Book("Dune", "Frank Herbert"));

    NotBorrowedException thrown = assertThrows(NotBorrowedException.class,
            () -> library.returnBook("Dune"));
    assertEquals("'Dune' is not currently borrowed", thrown.getMessage());
}
Use throws Exception, not throws IOException

It is natural to write throws IOException, because that is what borrowBook declares today. But you are about to change that, and the moment you do the test stops compiling:

LibraryTest.java:[57,27] unreported exception
  com.solirius.intermediate.library.exceptions.BookNotFoundException;
  must be caught or declared to be thrown

throws Exception on a test method is fine — JUnit catches whatever comes out, and a test signature is not an API anyone depends on. It saves you editing the clause twice.

Step 2 · Run it and read the red

cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn clean test
[ERROR] Tests run: 16, Failures: 3, Errors: 0, Skipped: 0

LibraryTest.refusesToBorrowAnUnknownBook
  Unexpected exception type thrown,
  expected: <...exceptions.BookNotFoundException> but was: <java.io.IOException>
LibraryTest.refusesToBorrowABookAlreadyOut
  Unexpected exception type thrown,
  expected: <...exceptions.AlreadyBorrowedException> but was: <java.io.IOException>
LibraryTest.refusesToReturnABookNeverBorrowed
  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 reds that do not count: a compile error in an unrelated file, a NullPointerException from a fixture you forgot, 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.

Step 3 · Add the common supertype

Create src/main/java/com/solirius/intermediate/library/exceptions/LibraryException.java:

package com.solirius.intermediate.library.exceptions;

/**
 * Base type for every failure that is meaningful in the library domain.
 * <p>
 * A common supertype lets a user interface catch and report all domain
 * failures uniformly, while code that needs to tell them apart can still
 * catch the specific subtype.
 * </p>
 */
public class LibraryException extends Exception {
    /**
     * Constructs a new LibraryException with the specified message.
     *
     * @param message the detail message
     */
    public LibraryException(final String message) {
        super(message);
    }
}

Step 4 · Point the three supplied exceptions at it

In each of AlreadyBorrowedException.java, BookNotFoundException.java and NotBorrowedException.java, change one word — Exception becomes LibraryException:

public class AlreadyBorrowedException extends Exception {      // before
public class AlreadyBorrowedException extends LibraryException {  // after

Nothing else in those three files changes.

Step 5 · Rewrite the three methods in Library

First the imports:

import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;

import java.util.Optional;

Replace searchBook with this — it returns an Optional, and gains a private helper that both other methods will use:

    /**
     * Searches for a book by its title.
     *
     * @param title the title of the book to search for
     * @return the book if present, otherwise an empty optional
     */
    public Optional<Book> searchBook(final String title) {
        for (Book book : books) {
            if (book.getTitle().equalsIgnoreCase(title)) {
                return Optional.of(book);
            }
        }
        return Optional.empty();
    }

    /**
     * Finds a book by title or fails, so callers that require the book to
     * exist do not each repeat the same absence check.
     *
     * @param title the title of the book required
     * @return the book
     * @throws BookNotFoundException if no book has that title
     */
    private Book requireBook(final String title) throws BookNotFoundException {
        return searchBook(title).orElseThrow(() ->
                new BookNotFoundException("No book titled '" + title + "'"));
    }

Replace borrowBook:

    /**
     * Borrows a book by its title.
     *
     * @param title the title of the book to borrow
     * @throws BookNotFoundException    if no book has that title
     * @throws AlreadyBorrowedException if the book is already on loan
     */
    public void borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = requireBook(title);
        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }
    }

Replace returnBook:

    /**
     * Returns a book by its title.
     *
     * @param title the title of the book to return
     * @throws BookNotFoundException if no book has that title
     * @throws NotBorrowedException  if the book is not currently on loan
     */
    public void returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = requireBook(title);
        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }
    }
What changed, in three sentences

Step 5b · Fix the javadoc you just invalidated

Easy to miss, and Checkstyle’s Sun rules — which this pom runs — will flag it. The comments above those three methods still describe the old signatures:

// borrowBook - it is void now, so there is no return value to document
 * @return true if the book is successfully borrowed, otherwise false
 * @throws BookNotFoundException    if no book has that title
 * @throws AlreadyBorrowedException if the book is already on loan

// returnBook
 * @return true if the book is successfully returned, otherwise false
 * @throws BookNotFoundException if no book has that title
 * @throws NotBorrowedException  if the book is not currently on loan

// searchBook - it returns an Optional now, never null
 * @param title the title of the book to search
 * @return the book if found, otherwise null
 * @param title the title of the book to search for
 * @return the book if present, otherwise an empty optional
Why this is worth two minutes

A javadoc that contradicts its own signature is worse than no javadoc: it tells a reader something false with apparent authority. It is also exactly the kind of detail that separates “made it compile” from “finished the change”, and the brief marks code readability and organisation explicitly.

Step 6 · Fix the three places Main now breaks

Compile and you will get this:

Main.java:[97,56] incompatible types: java.util.Optional<Book>
  cannot be converted to Book

Add the imports:

import com.solirius.intermediate.library.exceptions.LibraryException;

import java.util.Optional;

6a. The search case — searchBook now returns an Optional, so the null check becomes a map/orElse:

// before
Book foundBook = library.searchBook(title);
System.out.println(foundBook != null
        ? foundBook : "Book not found.");

// after
Optional<Book> foundBook = library.searchBook(title);
System.out.println(foundBook
        .map(Object::toString)
        .orElse("Book not found."));

6b. The borrow case — this is the block that currently kills the application on a typo:

// before
try {
    library.borrowBook(title);
    System.out.println("Book borrowed successfully!");
} catch (IOException e) {
    throw new RuntimeException(e);
}

// after
try {
    library.borrowBook(title);
    System.out.println("Book borrowed successfully!");
} catch (LibraryException e) {
    System.out.println(e.getMessage());
}

6c. The return case — identical change:

} catch (LibraryException e) {
    System.out.println(e.getMessage());
}
This is the supertype earning its keep

One catch (LibraryException e) handles both BookNotFoundException and AlreadyBorrowedException. Without the supertype you would need two identical catch blocks per call site, and a third the day someone adds a fourth exception.

And notice what the change actually does: throw new RuntimeException(e) becomes println(e.getMessage()). A mistyped title stops being fatal. That single line is the difference between losing the user’s whole session and printing one helpful sentence.

Step 7 · Fix the two Lesson 0003 tests that now fail

You will hit this one

If you skip straight to running the suite you will see:

LibraryTest.returnsNullWhenSearchingForAnAbsentBook:51
  expected: <null> but was: <Optional.empty>

That is not a bug in your change — it is the Lesson 0003 test doing its job. It characterised searchBook returning null, and you have just deliberately changed that.

searchBook returns an Optional now, so the assertions you wrote in Lesson 0003 must change. This is expected — you characterised behaviour you were about to alter.

// matchesATitleRegardlessOfCase
assertNotNull(library.searchBook("dune"));
assertTrue(library.searchBook("dune").isPresent());

// rename the test too - it no longer describes null
@DisplayName("returns null when searching for a book it does not have")
void returnsNullWhenSearchingForAnAbsentBook() {
    assertNull(library.searchBook("Gone With The Wind"));
}
@DisplayName("returns an empty optional when searching for a book it does not have")
void returnsEmptyWhenSearchingForAnAbsentBook() {
    assertTrue(library.searchBook("Gone With The Wind").isEmpty());
}

Remove the now-unused import java.io.IOException; from LibraryTest if your IDE flags it.

Step 8 · Green — except the golden master, deliberately

Run clean, or you will chase a ghost

Use mvn clean test here, not mvn test. You have just changed a method signature and an exception type, and that is precisely the case where incremental compilation leaves a stale .class behind.

The symptom is baffling if you do not know it. The test still fails with the old exception:

Expected :class ...exceptions.AlreadyBorrowedException
Actual   :class java.io.IOException
Caused by: java.io.IOException: Book not found: Dune
    at com.solirius.intermediate.library.Library.borrowBook(Library.java:75)

Check the line number against your source. If line 75 of your Library.java is a javadoc comment — or anything that plainly cannot throw — you are not looking at the code you just wrote. You are looking at a class file compiled before your edit.

mvn clean test

In IntelliJ the equivalents are Build → Rebuild Project, and — whenever pom.xml changes — right-click pom.xml → Maven → Reload Project. When IntelliJ and Maven disagree, believe Maven: Maven is what the assessor will run.

mvn clean test
Tests run: 5 ... in A library
Tests run: 7 ... in A book
[ERROR] GoldenMasterTest.behavesAsApproved » Failed Approval
[ERROR] Tests run: 16, Failures: 0, Errors: 1, Skipped: 0

Every unit test passes and only the golden master is red. That is exactly right: you changed what the user sees, so the recorded transcript no longer matches. Look at what moved before you approve anything:

D=src/test/java/com/solirius/intermediate/library
diff -u $D/GoldenMasterTest.behavesAsApproved.approved.txt \
        $D/GoldenMasterTest.behavesAsApproved.received.txt

The entire diff, menus elided — three hunks, nothing else:

-!! java.lang.RuntimeException: java.io.IOException: Book not found: Dune
+Enter the title of the book to borrow: 'Dune' is already borrowed
+Saving library state...
+Thank you for using the Library Management System!

-!! java.lang.RuntimeException: java.io.IOException: Book not found: Gone With The Wind
+Enter the title of the book to borrow: No book titled 'Gone With The Wind'

-!! java.lang.RuntimeException: java.io.IOException: Book not found or not borrowed: Dune
+Enter the title of the book to return: 'Dune' is not currently borrowed
Look at what the + lines include

Saving library state... and Thank you for using... now appear in all three. Previously the application died before reaching them, so everything the user had done that session was discarded. Fixing the exception types did not just improve a message — it stopped three ways of losing data.

Only now, approve it:

mv -f $D/GoldenMasterTest.behavesAsApproved.received.txt \
      $D/GoldenMasterTest.behavesAsApproved.approved.txt
mvn clean test
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Step 9 · Commit

cd ~/Desktop/java-guild-candidate-test-intermediate-main
mvn clean test    # must be green

git add src/main/java/com/solirius/intermediate/library/exceptions/
git add src/main/java/com/solirius/intermediate/library/Library.java
git add src/main/java/com/solirius/intermediate/library/Main.java
git add src/test/java/com/solirius/intermediate/library/LibraryTest.java
git add src/test/java/com/solirius/intermediate/library/GoldenMasterTest.behavesAsApproved.approved.txt

git commit -F - <<'EOF'
fix: 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
referenced by nothing, and adds LibraryException as a common supertype
so the console can report any domain failure uniformly.

They remain checked exceptions: the caller has a real recovery, which
is to report the message and continue.

searchBook returns Optional rather than null, which also gives
borrowBook and returnBook a shared requireBook helper via orElseThrow.

The boolean return values are dropped. With failure signalled by an
exception both methods could only ever return true. This deviates from
the signatures in the brief and is recorded in the README.

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

git log --oneline

The design decisions, and how to defend them

Getting to green was mechanical. The marks are in four choices you just made, each of which an interviewer can push on.

1. Checked or unchecked?

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. The common supertype

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. Optional instead of null

Know the limits 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.

4. Deviating from the brief — declare it

Say so, do not smuggle it

The README specifies boolean borrowBook(String title) and Book searchBook(String title). You changed both. That is defensible and probably wanted, but it must be declared. Put this 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. searchBook returns Optional<Book> rather than a nullable Book.

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.

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.

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.