Java Guild course · Lesson 0006
Lesson 0006 · design under pressure
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.
Two things are still wrong with Main, and one of them may surprise you.
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.
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
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.
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.
Two lines change. Work through 1a to 1f in order — each one tells you what to run and what you should see.
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.
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.
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.
| Exception | Means |
|---|---|
| InputMismatchException | Bad input — something is there, but it is not a number. Fixed in Step 3. |
| NoSuchElementException | No 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”.
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
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
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.
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
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
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
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 | Expected |
|---|---|
| grep -c 'RuntimeException' …/Main.java | 0 |
| mvn clean test | 17 tests, 0 failures, 0 errors |
| git status --short | empty — everything committed |
| printf '4\nX\n6\n' | java -cp target/classes …Main | prints no book titled 'X', then the menu again |
This step changes no behaviour whatsoever. Nothing but package declarations and imports.
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.
| File | New package line |
|---|---|
| domain/Book.java domain/Library.java | package com.solirius.intermediate.library.domain; |
| domain/exceptions/*.java | package com.solirius.intermediate.library.domain.exceptions; |
| port/LibraryRepository.java | package com.solirius.intermediate.library.port; |
| adapter/file/FileLibraryRepository.java | package com.solirius.intermediate.library.adapter.file; |
The test files take the same packages as the classes they test.
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.
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:
| File | Imports 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.
mvn clean test
[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
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
MenuOptionadapter/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();
}
}
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.
LibraryConsoleadapter/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);
}
}
}
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.
Main to wiringReplace 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.
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);
}
}
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
The pom declares mockito-core and nothing uses it. Resist the urge to
fix that here.
| For this class | Verdict |
|---|---|
Mocking Scanner and PrintStream | No. ByteArrayInputStream and ByteArrayOutputStream are real, simpler, and assert on the actual bytes a user would see. |
Mocking Library to verify borrowBook was called | No. That asserts your implementation, not your behaviour. |
Mocking LibraryRepository to simulate a disk failure | Yes. A genuine use: making a collaborator fail on demand is hard to arrange for real. |
“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.
Why does taking Scanner and PrintStream as constructor parameters make the class testable?
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?
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()?
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.
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
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
“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.
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.
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.