Java Guild course · Lesson 0006

Lesson 0006 · design under pressure

Making Main Testable

A 90-line static method with a hardcoded Scanner, a giant switch, and a crash on any keypress that is not a digit.

Main is where most candidates stop, because it works when you type carefully. It is also the class that most directly answers the criterion “proper use of object-oriented programming principles” — and right now it has almost none.

Where you are, and where this ends up

Two things are still wrong with Main, and one of them may surprise you.

Check this first — Lesson 0004 may be half-applied

Run:

grep -n 'RuntimeException' src/main/java/com/solirius/intermediate/library/Main.java

If that prints anything, your borrow and return cases still look like this:

} catch (LibraryException e) {
    throw new RuntimeException(e);
}

You changed the type in the catch but not the handling. The application still dies on a mistyped title — it just dies wrapping a different exception. And your golden master will be green, because it was approved with those crashes recorded as expected behaviour.

Fix that first, as its own commit (Step 1 below). It belongs to Lesson 0004, and folding it into this lesson's refactor would muddy both.

The second problem is the one this lesson is named after: Main is a 150-line static method with System.in and System.out wired into it, so the menu loop has no seam a test can reach. Type a letter at the menu:

printf 'banana\n6\n' | java -cp target/classes com.solirius.intermediate.library.Main
Exception in thread "main" java.util.InputMismatchException
	at java.base/java.util.Scanner.nextInt(Scanner.java:2251)
	at com.solirius.intermediate.library.Main.main(Main.java:75)

One stray keypress kills it, and because state is written only on menu option 6, everything added that session is lost.

The destination

By the end of this lesson Main is 63 lines and does nothing but wiring, and the code sits in a ports-and-adapters layout:

src/main/java/com/solirius/intermediate/library/
│
├── Main.java                         ← composition root: wires adapters to the domain
├── package-info.java
│
├── domain/                           ← THE CORE. knows nothing outside itself
│   ├── Book.java
│   ├── Library.java
│   └── exceptions/
│       ├── LibraryException.java
│       ├── AlreadyBorrowedException.java
│       ├── BookNotFoundException.java
│       ├── NotBorrowedException.java
│       └── LibraryStorageException.java
│
├── port/                             ← what the core NEEDS, as interfaces
│   └── LibraryRepository.java
│
└── adapter/                          ← the outside world, plugged into the ports
    ├── console/                         driving: a human operates the core
    │   ├── LibraryConsole.java
    │   └── MenuOption.java
    └── file/                            driven: the core operates a file
        └── FileLibraryRepository.java
The one rule that makes it hexagonal

Dependencies point inwards. adapter imports domain and port; port imports domain; domain imports nothing of the other two. That is checkable in one command, and worth showing an interviewer:

grep -rn "import com.solirius.intermediate.library.\(adapter\|port\)" \
     src/main/java/com/solirius/intermediate/library/domain/

It must print nothing. The domain does not know that files or terminals exist.

The two adapter sub-packages are the two sides of the hexagon. console is a driving adapter — something outside calls in. file is a driven adapter — the core calls out through a port it defined. Being able to say which is which is most of what “hexagonal” actually means.

Be ready for “isn't this over-engineered for six classes?”

A fair question, and you want an answer rather than a defence:

“For six classes the folder structure is arguably ceremony, and I'd drop it in a smaller codebase. I kept it because the brief's Advanced tier asks for a database store, and this layout makes the answer to ‘where does that go?’ obvious — a second package under adapter, implementing the existing port, with nothing in domain touched. The value isn't the folders, it's that the dependency direction is enforced and checkable.”

Conceding the reasonable half of the objection is what makes the rest credible.


Step 1 · Stop the crash on a mistyped title

Two lines change. Work through 1a to 1f in order — each one tells you what to run and what you should see.

1a · Confirm you have the problem

cd ~/Desktop/java-guild-candidate-test-intermediate-main
grep -n 'RuntimeException' src/main/java/com/solirius/intermediate/library/Main.java
113:                        throw new RuntimeException(e);
124:                        throw new RuntimeException(e);

Two hits, around lines 113 and 124 — the borrow case and the return case. If you get no output, you have already done this and can skip to Step 2.

1b · Watch it fail, so you know what you are fixing

mvn -q clean compile
printf '4\nGone With The Wind\n6\n' | java -cp target/classes com.solirius.intermediate.library.Main
rm -f libraryState.txt
Enter the title of the book to borrow:
Exception in thread "main" java.lang.RuntimeException:
  com.solirius.intermediate.library.exceptions.BookNotFoundException:
  no book titled 'Gone With The Wind'

A typo kills the application. Note what is missing: no Saving library state..., so everything added that session is discarded.

If you see NoSuchElementException instead

Once Step 1 is applied, running the command without a trailing 6 gives you this:

Enter the title of the book to borrow: no book titled 'Gone With The Wind'

<menu again>
Exception in thread "main" java.util.NoSuchElementException
	at java.base/java.util.Scanner.nextInt(Scanner.java:2251)

That is not a failure of your fix — your fix worked. Look at the green line: the borrow reported its problem and the menu came back, which is exactly what Step 1 was for. The application then asked for another choice, and stdin was empty because you only piped two lines.

ExceptionMeans
InputMismatchExceptionBad input — something is there, but it is not a number. Fixed in Step 3.
NoSuchElementExceptionNo input — the stream ended. Also fixed in Step 3.

Add 6\n to the end of any piped input so the application exits through the menu, as a real user would. Every command on this page now does.

It is still a real robustness gap, and worth naming: press Ctrl+D at the menu of the current build and it dies the same way. LibraryConsole in Step 3 closes it with while (running && in.hasNextLine()) — running out of input becomes an ordinary exit rather than a crash. Verified: after Step 3 the same two-line command reaches “Library state saved successfully”.

1c · Make the change — two lines

Open Main.java. At line 113 (inside the borrow case) and again at line 124 (inside the return case), you will find this exact block:

                    } catch (LibraryException e) {
                        throw new RuntimeException(e);
                    }

Replace the middle line in both places:

                    } catch (LibraryException e) {
-                       throw new RuntimeException(e);
+                       System.out.println(e.getMessage());
                    }

Nothing else changes. Confirm both are done:

grep -c 'RuntimeException' src/main/java/com/solirius/intermediate/library/Main.java
0

1d · Run the tests — the golden master should now be RED

mvn clean test
Tests run: 5 ... in A library
Tests run: 7 ... in A book
Tests run: 4 ... in The file-backed library repository
[ERROR] GoldenMasterTest.behavesAsApproved » Failed Approval
[ERROR] Tests run: 17, Failures: 0, Errors: 1, Skipped: 0
Red is correct here

You changed what the user sees, so the recorded transcript no longer matches. If the golden master had stayed green, your edit would not have taken effect — that is the signal to check you saved the file and ran clean.

1e · Read the diff, then approve it

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

You are looking for exactly three stack traces becoming three sentences. Menus elided:

- !! java.lang.RuntimeException: ...AlreadyBorrowedException: 'Dune' is already borrowed
+ Enter the title of the book to borrow: 'Dune' is already borrowed
+ Saving library state...
+ Library state saved successfully.
+ Thank you for using the Library Management System!

- !! java.lang.RuntimeException: ...BookNotFoundException: no book titled 'Gone With The Wind'
+ Enter the title of the book to borrow: no book titled 'Gone With The Wind'

- !! java.lang.RuntimeException: ...NotBorrowedException: 'Dune' is not currently borrowed
+ Enter the title of the book to return: 'Dune' is not currently borrowed
Look at the added lines, not just the removed ones

Saving library state... now appears in all three scenarios. Previously the application died before reaching it. You have not merely improved three error messages — you have closed three ways of silently losing the user's whole session.

If — and only if — the diff contains those three hunks and nothing else:

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

1f · Commit

Copy the whole block:

cd ~/Desktop/java-guild-candidate-test-intermediate-main

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

git commit -F - <<'EOF'
fix: report domain failures instead of crashing on them

The catch blocks for borrow and return were changed to catch
LibraryException but still rethrew it wrapped in a RuntimeException, so
a mistyped title still terminated the application and discarded
everything added during that session - state is only written on a clean
exit via the menu.

They now print the exception's message and return to the menu, which is
what the custom exceptions were introduced for in the first place.

Golden master re-approved: three stack traces become three messages, and
all three scenarios now reach "Library state saved successfully".
EOF

git log --oneline -3
Why this is its own commit

It is a behaviour fix and it belongs to Lesson 0004. Everything in Step 2 below is a pure refactor that must change no behaviour at all.

Keeping them separate is what lets Step 2's commit message claim “the golden master passes against its existing approved file” — a statement a reviewer can verify in one command. Fold the two together and you lose that claim entirely, because the approved file would have changed for two unrelated reasons at once.

Check before moving on

CheckExpected
grep -c 'RuntimeException' …/Main.java0
mvn clean test17 tests, 0 failures, 0 errors
git status --shortempty — everything committed
printf '4\nX\n6\n' | java -cp target/classes …Mainprints no book titled 'X', then the menu again

Step 2 · Move the files into the hexagonal layout

This step changes no behaviour whatsoever. Nothing but package declarations and imports.

2a · Create the directories and move the files

cd ~/Desktop/java-guild-candidate-test-intermediate-main
B=src/main/java/com/solirius/intermediate/library
T=src/test/java/com/solirius/intermediate/library

mkdir -p $B/domain/exceptions $B/port $B/adapter/console $B/adapter/file
mkdir -p $T/domain $T/adapter/console $T/adapter/file

git mv $B/Book.java $B/Library.java             $B/domain/
git mv $B/exceptions/*.java                     $B/domain/exceptions/
git mv $B/repository/LibraryRepository.java     $B/port/
git mv $B/repository/FileLibraryRepository.java $B/adapter/file/
rmdir $B/exceptions $B/repository

git mv $T/BookTest.java $T/LibraryTest.java     $T/domain/
git mv $T/FileLibraryRepositoryTest.java        $T/adapter/file/

Main.java, package-info.java and GoldenMasterTest.java stay where they are — Main is the composition root, and the golden master drives the application from outside it.

2b · Fix the package declarations

FileNew package line
domain/Book.java
domain/Library.java
package com.solirius.intermediate.library.domain;
domain/exceptions/*.javapackage com.solirius.intermediate.library.domain.exceptions;
port/LibraryRepository.javapackage com.solirius.intermediate.library.port;
adapter/file/FileLibraryRepository.javapackage com.solirius.intermediate.library.adapter.file;

The test files take the same packages as the classes they test.

Or let IntelliJ do it

Drag the files in the Project view and choose Refactor → Move. It rewrites package declarations and every import across the project. The commands above are here so you know what it did — and in case it misses the test packages, which it sometimes does.

2c · Fix the imports that same-package access was hiding

This is the part that surprises people

Everything used to be in one package, so classes referred to each other with no import at all. Split the package and the compiler starts asking for them. You will see a burst of:

cannot find symbol
  symbol: class Library
cannot find symbol
  symbol: class Book

That is not a mistake — it is the compiler enumerating dependencies the flat package was concealing. Add them:

FileImports to add
port/LibraryRepository.java...domain.Library
adapter/file/
FileLibraryRepository.java
...domain.Book
...domain.Library
...port.LibraryRepository
Main.java...domain.Library
...port.LibraryRepository
...adapter.file.FileLibraryRepository
adapter/file/
FileLibraryRepositoryTest.java
...domain.Book
...domain.Library
...port.LibraryRepository
...adapter.file.FileLibraryRepository

All of them begin com.solirius.intermediate.library.

2d · Verify it changed nothing

mvn clean test
[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
The golden master is the proof

0 errors, and no .received.txt written. You moved every class in the project and the application's output across thirteen scenarios did not shift by a byte. Verified before publishing.

ls $D/*.received.txt      # should say "No such file"
git add -A
git commit -F - <<'EOF'
refactor: arrange the code as ports and adapters

Everything lived in one package, so nothing recorded which classes were
domain rules and which were plumbing, and the compiler could not enforce
a direction.

The layout is now domain / port / adapter. Book, Library and the
exceptions are the core; LibraryRepository is the port the core defines;
FileLibraryRepository is the adapter implementing it. Dependencies point
inwards only - domain imports nothing from port or adapter, which is
checkable with a single grep.

Splitting the package also made previously invisible dependencies
explicit, since same-package access no longer hides them.

No behaviour change: package declarations and imports only. The golden
master passes against its existing approved file.
EOF

Step 3 · Extract the console adapter

3a · Create MenuOption

adapter/console/MenuOption.java:

package com.solirius.intermediate.library.adapter.console;

import java.util.Optional;

/**
 * The choices offered by the console menu.
 * <p>
 * Holding the code and the label together keeps the menu text and the
 * dispatch logic from drifting apart: the menu is rendered from these
 * values, so an option cannot exist without appearing on screen.
 * </p>
 */
public enum MenuOption {
    /** Add a new book to the library. */
    ADD_BOOK(1, "Add a new book"),
    /** List the books that are not currently on loan. */
    VIEW_AVAILABLE(2, "View available books"),
    /** Find a book by title. */
    SEARCH(3, "Search for a book"),
    /** Borrow a book by title. */
    BORROW(4, "Borrow a book"),
    /** Return a previously borrowed book. */
    RETURN(5, "Return a book"),
    /** Save state and leave. */
    EXIT(6, "Exit");

    /** The number the user types to choose this option. */
    private final int code;
    /** The text shown on the menu. */
    private final String label;

    MenuOption(final int theCode, final String theLabel) {
        this.code = theCode;
        this.label = theLabel;
    }

    /**
     * Finds the option a user's input selects.
     *
     * @param input the raw text the user typed
     * @return the matching option, or empty if the input selects none
     */
    public static Optional<MenuOption> from(final String input) {
        for (MenuOption option : values()) {
            if (String.valueOf(option.code).equals(input.trim())) {
                return Optional.of(option);
            }
        }
        return Optional.empty();
    }

    /**
     * Renders the whole menu, so the text can never drift from the options.
     *
     * @return the menu as it should be printed
     */
    public static String menu() {
        StringBuilder text = new StringBuilder("Menu:");
        for (MenuOption option : values()) {
            text.append(System.lineSeparator())
                .append(option.code).append(". ").append(option.label);
        }
        return text.append(System.lineSeparator())
                   .append("Enter your choice: ").toString();
    }
}
Why this is the strongest OO signal in the submission

Main currently holds six public static final int constants and a hand-written MENU string repeating all six labels. Two lists kept in step by hand: add an option, forget the string, and the feature is invisible.

menu() renders from values(), so an option cannot exist without appearing on screen. That is not “using an enum because enums are good” — it is deleting a class of bug by making it unrepresentable.

3b · Create LibraryConsole

adapter/console/LibraryConsole.java:

package com.solirius.intermediate.library.adapter.console;

import com.solirius.intermediate.library.domain.Book;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryException;

import java.io.PrintStream;
import java.util.List;
import java.util.Scanner;

/**
 * Menu-driven console interface to a library.
 * <p>
 * Input and output are supplied by the caller rather than reached for
 * statically, so the whole interaction can be driven from a test.
 * </p>
 */
public final class LibraryConsole {

    /** The library being operated on. */
    private final Library library;
    /** Where user input is read from. */
    private final Scanner in;
    /** Where output is written to. */
    private final PrintStream out;

    /**
     * Creates a console bound to the given library and streams.
     *
     * @param theLibrary the library to operate on
     * @param input      the source of user input
     * @param output     the destination for output
     */
    public LibraryConsole(final Library theLibrary,
                          final Scanner input,
                          final PrintStream output) {
        this.library = theLibrary;
        this.in = input;
        this.out = output;
    }

    /** Runs the menu loop until the user exits or input runs out. */
    public void run() {
        boolean running = true;
        while (running && in.hasNextLine()) {
            out.println();
            out.println(MenuOption.menu());
            running = handle(MenuOption.from(in.nextLine()).orElse(null));
        }
    }

    /**
     * Performs one menu action.
     *
     * @param option the chosen option, or null if the input matched none
     * @return whether the menu loop should continue
     */
    private boolean handle(final MenuOption option) {
        if (option == null) {
            out.println("Invalid choice. Please try again.");
            return true;
        }
        switch (option) {
            case ADD_BOOK: addBook(); return true;
            case VIEW_AVAILABLE: list(library.viewAvailableBooks()); return true;
            case SEARCH: search(); return true;
            case BORROW: borrow(); return true;
            case RETURN: returnBook(); return true;
            case EXIT:
            default: return false;
        }
    }

    /** Prompts for a title and author and adds the book. */
    private void addBook() {
        String title = prompt("Enter book title: ");
        String author = prompt("Enter book author: ");
        library.addBook(new Book(title, author));
        out.println("Book added successfully!");
    }

    /** Prompts for a title and reports whether the library holds it. */
    private void search() {
        String title = prompt("Enter the title of the book to search: ");
        out.println(library.searchBook(title)
                .map(Object::toString)
                .orElse("Book not found."));
    }

    /** Prompts for a title and borrows it, reporting any domain failure. */
    private void borrow() {
        String title = prompt("Enter the title of the book to borrow: ");
        try {
            library.borrowBook(title);
            out.println("Book borrowed successfully!");
        } catch (LibraryException e) {
            out.println(e.getMessage());
        }
    }

    /** Prompts for a title and returns it, reporting any domain failure. */
    private void returnBook() {
        String title = prompt("Enter the title of the book to return: ");
        try {
            library.returnBook(title);
            out.println("Book returned successfully!");
        } catch (LibraryException e) {
            out.println(e.getMessage());
        }
    }

    /**
     * Prints a prompt and reads one line of input.
     *
     * @param message the prompt to display
     * @return the line the user typed, or an empty string if input ran out
     */
    private String prompt(final String message) {
        out.print(message);
        return in.hasNextLine() ? in.nextLine() : "";
    }

    /**
     * Prints the available books.
     *
     * @param books the books to print
     */
    private void list(final List<Book> books) {
        out.println("Available books:");
        for (Book book : books) {
            out.println(book);
        }
    }
}
The two parts that matter most
public LibraryConsole(final Library theLibrary,
                      final Scanner input,
                      final PrintStream output) {

Previously the class named its own collaborators — System.in, System.out — so nobody else could choose them. Now the caller does: production passes the real streams, a test passes byte arrays it controls. That is constructor injection, with no framework, no annotations and no container.

MenuOption.from(in.nextLine()).orElse(null)

And this is what fixes banana. Note it reads a whole line rather than guarding with hasNextInt(). hasNextInt() consumes nothing, so looping on it without calling next() to discard the bad token spins forever printing “Invalid choice” — you would have swapped a crash for a hang, which is worse.

3c · Reduce Main to wiring

Replace Main.java entirely:

package com.solirius.intermediate.library;

import com.solirius.intermediate.library.adapter.console.LibraryConsole;
import com.solirius.intermediate.library.adapter.file.FileLibraryRepository;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryException;
import com.solirius.intermediate.library.port.LibraryRepository;

import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/**
 * Entry point and composition root.
 * <p>
 * Its only job is to choose the concrete adapters and wire them to the
 * domain. It contains no behaviour of its own, which is why it has no test
 * of its own beyond the end-to-end golden master.
 * </p>
 */
public final class Main {

    /** Where the library state is kept between runs. */
    private static final String STATE_FILE = "libraryState.txt";

    private Main() {
    }

    /**
     * Initialises the LMS program.
     *
     * @param args from the command line; an optional state file path
     */
    public static void main(final String[] args) {
        String filePath = args.length > 0 ? args[0] : STATE_FILE;
        PrintStream out =
                new PrintStream(System.out, true, StandardCharsets.UTF_8);
        LibraryRepository repository = new FileLibraryRepository(filePath);

        Library library;
        try {
            library = repository.load();
            out.println("Library state loaded successfully.");
        } catch (LibraryException e) {
            library = new Library();
            out.println("Could not load library state. Starting fresh.");
        }

        out.println("Welcome to the Library Management System!");
        try (Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8)) {
            new LibraryConsole(library, scanner, out).run();
        }

        out.println("Saving library state...");
        try {
            repository.save(library);
            out.println("Library state saved successfully.");
        } catch (LibraryException e) {
            out.println(e.getMessage());
        }
        out.println("Thank you for using the Library Management System!");
    }
}

From 150 lines to 63, with no switch, no menu constants and no business logic. It chooses the concrete adapters and hands them to the domain — which is the whole definition of a composition root.

3d · Add the test that was impossible before

src/test/java/.../adapter/console/LibraryConsoleTest.java:

package com.solirius.intermediate.library.adapter.console;

import com.solirius.intermediate.library.domain.Library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

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

@DisplayName("The console")
class LibraryConsoleTest {

    private String runWith(final String keystrokes) {
        ByteArrayOutputStream captured = new ByteArrayOutputStream();
        new LibraryConsole(
                new Library(),
                new Scanner(new ByteArrayInputStream(
                        keystrokes.getBytes(StandardCharsets.UTF_8))),
                new PrintStream(captured, true, StandardCharsets.UTF_8)
        ).run();
        return captured.toString(StandardCharsets.UTF_8);
    }

    @Test
    @DisplayName("asks again when the choice is not a number")
    void asksAgainWhenTheChoiceIsNotANumber() {
        String output = runWith("banana\n6\n");
        assertTrue(output.contains("Invalid choice. Please try again."),
                "expected 'banana' to be rejected, but the console printed:\n" + output);
    }

    @Test
    @DisplayName("adds a book and lists it")
    void addsABookAndListsIt() {
        String output = runWith("1\nDune\nFrank Herbert\n2\n6\n");
        assertTrue(output.contains("Book added successfully!"), output);
        assertTrue(output.contains("Dune by Frank Herbert (Available)"), output);
    }
}

3e · Run it

mvn clean test
Tests run: 19, Failures: 0, Errors: 1, Skipped: 0

Nineteen — your seventeen plus two console tests. The single error is the golden master, and its diff is one behaviour change:

- !! java.util.InputMismatchException: null
+ Invalid choice. Please try again.
+ Saving library state...
+ Library state saved successfully.
+ Thank you for using the Library Management System!

Everything else — twelve scenarios — is byte-identical. The extraction preserved behaviour; only the input handling deliberately changed.

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

And the crash from the top of this lesson:

printf 'banana\n6\n' | java -cp target/classes com.solirius.intermediate.library.Main
Invalid choice. Please try again.
Thank you for using the Library Management System!
git add -A
git commit -F - <<'EOF'
fix: extract the console adapter and handle non-numeric input

Main read System.in and wrote System.out from a 150-line static method,
so the menu loop had no seam a test could reach, and any non-digit at
the menu raised InputMismatchException from Scanner.nextInt and
terminated the application - discarding everything added that session,
since state is written only on a clean exit.

LibraryConsole takes its Library, Scanner and PrintStream as constructor
parameters, so a test can drive it with byte arrays and assert on the
exact output a user would see. The choice is read a line at a time and
matched against MenuOption, which also replaces the six int constants
and the hand-maintained menu string; the menu is rendered from values(),
so an option cannot exist without appearing on screen.

Reading a line rather than guarding with hasNextInt avoids the second
trap: hasNextInt consumes nothing, so a loop on it spins forever unless
the offending token is discarded separately.

Main is reduced to a composition root: it chooses the adapters and wires
them to the domain, and holds no behaviour of its own.

Golden master re-approved: one hunk, the InputMismatchException becoming
a message, with the application surviving to save its state.
EOF

Where Mockito fits — and where it does not

The pom declares mockito-core and nothing uses it. Resist the urge to fix that here.

For this classVerdict
Mocking Scanner and PrintStreamNo. ByteArrayInputStream and ByteArrayOutputStream are real, simpler, and assert on the actual bytes a user would see.
Mocking Library to verify borrowBook was calledNo. That asserts your implementation, not your behaviour.
Mocking LibraryRepository to simulate a disk failureYes. A genuine use: making a collaborator fail on demand is hard to arrange for real.
This is a near-certain interview question

“I see Mockito in the pom — why didn't you use it?” is a trap for the eager. The strong answer: “I only reach for a mock when the real collaborator is slow, non-deterministic, or hard to drive into the state I need. Streams are none of those, so I used real ones and asserted on the actual output. Where I would use it is the repository port — forcing a storage failure to test that path.”

Note that the port from Step 2 is what makes that answer available to you.

Drill

Why does taking Scanner and PrintStream as constructor parameters make the class testable?

  1. Constructor parameters are initialised before any method can be invoked
  2. It gives the test a seam to substitute streams it controls and can read
  3. Static methods cannot be invoked from a JUnit test without reflection use
  4. Injected fields can be marked final, which makes the whole class immutable

True of any field assigned in a constructor, and nothing to do with testability.

This is the whole mechanism. Previously the class named its collaborators (System.in, System.out) so nobody else could choose them. Now the caller chooses — production passes the real streams, the test passes byte arrays it can write to and read back.

Static methods are perfectly callable from tests; the golden master calls Main.main directly. The problem was never the static keyword, it was the hardwired streams.

The fields can be final, but a Scanner is deeply mutable, so the class is not immutable. Nor would immutability make it testable.

What is the real benefit of replacing the six int constants with an enum?

  1. Enums use less memory at runtime than the equivalent integer constants do
  2. An enum constant cannot be passed where a plain integer is being expected
  3. Switching over an enum executes faster than switching over an int value
  4. The menu text is derived from the options, so the two cannot drift apart

The opposite — each constant is an object. Memory is irrelevant at this scale either way.

A genuine benefit of type safety, and worth a sentence. But the menu had no such confusion to prevent, so it is not the main win here.

Both compile to a jump table. Performance is not a consideration in a method that waits on a human typing.

This is the one. The starter kept a hand-written MENU string alongside six separate constants, so adding an option meant remembering to edit both. Rendering the menu from values() makes that impossible — you have removed a bug rather than tidied some syntax.

Why is reading a whole line better than calling hasNextInt() before nextInt()?

  1. hasNextInt consumes nothing, so bad input must be discarded separately
  2. hasNextInt was deprecated in Java 11 and should no longer be relied upon
  3. Reading a line is the only way to accept input containing any whitespace
  4. nextInt cannot parse negative numbers, so out-of-range input still crashes

Correct, and it is the trap. hasNextInt() only peeks. Loop on it without calling next() to throw the bad token away and you have swapped a crash for an infinite loop printing “Invalid choice” forever — which is arguably worse, because it hangs rather than exits.

It is not deprecated and is perfectly usable, provided you also consume the token.

You can read whitespace-containing input other ways, and the menu choice contains none. Not the issue.

nextInt() parses negatives fine. -5 would be read and then rejected as an unknown option, which is correct behaviour.

Commit it — as two commits

First the extraction. This one must change nothing, so the golden master should still be green — and the commit message says so.

cd ~/Desktop/java-guild-candidate-test-intermediate-main
~/.local/maven/bin/mvn test          # golden master MUST still be green here

git add src/main/java/com/solirius/intermediate/library/LibraryConsole.java
git add src/main/java/com/solirius/intermediate/library/Main.java

git commit -F - <<'EOF'
refactor: extract LibraryConsole from Main

Main read System.in and wrote System.out from a 90-line static method,
so the menu loop had no seam a test could reach.

LibraryConsole takes its Library, Scanner and PrintStream as constructor
parameters, which lets a test drive it with byte arrays and assert on
the exact output a user would see. Main is reduced to wiring: load
state, run the console, save state.

No behaviour change: the golden master is unchanged by this commit.
EOF
If the golden master went red on that first commit

Then the extraction was not behaviour-preserving and the commit message would be a false claim. Diff it, find the incidental change — almost always a println that became a print, or a dropped header line — and put it back. Do not re-approve to make the red go away.

Then the behaviour change, separately:

D=src/test/java/com/solirius/intermediate/library

diff -u $D/GoldenMasterTest.behavesAsApproved.approved.txt \
        $D/GoldenMasterTest.behavesAsApproved.received.txt

mv -f $D/GoldenMasterTest.behavesAsApproved.received.txt \
      $D/GoldenMasterTest.behavesAsApproved.approved.txt

~/.local/maven/bin/mvn test

git add src/main/java/com/solirius/intermediate/library/MenuOption.java
git add src/main/java/com/solirius/intermediate/library/LibraryConsole.java
git add src/test/java/com/solirius/intermediate/library/LibraryConsoleTest.java
git add $D/GoldenMasterTest.behavesAsApproved.approved.txt

git commit -F - <<'EOF'
fix: handle menu input that is not a number

Typing any non-digit at the menu raised InputMismatchException from
Scanner.nextInt and terminated the application, discarding everything
added during that session, since state is only written on a clean exit.

The choice is now read a line at a time and matched against MenuOption.
Reading a line rather than guarding with hasNextInt avoids the second
trap: hasNextInt consumes nothing, so a loop on it spins forever unless
the offending token is discarded separately.

MenuOption also replaces the six int constants and the hand-maintained
MENU string. The menu is rendered from values(), so an option cannot
exist without appearing on screen.
EOF

git log --oneline
Note what that first message claims

“No behaviour change: the golden master is unchanged by this commit.” That is a falsifiable statement, and the assessor can check it in seconds. Making claims a reviewer can verify is a habit worth having.

Read this

Primary source

Martin Fowler — “Inversion of Control Containers and the Dependency Injection Pattern”. Long, but read the first third: the naive example, and the section on constructor injection. It is the article that makes clear DI is a pattern, not a framework — which is precisely the point you are making by doing it here with no framework at all.

Carry on

Send me your LibraryConsole and I will review it as an assessor would — the first thing I will look for is whether anything in it still mentions System.