Java Guild course · The Rebuild · Phase 3

Phase 3 · commits 12–19

Exceptions and the domain API

The headline requirement of the Intermediate tier. Three supplied exception classes that nothing referenced get wired in one case at a time, and the two signatures that stop making sense once they are get changed deliberately.

How to use this page

Every commit below is a numbered list of edits, then a verification, then the commit. mvn means ~/.local/maven/bin/mvn with JAVA_HOME set — see the setup block. Where a step says find this / replace with this, the “find” block is the code exactly as it stands after the previous commit.

12 · report a failed borrow or return instead of crashing

fix · b65550c

Why

Main caught the IOException from borrowBook and returnBook and rethrew it as an unchecked RuntimeException, terminating the application. A user who asked for a book the library did not have lost the whole session, including anything added before the mistake, because state is written only on a clean exit.

Step 1. In src/main/java/com/solirius/intermediate/library/Main.java, find:

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

                case RETURN_BOOK_OPTION:

and replace it with:

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

                case RETURN_BOOK_OPTION:

Step 2. In src/main/java/com/solirius/intermediate/library/Main.java, find:

                    try {
                        library.returnBook(title);
                        System.out.println("Book returned successfully!");
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                    break;

                case EXIT_OPTION:

and replace it with:

                    try {
                        library.returnBook(title);
                        System.out.println("Book returned successfully!");
                    } catch (IOException e) {
                        System.out.println(e.getMessage());
                    }
                    break;

                case EXIT_OPTION:

Step 3. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:

mvn clean test          # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
     GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
   GoldenMasterTest.behavesAsApproved.approved.txt
cd -

Step 4. Verify:

mvn clean test
# Tests run: 13, Failures: 0, Errors: 0

Step 5. Commit:

git add -A
git commit -F- <<'EOF'
fix: report a failed borrow or return instead of crashing

Main caught the IOException from borrowBook and returnBook and rethrew
it as an unchecked RuntimeException, terminating the application. A user
who asked for a book the library did not have lost the whole session,
including anything added before the mistake, because state is written
only on a clean exit.

The message is now printed and the loop continues, which is the recovery
the user actually wants.

Golden master re-approved: three of the four recorded crashes become
messages and those sessions now reach "Library state saved
successfully". The remaining crash is the InputMismatchException on
non-numeric input, fixed later.

This deliberately does not touch which exception is thrown. IOException
for a domain rule is wrong, and is dealt with next; this commit is only
about not losing the user's work.
EOF

13 · give the domain exceptions a common supertype

refactor · 3d706a2 · ADR 0002 →

Why

AlreadyBorrowedException, BookNotFoundException and NotBorrowedException were supplied with the project, extend Exception directly, and are referenced by nothing.

Step 1. Create docs/adr/0002-domain-failures-are-checked-exceptions.md:

# 2. Domain failures are checked exceptions, not return codes

Date: 2026-09-02

## Status

Accepted

## Context

The supplied `Library` signals every failure by throwing `java.io.IOException`
from `borrowBook` and `returnBook`, neither of which performs any I/O. It also
returns `boolean` from both. The repository ships `AlreadyBorrowedException`,
`BookNotFoundException` and `NotBorrowedException` — named after exactly the
three cases the Intermediate tier is asked to handle — and nothing references
them.

`IOException` is wrong twice over. It describes a mechanism that is not
involved, and it is a single type for three distinct situations, so the console
cannot tell the user which one happened. The golden master records the result:
asking to borrow a book that is already on loan reports *Book not found* for a
book the library is holding.

## Decision

Use the three supplied exceptions, under a new `LibraryException` supertype.

They stay **checked**. The usual argument against checked exceptions is that
callers cannot do anything useful with them, but here the caller has a real
recovery: print the message and carry on serving the user. Making them checked
puts that recovery in the compiler's hands rather than in a reviewer's.

The supertype exists so the console can catch one type to report any domain
failure uniformly, while a caller that needs to distinguish the cases still can.

## Consequences

- The console handles domain failure in one `catch`, not three.
- Adding a rule later means adding a subclass; existing callers keep compiling.
- `borrowBook` and `returnBook` can no longer usefully return `boolean` — with
  failure signalled by an exception, the only reachable return is `true`. That
  is a deviation from the method signatures in the brief and is recorded in
  ADR 0003.
- Three checked types in `throws` clauses is more ceremony than a single
  unchecked type would be. Accepted deliberately: the ceremony is the point.

Step 2. In src/main/java/com/solirius/intermediate/library/exceptions/AlreadyBorrowedException.java, find:

/**
 * Exception thrown when attempting to borrow a book that is already borrowed.
 */
public class AlreadyBorrowedException extends Exception {
    /**
     * Constructs a new AlreadyBorrowedException with the specified message.
     *
     * @param message the detail message

and replace it with:

/**
 * Exception thrown when attempting to borrow a book that is already borrowed.
 */
public class AlreadyBorrowedException extends LibraryException {
    /**
     * Constructs a new AlreadyBorrowedException with the specified message.
     *
     * @param message the detail message

Step 3. In src/main/java/com/solirius/intermediate/library/exceptions/BookNotFoundException.java, find:

/**
 * Exception thrown when a book is not found in the library.
 */
public class BookNotFoundException extends Exception {
    /**
     * Constructs a new BookNotFoundException with the specified message.
     *
     * @param message the detail message

and replace it with:

/**
 * Exception thrown when a book is not found in the library.
 */
public class BookNotFoundException extends LibraryException {
    /**
     * Constructs a new BookNotFoundException with the specified message.
     *
     * @param message the detail message

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

package com.solirius.intermediate.library.exceptions;

/**
 * Base type for every failure of a library rule.
 *
 * <p>Lets a caller that only wants to report the problem catch one type,
 * while a caller that needs to tell the cases apart still can.
 */
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 5. In src/main/java/com/solirius/intermediate/library/exceptions/NotBorrowedException.java, find:

/**
 * Exception thrown when attempting to return a book that was not borrowed.
 */
public class NotBorrowedException extends Exception {
    /**
     * Constructs a new NotBorrowedException with the specified message.
     *
     * @param message the detail message

and replace it with:

/**
 * Exception thrown when attempting to return a book that was not borrowed.
 */
public class NotBorrowedException extends LibraryException {
    /**
     * Constructs a new NotBorrowedException with the specified message.
     *
     * @param message the detail message

Step 6. Verify:

mvn clean test
# Tests run: 13, Failures: 0, Errors: 0

Step 7. Commit:

git add -A
git commit -F- <<'EOF'
refactor: give the domain exceptions a common supertype

AlreadyBorrowedException, BookNotFoundException and NotBorrowedException
were supplied with the project, extend Exception directly, and are
referenced by nothing.

Adds LibraryException as their common parent so a caller that only wants
to report a failure can catch one type, while a caller that needs to
tell the cases apart still can.

No behaviour change: nothing throws these yet and the golden master is
untouched. Wiring them in is the next three commits, one case at a time.

Decision recorded in docs/adr/0002-domain-failures-are-checked-exceptions.md
EOF

14 · signal a missing book with BookNotFoundException

fix · 8fea906

Why

borrowBook and returnBook threw IOException, although neither performs any I/O. The type described a mechanism that was not involved and gave the console nothing to distinguish one failure from another.

Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:

package com.solirius.intermediate.library;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;

and replace it with:

package com.solirius.intermediate.library;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     *
     * @param title the title of the book to borrow
     * @return true if the book is successfully borrowed, otherwise false
     */
    public boolean borrowBook(final String title) throws IOException {
        Book book = searchBook(title);
        if (book == null || !book.borrowBook()) {
            throw new IOException("Book not found: " + title);
        }
        return true;
    }

and replace it with:

     *
     * @param title the title of the book to borrow
     * @return true if the book is successfully borrowed, otherwise false
     */
    public boolean borrowBook(final String title)
            throws BookNotFoundException {
        Book book = searchBook(title);
        if (book == null || !book.borrowBook()) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        return true;
    }

Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     *
     * @param title the title of the book to return
     * @return true if the book is successfully returned, otherwise false
     */
    public boolean returnBook(final String title) throws IOException {
        Book book = searchBook(title);
        if (book == null || !book.returnBook()) {
            throw new IOException("Book not found or not borrowed: " + title);
        }
        return true;
    }

and replace it with:

     *
     * @param title the title of the book to return
     * @return true if the book is successfully returned, otherwise false
     */
    public boolean returnBook(final String title)
            throws BookNotFoundException {
        Book book = searchBook(title);
        if (book == null || !book.returnBook()) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        return true;
    }

Step 4. In src/main/java/com/solirius/intermediate/library/Main.java, find:

package com.solirius.intermediate.library;

import java.io.IOException;
import java.util.Scanner;

public final class Main {

and replace it with:

package com.solirius.intermediate.library;

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

import java.io.IOException;
import java.util.Scanner;

public final class Main {

Step 5. In src/main/java/com/solirius/intermediate/library/Main.java, find:

                    title = scanner.nextLine();
                    try {
                        library.borrowBook(title);
                        System.out.println("Book borrowed successfully!");
                    } catch (IOException e) {
                        System.out.println(e.getMessage());
                    }
                    break;

and replace it with:

                    title = scanner.nextLine();
                    try {
                        library.borrowBook(title);
                        System.out.println("Book borrowed successfully!");
                    } catch (LibraryException e) {
                        System.out.println(e.getMessage());
                    }
                    break;

Step 6. In src/main/java/com/solirius/intermediate/library/Main.java, find:

                    title = scanner.nextLine();
                    try {
                        library.returnBook(title);
                        System.out.println("Book returned successfully!");
                    } catch (IOException e) {
                        System.out.println(e.getMessage());
                    }
                    break;

and replace it with:

                    title = scanner.nextLine();
                    try {
                        library.returnBook(title);
                        System.out.println("Book returned successfully!");
                    } catch (LibraryException e) {
                        System.out.println(e.getMessage());
                    }
                    break;

Step 7. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

package com.solirius.intermediate.library;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("A library")
class LibraryTest {

and replace it with:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("A library")
class LibraryTest {

Step 8. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

        assertEquals("Frank Herbert", library.searchBook("dune").getAuthor());
        assertEquals("Frank Herbert", library.searchBook("DUNE").getAuthor());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

and replace it with:

        assertEquals("Frank Herbert", library.searchBook("dune").getAuthor());
        assertEquals("Frank Herbert", library.searchBook("DUNE").getAuthor());
    }

    @Test
    @DisplayName("refuses to lend a title it does not have")
    void refusesToLendATitleItDoesNotHave() {
        BookNotFoundException thrown = assertThrows(BookNotFoundException.class,
                () -> library.borrowBook("Gone With The Wind"));

        assertEquals("no book titled 'Gone With The Wind'",
                thrown.getMessage());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

Step 9. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:

mvn clean test          # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
     GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
   GoldenMasterTest.behavesAsApproved.approved.txt
cd -

Step 10. Verify:

mvn clean test
# Tests run: 14, Failures: 0, Errors: 0

Step 11. Commit:

git add -A
git commit -F- <<'EOF'
fix: signal a missing book with BookNotFoundException

borrowBook and returnBook threw IOException, although neither performs
any I/O. The type described a mechanism that was not involved and gave
the console nothing to distinguish one failure from another.

Both now throw BookNotFoundException, which was supplied with the
project and until now referenced by nothing, and Main catches
LibraryException so any domain failure is reported the same way.

Messages change from "Book not found: Dune" to "no book titled 'Dune'",
naming the thing that is missing rather than restating the class name.

The already-borrowed case still reports a missing book. That conflation
is a separate defect and is fixed next; this commit only replaces the
exception type.
EOF

15 · distinguish an already-borrowed book from a missing one

fix · 57beddb

Why

Borrowing a book that was already on loan reported "no book titled 'Dune'" for a book the library was holding, because borrowBook tested "book == null || !book.borrowBook()" in a single branch and could not tell the two cases apart.

Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:

package com.solirius.intermediate.library;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;

and replace it with:

package com.solirius.intermediate.library;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @param title the title of the book to borrow
     * @return true if the book is successfully borrowed, otherwise false
     */
    public boolean borrowBook(final String title)
            throws BookNotFoundException {
        Book book = searchBook(title);
        if (book == null || !book.borrowBook()) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        return true;
    }

    /**

and replace it with:

     * @param title the title of the book to borrow
     * @return true if the book is successfully borrowed, otherwise false
     */
    public boolean borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }
        return true;
    }

    /**

Step 3. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

and replace it with:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

Step 4. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

        assertEquals("no book titled 'Gone With The Wind'",
                thrown.getMessage());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

and replace it with:

        assertEquals("no book titled 'Gone With The Wind'",
                thrown.getMessage());
    }

    @Test
    @DisplayName("refuses to lend a book that is already out")
    void refusesToLendABookThatIsAlreadyOut() 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("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

Step 5. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:

mvn clean test          # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
     GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
   GoldenMasterTest.behavesAsApproved.approved.txt
cd -

Step 6. Verify:

mvn clean test
# Tests run: 15, Failures: 0, Errors: 0

Step 7. Commit:

git add -A
git commit -F- <<'EOF'
fix: distinguish an already-borrowed book from a missing one

Borrowing a book that was already on loan reported "no book titled
'Dune'" for a book the library was holding, because borrowBook tested
"book == null || !book.borrowBook()" in a single branch and could not
tell the two cases apart.

The conditions are now separate and the second raises
AlreadyBorrowedException, the second of the three supplied types.

Golden master: the "borrow the same book twice" journey changes from
"no book titled 'Dune'" to "'Dune' is already borrowed". This is the
defect the baseline was written to expose.
EOF

16 · distinguish a book that was never borrowed from a missing one

fix · 975d077

Why

returnBook had the same conflated branch as borrowBook: returning a book the library holds but never lent reported that no such book existed.

Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:

package com.solirius.intermediate.library;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;

and replace it with:

package com.solirius.intermediate.library;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @param title the title of the book to return
     * @return true if the book is successfully returned, otherwise false
     */
    public boolean returnBook(final String title)
            throws BookNotFoundException {
        Book book = searchBook(title);
        if (book == null || !book.returnBook()) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        return true;
    }

    /**

and replace it with:

     * @param title the title of the book to return
     * @return true if the book is successfully returned, otherwise false
     */
    public boolean returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }
        return true;
    }

    /**

Step 3. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

and replace it with:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

Step 4. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

        assertEquals("'Dune' is already borrowed", thrown.getMessage());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

and replace it with:

        assertEquals("'Dune' is already borrowed", thrown.getMessage());
    }

    @Test
    @DisplayName("refuses to take back a book that was never lent")
    void refusesToTakeBackABookThatWasNeverLent() {
        library.addBook(new Book("Dune", "Frank Herbert"));

        NotBorrowedException thrown = assertThrows(NotBorrowedException.class,
                () -> library.returnBook("Dune"));

        assertEquals("'Dune' is not currently borrowed", thrown.getMessage());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));

Step 5. Re-approve the transcript. Read the diff before you accept it — this is the moment you confirm the behaviour change is the one you intended, and nothing else moved with it:

mvn clean test          # fails: behaviour changed
cd src/test/java/com/solirius/intermediate/library
diff GoldenMasterTest.behavesAsApproved.approved.txt \
     GoldenMasterTest.behavesAsApproved.received.txt
mv GoldenMasterTest.behavesAsApproved.received.txt \
   GoldenMasterTest.behavesAsApproved.approved.txt
cd -

Step 6. Verify:

mvn clean test
# Tests run: 16, Failures: 0, Errors: 0

Step 7. Commit:

git add -A
git commit -F- <<'EOF'
fix: distinguish a book that was never borrowed from a missing one

returnBook had the same conflated branch as borrowBook: returning a book
the library holds but never lent reported that no such book existed.

Raises NotBorrowedException, the last of the three supplied types, which
completes the Intermediate tier's exception-handling requirement:
already borrowed, does not exist, and cannot be returned are now three
distinct, separately reportable outcomes.
EOF

17 · drop the boolean returns no caller can act on

refactor · ce9eca4 · ADR 0003 →

Why

With every failure signalled by an exception, borrowBook and returnBook could only ever return true. The value invited a caller to write

Step 1. Create docs/adr/0003-borrow-and-return-do-not-return-a-boolean.md:

# 3. borrowBook and returnBook return void, not boolean

Date: 2026-09-02

## Status

Accepted. Deviates from the brief.

## Context

The brief specifies:

```java
boolean borrowBook(String title)
boolean returnBook(String title)
```

Once ADR 0002 is in force, every failure leaves these methods by way of an
exception. The only path that reaches a `return` statement is the successful
one, so both methods can only ever return `true`.

A `boolean` no caller can act on is worse than no return value. It invites a
reader to write `if (library.borrowBook(title))` and to believe the `else`
branch is reachable, when the compiler has already guaranteed it is not.

## Decision

Return `void`. Success is the absence of an exception.

`Book.borrowBook` and `Book.returnBook` keep their booleans: at that level the
answer is genuinely two-valued, and `Library` uses it to decide which exception
to raise. The rule is that `Book` reports and `Library` decides how to escalate.

## Consequences

- Callers cannot branch on a value that has only one possible state.
- This deviates from the brief's stated signature, so it is called out in the
  README as a deliberate choice rather than left for a reviewer to find.
- If a caller ever needs to attempt a borrow without an exception on failure,
  that is a new method with a name that says so (`tryBorrow`), not a resurrected
  return code.

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

    /**
     * Borrows a book by its title.
     *
     * @param title the title of the book to borrow
     * @return true if the book is successfully borrowed, otherwise false
     */
    public boolean borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");

and replace it with:

    /**
     * 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 = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");

Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:

        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }
        return true;
    }

    /**
     * Returns a book by its title.
     *
     * @param title the title of the book to return
     * @return true if the book is successfully returned, otherwise false
     */
    public boolean returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");

and replace it with:

        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }
    }

    /**
     * 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 = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");

Step 4. In src/main/java/com/solirius/intermediate/library/Library.java, find:

        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }
        return true;
    }

    /**
     * Saves the state of the library to a file.

and replace it with:

        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }
    }

    /**
     * Saves the state of the library to a file.

Step 5. Verify:

mvn clean test
# Tests run: 16, Failures: 0, Errors: 0

Step 6. Commit:

git add -A
git commit -F- <<'EOF'
refactor: drop the boolean returns no caller can act on

With every failure signalled by an exception, borrowBook and returnBook
could only ever return true. The value invited a caller to write

  if (library.borrowBook(title)) { ... } else { ... }

where the compiler had already proved the else branch unreachable.

Both now return void; success is the absence of an exception. Book keeps
its booleans, where the answer really is two-valued and Library uses it
to choose which exception to raise.

Deviates from the signatures in the brief. Recorded in
docs/adr/0003-borrow-and-return-do-not-return-a-boolean.md and called
out in the README.

No behaviour change: the golden master is untouched by this commit.
EOF

18 · return Optional from searchBook

refactor · d8aec53 · ADR 0004 →

Why

searchBook returned null for an absent title, so every caller had to remember a check the compiler could not enforce. Two of them — borrowBook and returnBook — carried duplicate "book == null" branches to do it.

Step 1. Create docs/adr/0004-searchbook-returns-optional.md:

# 4. searchBook returns Optional, not a nullable Book

Date: 2026-09-02

## Status

Accepted. Deviates from the brief.

## Context

The brief specifies `Book searchBook(String title)`, and the supplied
implementation returns `null` when nothing matches. Its own Javadoc says so:
*"@return the book if found, otherwise null"*.

Every caller therefore has to remember a check the compiler cannot enforce.
The supplied `Main` does remember it. `Library.borrowBook` and
`Library.returnBook` remember it too, in duplicated `book == null` branches.
Nothing stops the next caller forgetting.

## Decision

Return `Optional<Book>`.

Absence becomes part of the type, so the compiler asks the question at every
call site instead of trusting each author to ask it.

## Consequences

- `borrowBook` and `returnBook` express "find it or fail" as `orElseThrow`,
  which is a single expression rather than a branch, and can therefore share
  one private `requireBook` helper.
- The console renders a miss as `.map(Object::toString).orElse("Book not
  found.")`, with no null anywhere.
- Deviates from the brief's stated signature; recorded in the README.
- Tests must assert on the *contents*, not the box: `assertNotNull` on an
  `Optional` can never fail, because `searchBook` never returns `null` now.
  The tests here use `orElseThrow()` and `isEmpty()` so that a broken search
  still fails them.

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Represents a library that holds a collection of books.
 */

and replace it with:

import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * Represents a library that holds a collection of books.
 */

Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:

    /**
     * Searches for a book by its title.
     *
     * @param title the title of the book to search
     * @return the book if found, otherwise null
     */
    public Book searchBook(final String title) {
        for (Book book : books) {
            if (book.getTitle().equalsIgnoreCase(title)) {
                return book;
            }
        }
        return null;
    }

    /**
     * Borrows a book by its title.

and replace it with:

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

    /**
     * Borrows a book by its title.

Step 4. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @throws AlreadyBorrowedException if the book is already on loan
     */
    public void borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }

and replace it with:

     * @throws AlreadyBorrowedException if the book is already on loan
     */
    public void borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = searchBook(title).orElseThrow(() ->
                new BookNotFoundException("no book titled '" + title + "'"));
        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }

Step 5. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @throws NotBorrowedException  if the book is not currently on loan
     */
    public void returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = searchBook(title);
        if (book == null) {
            throw new BookNotFoundException("no book titled '" + title + "'");
        }
        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }

and replace it with:

     * @throws NotBorrowedException  if the book is not currently on loan
     */
    public void returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = searchBook(title).orElseThrow(() ->
                new BookNotFoundException("no book titled '" + title + "'"));
        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }

Step 6. In src/main/java/com/solirius/intermediate/library/Main.java, find:

                case SEARCH_BOOK_OPTION:
                    System.out.print("Enter the title of the book to search: ");
                    title = scanner.nextLine();
                    Book foundBook = library.searchBook(title);
                    System.out.println(foundBook != null
                            ? foundBook : "Book not found.");
                    break;

                case BORROW_BOOK_OPTION:
                    System.out.print("Enter the title of the book to borrow: ");

and replace it with:

                case SEARCH_BOOK_OPTION:
                    System.out.print("Enter the title of the book to search: ");
                    title = scanner.nextLine();
                    System.out.println(library.searchBook(title)
                            .map(Object::toString)
                            .orElse("Book not found."));
                    break;

                case BORROW_BOOK_OPTION:
                    System.out.print("Enter the title of the book to borrow: ");

Step 7. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("A library")

and replace it with:

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("A library")

Step 8. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

    @DisplayName("matches a title regardless of case")
    void matchesATitleRegardlessOfCase() {
        library.addBook(new Book("Dune", "Frank Herbert"));

        assertEquals("Frank Herbert", library.searchBook("dune").getAuthor());
        assertEquals("Frank Herbert", library.searchBook("DUNE").getAuthor());
    }

    @Test
    @DisplayName("refuses to lend a title it does not have")

and replace it with:

    @DisplayName("matches a title regardless of case")
    void matchesATitleRegardlessOfCase() {
        library.addBook(new Book("Dune", "Frank Herbert"));

        assertEquals("Frank Herbert",
                library.searchBook("dune").orElseThrow().getAuthor());
        assertEquals("Frank Herbert",
                library.searchBook("DUNE").orElseThrow().getAuthor());
    }

    @Test
    @DisplayName("refuses to lend a title it does not have")

Step 9. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:

        assertEquals("'Dune' is not currently borrowed", thrown.getMessage());
    }

    @Test
    @DisplayName("returns null for a title it does not have")
    void returnsNullForATitleItDoesNotHave() {
        assertNull(library.searchBook("Gone With The Wind"));
    }
}

and replace it with:

        assertEquals("'Dune' is not currently borrowed", thrown.getMessage());
    }

    @Test
    @DisplayName("finds nothing for a title it does not have")
    void findsNothingForATitleItDoesNotHave() {
        assertTrue(library.searchBook("Gone With The Wind").isEmpty());
    }
}

Step 10. Verify:

mvn clean test
# Tests run: 16, Failures: 0, Errors: 0

Step 11. Commit:

git add -A
git commit -F- <<'EOF'
refactor: return Optional from searchBook

searchBook returned null for an absent title, so every caller had to
remember a check the compiler could not enforce. Two of them — borrowBook
and returnBook — carried duplicate "book == null" branches to do it.

Absence is now part of the return type. The console renders a miss with
map/orElse and the two domain methods use orElseThrow, so there is no
null anywhere in the class.

Note for the tests: assertNotNull on an Optional can never fail, since
searchBook no longer returns null at all. The assertions here go through
orElseThrow() and isEmpty() so a broken search still fails them.

Deviates from the signatures in the brief. Recorded in
docs/adr/0004-searchbook-returns-optional.md.

No behaviour change: the golden master is untouched by this commit.
EOF

19 · derive borrow and return from a shared requireBook

refactor · 9cb6307

Why

Both methods opened with the same orElseThrow and the same message, so the wording of "no book titled" was defined in two places and could drift.

Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @throws AlreadyBorrowedException if the book is already on loan
     */
    public void borrowBook(final String title)
            throws BookNotFoundException, AlreadyBorrowedException {
        Book book = searchBook(title).orElseThrow(() ->
                new BookNotFoundException("no book titled '" + title + "'"));
        if (!book.borrowBook()) {
            throw new AlreadyBorrowedException(
                    "'" + book.getTitle() + "' is already borrowed");
        }

and replace it with:

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

Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:

     * @throws NotBorrowedException  if the book is not currently on loan
     */
    public void returnBook(final String title)
            throws BookNotFoundException, NotBorrowedException {
        Book book = searchBook(title).orElseThrow(() ->
                new BookNotFoundException("no book titled '" + title + "'"));
        if (!book.returnBook()) {
            throw new NotBorrowedException(
                    "'" + book.getTitle() + "' is not currently borrowed");
        }

and replace it with:

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

Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:

                books.add(book);
            }
        }
    }
}

and replace it with:

                books.add(book);
            }
        }
    }

    /**
     * Finds a book by title or fails.
     *
     * @param title the title to look for
     * @return the book the library holds under that title
     * @throws BookNotFoundException if the library holds no such book
     */
    private Book requireBook(final String title) throws BookNotFoundException {
        return searchBook(title).orElseThrow(() ->
                new BookNotFoundException("no book titled '" + title + "'"));
    }
}

Step 4. Verify:

mvn clean test
# Tests run: 16, Failures: 0, Errors: 0

Step 5. Commit:

git add -A
git commit -F- <<'EOF'
refactor: derive borrow and return from a shared requireBook

Both methods opened with the same orElseThrow and the same message, so
the wording of "no book titled" was defined in two places and could
drift.

Extracted to a private requireBook. Each public method is now its own
rule and nothing else: look the book up, then apply the one condition
that distinguishes it.

No behaviour change: the golden master is untouched by this commit.
EOF