Java Guild course · The Rebuild · Phase 5
Phase 5 · commits 26–31
The ninety-line static method gets a seam, the menu stops being two lists kept in step by hand, the last crash goes, and the README gets written last — so it describes what the code does rather than what was planned.
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.
refactor · c9138a0 · ADR 0007 →
main() was a ninety-line static method that read System.in and wrote System.out inline, so the menu loop had no seam a test could reach without swapping the process's streams.
Step 1. Create docs/adr/0007-the-console-takes-its-streams-as-parameters.md:
# 7. The console takes its streams as constructor parameters
Date: 2026-09-02
## Status
Accepted
## Context
The supplied `main` was a ninety-line static method that read `System.in` and
wrote `System.out` directly, with the menu loop, the prompts, the state file
and the wiring all in one scope.
Nothing in it could be tested below the level of the whole application. The
golden master reaches it by swapping `System.in` and `System.out` globally,
which works but is process-wide: two such tests cannot run in parallel, and a
failure points at the transcript rather than at a method.
## Decision
`LibraryConsole` takes its `Library`, its `Scanner` and its `PrintStream` as
constructor parameters and touches no global. `Main` becomes wiring: load,
run, save.
## Consequences
- A console test constructs one with byte arrays and asserts on the exact
output a user would see, with no global state and no `System.setOut`.
- The dependency is visible in the constructor signature rather than hidden in
the method body.
- Writing to `System.out` anywhere inside `LibraryConsole` would silently
bypass the injected stream and leave that output untested. Every write in
the class goes through `out`; there are no exceptions, and a stray
`System.out.println` in this class should be treated as a defect.
- `Main` still uses `System.out` directly, which is correct: it is the
composition root, and naming the real stream is its job.
Step 2. Create src/main/java/com/solirius/intermediate/library/LibraryConsole.java:
package com.solirius.intermediate.library;
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.Scanner;
/**
* The menu-driven user interface.
*
* <p>Reads and writes only through the streams it is given, so a test can
* drive it with byte arrays and assert on exactly what a user would see.
*/
public final class LibraryConsole {
/**
* The main menu.
*/
public static final String MENU = "\nMenu:"
+ "\n1. Add a new book"
+ "\n2. View available books"
+ "\n3. Search for a book"
+ "\n4. Borrow a book"
+ "\n5. Return a book"
+ "\n6. Exit"
+ "\nEnter your choice: ";
private static final int ADD_A_BOOK_OPTION = 1;
private static final int LIST_AVAILABLE_BOOKS = 2;
private static final int SEARCH_BOOK_OPTION = 3;
private static final int BORROW_BOOK_OPTION = 4;
private static final int RETURN_BOOK_OPTION = 5;
private static final int EXIT_OPTION = 6;
private final Library library;
private final Scanner in;
private final PrintStream out;
/**
* Creates a console over a library and a pair of streams.
*
* @param theLibrary the library to operate on
* @param theIn where the user's choices are read from
* @param theOut where messages are written to
*/
public LibraryConsole(final Library theLibrary,
final Scanner theIn,
final PrintStream theOut) {
this.library = theLibrary;
this.in = theIn;
this.out = theOut;
}
/**
* Serves the user until they choose to exit.
*/
public void run() {
boolean running = true;
while (running) {
out.println(MENU);
int choice = in.nextInt();
in.nextLine(); // Consume newline
switch (choice) {
case ADD_A_BOOK_OPTION: addBook(); break;
case LIST_AVAILABLE_BOOKS: listAvailableBooks(); break;
case SEARCH_BOOK_OPTION: search(); break;
case BORROW_BOOK_OPTION: borrow(); break;
case RETURN_BOOK_OPTION: returnBook(); break;
case EXIT_OPTION: running = false; break;
default: out.println("Invalid choice. Please try again.");
}
}
}
private void addBook() {
out.print("Enter book title: ");
String title = in.nextLine();
out.print("Enter book author: ");
String author = in.nextLine();
library.addBook(new Book(title, author));
out.println("Book added successfully!");
}
private void listAvailableBooks() {
out.println("Available books:");
for (Book book : library.viewAvailableBooks()) {
out.println(book);
}
}
private void search() {
out.print("Enter the title of the book to search: ");
String title = in.nextLine();
out.println(library.searchBook(title)
.map(Object::toString)
.orElse("Book not found."));
}
private void borrow() {
out.print("Enter the title of the book to borrow: ");
String title = in.nextLine();
try {
library.borrowBook(title);
out.println("Book borrowed successfully!");
} catch (LibraryException e) {
out.println(e.getMessage());
}
}
private void returnBook() {
out.print("Enter the title of the book to return: ");
String title = in.nextLine();
try {
library.returnBook(title);
out.println("Book returned successfully!");
} catch (LibraryException e) {
out.println(e.getMessage());
}
}
}
Step 3. In src/main/java/com/solirius/intermediate/library/Main.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Book;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
Step 4. In src/main/java/com/solirius/intermediate/library/Main.java, find:
import java.util.Scanner;
public final class Main {
/**
* The main menu.
*/
public static final String MENU = "\nMenu:"
+ "\n1. Add a new book"
+ "\n2. View available books"
+ "\n3. Search for a book"
+ "\n4. Borrow a book"
+ "\n5. Return a book"
+ "\n6. Exit"
+ "\nEnter your choice: ";
/**
* Option to add a Book.
*/
public static final int ADD_A_BOOK_OPTION = 1;
/**
* Option to list available Books.
*/
public static final int LIST_AVAILABLE_BOOKS = 2;
/**
* Option to search a Book.
*/
public static final int SEARCH_BOOK_OPTION = 3;
/**
* Option to search a Book.
*/
public static final int BORROW_BOOK_OPTION = 4;
/**
* Option to return a Book.
*/
public static final int RETURN_BOOK_OPTION = 5;
/**
* Option to terminate the program.
*/
public static final int EXIT_OPTION = 6;
private Main() {
}
/**and replace it with:
import java.util.Scanner;
public final class Main {
private Main() {
}
/**
Step 5. In src/main/java/com/solirius/intermediate/library/Main.java, find:
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);
boolean running = true;
System.out.println("Welcome to the Library Management System!");
while (running) {
System.out.println(MENU);
int choice = scanner.nextInt();
scanner.nextLine(); // Consume newline
switch (choice) {
case ADD_A_BOOK_OPTION:
System.out.print("Enter book title: ");
String title = scanner.nextLine();
System.out.print("Enter book author: ");
String author = scanner.nextLine();
library.addBook(new Book(title, author));
System.out.println("Book added successfully!");
break;
case LIST_AVAILABLE_BOOKS:
System.out.println("Available books:");
for (Book book : library.viewAvailableBooks()) {
System.out.println(book);
}
break;
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: ");
title = scanner.nextLine();
try {
library.borrowBook(title);
System.out.println("Book borrowed successfully!");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
break;
case RETURN_BOOK_OPTION:
System.out.print("Enter the title of the book to return: ");
title = scanner.nextLine();
try {
library.returnBook(title);
System.out.println("Book returned successfully!");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
break;
case EXIT_OPTION:
System.out.println("Saving library state...");
try {
repository.save(library);
System.out.println("Library state saved successfully.");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
running = false;
System.out.println("Thank you for using "
+ "the Library Management System!");
break;
default:
System.out.println("Invalid choice. Please try again.");
}
}
scanner.close();
}
}and replace it with:
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);
System.out.println("Welcome to the Library Management System!");
new LibraryConsole(library, scanner, System.out).run();
System.out.println("Saving library state...");
try {
repository.save(library);
System.out.println("Library state saved successfully.");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
System.out.println("Thank you for using "
+ "the Library Management System!");
scanner.close();
}
}
Step 6. Verify:
mvn clean test
# Tests run: 21, Failures: 0, Errors: 0
Step 7. Commit:
git add -A
git commit -F- <<'EOF'
refactor: extract LibraryConsole from Main
main() was a ninety-line static method that read System.in and wrote
System.out inline, so the menu loop had no seam a test could reach
without swapping the process's streams.
LibraryConsole takes its Library, Scanner and PrintStream as constructor
parameters, so a test can drive it with byte arrays. Main is reduced to
wiring: load the state, run the console, save the state.
Every write in the new class goes through the injected stream. A
System.out.println here would still appear on screen while being
invisible to a test holding the injected one, which is the failure mode
this refactor exists to prevent.
The switch still reads with nextInt and still crashes on non-numeric
input. That is deliberate: this commit only moves code. The crash is
fixed once the menu is an enum, two commits from here.
No behaviour change: the golden master needed no re-approval, so the
extracted console produces byte-identical output.
Decision recorded in
docs/adr/0007-the-console-takes-its-streams-as-parameters.md
EOF
test · dca681f
Cashes in the seam the previous commit opened. Three cases the golden master could only assert as part of a whole transcript, now asserted directly and without touching System.in or System.out.
Step 1. Create src/test/java/com/solirius/intermediate/library/LibraryConsoleTest.java:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Book;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("The console")
class LibraryConsoleTest {
private final Library library = new Library();
private String runWith(final String keystrokes) {
ByteArrayOutputStream captured = new ByteArrayOutputStream();
new LibraryConsole(
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("adds a book and then lists it as available")
void addsABookAndThenListsItAsAvailable() {
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);
}
@Test
@DisplayName("reports a borrow failure and keeps serving the user")
void reportsABorrowFailureAndKeepsServingTheUser() {
String output = runWith("4\nGone With The Wind\n2\n6\n");
assertTrue(output.contains("no book titled 'Gone With The Wind'"),
output);
assertTrue(output.contains("Available books:"),
"the menu must still be served after a failed borrow:\n"
+ output);
}
@Test
@DisplayName("says so when a search finds nothing")
void saysSoWhenASearchFindsNothing() {
library.addBook(new Book("Dune", "Frank Herbert"));
String output = runWith("3\nGone With The Wind\n6\n");
assertTrue(output.contains("Book not found."), output);
assertFalse(output.contains("Dune"), output);
}
}
Step 2. Verify:
mvn clean test
# Tests run: 24, Failures: 0, Errors: 0
Step 3. Commit:
git add -A
git commit -F- <<'EOF'
test: drive the console through its injected streams
Cashes in the seam the previous commit opened. Three cases the golden
master could only assert as part of a whole transcript, now asserted
directly and without touching System.in or System.out.
The second is the one worth having: after a failed borrow the console
must print the reason and then keep serving the menu. That was the
defect fixed in "report a failed borrow or return instead of crashing",
and until now only the golden master would have caught a regression.
EOF
refactor · 67c977c · ADR 0008 →
The menu lived in two places that had to be kept in step by hand: a MENU string of six numbered lines, and six int constants for the switch. Adding an option meant three separate edits, and missing one produced an entry that was advertised but dead, or implemented but invisible.
Step 1. Create docs/adr/0008-the-menu-is-an-enum-rendered-from-its-values.md:
# 8. The menu is an enum, rendered from its own values
Date: 2026-09-02
## Status
Accepted
## Context
The supplied code held the menu in two places that had to agree by hand: a
`MENU` string listing six numbered lines, and six `public static final int`
constants used by the `switch`. Nothing tied them together. A seventh option
meant editing the string, adding a constant and adding a `case`, and forgetting
any one of the three produced an option that was advertised but dead, or
implemented but invisible.
`switch` on an `int` also has no exhaustiveness: every value outside 1–6 is a
`default`, so the compiler cannot say whether every option is handled.
## Decision
One `MenuOption` enum carrying each option's code and label. The menu is
rendered from `values()`, and the console switches on the enum.
## Consequences
- The screen and the `switch` cannot disagree: both are derived from the same
list, so an option that exists is displayed and an option displayed exists.
- Adding an option is one entry in one place.
- The menu text is built with `'\n'` rather than `System.lineSeparator()`, so
the golden master transcript is identical on every platform. Line endings
here are a wire format, not a local preference.
- The console keeps an explicit `default` that treats an unrecognised choice as
invalid, because the selection still arrives from outside the program and
cannot be trusted to name an option that exists.
Step 2. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
* drive it with byte arrays and assert on exactly what a user would see.
*/
public final class LibraryConsole {
/**
* The main menu.
*/
public static final String MENU = "\nMenu:"
+ "\n1. Add a new book"
+ "\n2. View available books"
+ "\n3. Search for a book"
+ "\n4. Borrow a book"
+ "\n5. Return a book"
+ "\n6. Exit"
+ "\nEnter your choice: ";
private static final int ADD_A_BOOK_OPTION = 1;
private static final int LIST_AVAILABLE_BOOKS = 2;
private static final int SEARCH_BOOK_OPTION = 3;
private static final int BORROW_BOOK_OPTION = 4;
private static final int RETURN_BOOK_OPTION = 5;
private static final int EXIT_OPTION = 6;
private final Library library;
private final Scanner in;
private final PrintStream out;and replace it with:
* drive it with byte arrays and assert on exactly what a user would see.
*/
public final class LibraryConsole {
private final Library library;
private final Scanner in;
private final PrintStream out;
Step 3. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
public void run() {
boolean running = true;
while (running) {
out.println(MENU);
int choice = in.nextInt();
in.nextLine(); // Consume newline
switch (choice) {
case ADD_A_BOOK_OPTION: addBook(); break;
case LIST_AVAILABLE_BOOKS: listAvailableBooks(); break;
case SEARCH_BOOK_OPTION: search(); break;
case BORROW_BOOK_OPTION: borrow(); break;
case RETURN_BOOK_OPTION: returnBook(); break;
case EXIT_OPTION: running = false; break;
default: out.println("Invalid choice. Please try again.");
}
}
}
private void addBook() {and replace it with:
public void run() {
boolean running = true;
while (running) {
out.println(MenuOption.menu());
int choice = in.nextInt();
in.nextLine(); // Consume newline
running = handle(MenuOption.from(choice).orElse(null));
}
}
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: listAvailableBooks(); return true;
case SEARCH: search(); return true;
case BORROW: borrow(); return true;
case RETURN: returnBook(); return true;
case EXIT:
default: return false;
}
}
private void addBook() {
Step 4. Create src/main/java/com/solirius/intermediate/library/MenuOption.java:
package com.solirius.intermediate.library;
import java.util.Optional;
/**
* The choices the menu offers.
*
* <p>The menu the user sees is rendered from these values, so an option
* cannot exist without appearing on screen, or appear without working.
*/
public enum MenuOption {
ADD_BOOK(1, "Add a new book"),
VIEW_AVAILABLE(2, "View available books"),
SEARCH(3, "Search for a book"),
BORROW(4, "Borrow a book"),
RETURN(5, "Return a book"),
EXIT(6, "Exit");
private final int code;
private final String label;
MenuOption(final int theCode, final String theLabel) {
this.code = theCode;
this.label = theLabel;
}
/**
* Finds the option a user selected.
*
* @param selected the code the user entered
* @return the matching option, or empty if the menu has no such entry
*/
public static Optional<MenuOption> from(final int selected) {
for (MenuOption option : values()) {
if (option.code == selected) {
return Optional.of(option);
}
}
return Optional.empty();
}
/**
* Renders the menu.
*
* @return the menu exactly as the user sees it
*/
public static String menu() {
StringBuilder text = new StringBuilder("\nMenu:");
for (MenuOption option : values()) {
text.append('\n').append(option.code).append(". ")
.append(option.label);
}
return text.append("\nEnter your choice: ").toString();
}
}
Step 5. Verify:
mvn clean test
# Tests run: 24, Failures: 0, Errors: 0
Step 6. Commit:
git add -A
git commit -F- <<'EOF'
refactor: replace the menu constants with a MenuOption enum
The menu lived in two places that had to be kept in step by hand: a MENU
string of six numbered lines, and six int constants for the switch.
Adding an option meant three separate edits, and missing one produced an
entry that was advertised but dead, or implemented but invisible.
MenuOption carries each option's code and its label. The menu is
rendered from values(), so the screen and the switch are derived from
the same list and cannot disagree.
Switching on the enum also lets the compiler see the cases. The explicit
default stays, because the choice still arrives from outside the program.
The menu is built with '\n' rather than System.lineSeparator() so the
approved transcript is identical on every platform.
No behaviour change: the rendered menu is byte-identical and the golden
master needed no re-approval.
Decision recorded in
docs/adr/0008-the-menu-is-an-enum-rendered-from-its-values.md
EOF
fix · f727c23
Scanner.nextInt threw InputMismatchException on any non-digit. Nothing caught it, so a single typo terminated the application and discarded everything added during the session, because state is written only on a clean exit.
Step 1. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
*/
public void run() {
boolean running = true;
while (running) {
out.println(MenuOption.menu());
int choice = in.nextInt();
in.nextLine(); // Consume newline
running = handle(MenuOption.from(choice).orElse(null));
}
}
private boolean handle(final MenuOption option) {
if (option == null) {
out.println("Invalid choice. Please try again.");
return true;and replace it with:
*/
public void run() {
boolean running = true;
while (running && in.hasNextLine()) {
out.println(MenuOption.menu());
running = handle(MenuOption.from(in.nextLine()).orElse(null));
}
}
/**
* Writes a prompt and reads the answer, tolerating exhausted input.
*/
private String prompt(final String message) {
out.print(message);
return in.hasNextLine() ? in.nextLine() : "";
}
private boolean handle(final MenuOption option) {
if (option == null) {
out.println("Invalid choice. Please try again.");
return true;
Step 2. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
}
}
private void addBook() {
out.print("Enter book title: ");
String title = in.nextLine();
out.print("Enter book author: ");
String author = in.nextLine();
library.addBook(new Book(title, author));
out.println("Book added successfully!");
}and replace it with:
}
}
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!");
}
Step 3. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
}
}
private void search() {
out.print("Enter the title of the book to search: ");
String title = in.nextLine();
out.println(library.searchBook(title)
.map(Object::toString)
.orElse("Book not found."));
}
private void borrow() {
out.print("Enter the title of the book to borrow: ");
String title = in.nextLine();
try {
library.borrowBook(title);
out.println("Book borrowed successfully!");
} catch (LibraryException e) {and replace it with:
}
}
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."));
}
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) {
Step 4. In src/main/java/com/solirius/intermediate/library/LibraryConsole.java, find:
}
}
private void returnBook() {
out.print("Enter the title of the book to return: ");
String title = in.nextLine();
try {
library.returnBook(title);
out.println("Book returned successfully!");
} catch (LibraryException e) {and replace it with:
}
}
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) {
Step 5. In src/main/java/com/solirius/intermediate/library/MenuOption.java, find:
/**
* Finds the option a user selected.
*
* @param selected the code the user entered
* @return the matching option, or empty if the menu has no such entry
*/
public static Optional<MenuOption> from(final int selected) {
for (MenuOption option : values()) {
if (option.code == selected) {
return Optional.of(option);
}
}
return Optional.empty();and replace it with:
/**
* Finds the option a user selected.
*
* @param selected whatever the user typed
* @return the matching option, or empty if it names no entry on the menu
*/
public static Optional<MenuOption> from(final String selected) {
String trimmed = selected.trim();
for (MenuOption option : values()) {
if (String.valueOf(option.code).equals(trimmed)) {
return Optional.of(option);
}
}
return Optional.empty();
Step 6. In src/test/java/com/solirius/intermediate/library/GoldenMasterTest.java, find:
new Scenario("a choice that is not on the menu",
"9\n6\n"),
new Scenario("a choice that is not a number",
"banana\n6\n"),
new Scenario("work is lost when the choice is not a number",
"1\nDune\nFrank Herbert\nbanana\n", "2\n6\n")
);
@BeforeEachand replace it with:
new Scenario("a choice that is not on the menu",
"9\n6\n"),
new Scenario("a choice that is not a number",
"banana\n6\n"),
new Scenario("work survives a mistyped menu choice",
"1\nDune\nFrank Herbert\nbanana\n", "2\n6\n")
);
@BeforeEach
Step 7. In src/test/java/com/solirius/intermediate/library/LibraryConsoleTest.java, find:
assertTrue(output.contains("Book not found."), output);
assertFalse(output.contains("Dune"), output);
}
}and replace it with:
assertTrue(output.contains("Book not found."), output);
assertFalse(output.contains("Dune"), output);
}
@Test
@DisplayName("asks again when the choice is not a number")
void asksAgainWhenTheChoiceIsNotANumber() {
String output = runWith("banana\n1\nDune\nFrank Herbert\n6\n");
assertTrue(output.contains("Invalid choice. Please try again."),
output);
assertTrue(output.contains("Book added successfully!"),
"a typo must not stop the user getting their work done:\n"
+ output);
}
@Test
@DisplayName("stops cleanly when the input runs out")
void stopsCleanlyWhenTheInputRunsOut() {
String output = runWith("1\nDune\nFrank Herbert\n");
assertTrue(output.contains("Book added successfully!"), output);
}
}
Step 8. 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 9. Verify:
mvn clean test
# Tests run: 26, Failures: 0, Errors: 0
Step 10. Commit:
git add -A
git commit -F- <<'EOF'
fix: keep serving the user when the menu choice is not a number
Scanner.nextInt threw InputMismatchException on any non-digit. Nothing
caught it, so a single typo terminated the application and discarded
everything added during the session, because state is written only on a
clean exit.
The choice is now read a line at a time and matched against MenuOption,
so anything that is not an option is reported as one:
Invalid choice. Please try again.
Reading a line rather than guarding with hasNextInt avoids the second
trap. hasNextInt consumes nothing, so looping on it spins forever unless
the offending token is discarded separately.
The loop also stops when input runs out rather than throwing
NoSuchElementException at end of stream, and the prompts tolerate the
same. A piped session that ends without choosing Exit now saves its
state instead of losing it.
Golden master: the last two recorded crashes are gone. The scenario that
proved a mistyped choice destroyed the session now shows Dune still on
the shelf after a restart, so it is renamed to say what it demonstrates.
EOF
docs · a6c7692
The consequences section attributed the clear-then-parse defect to ADR 0005, which is about storage exception types. The defect has no ADR of its own; it is described in the commit that fixed it. Named directly instead.
Step 1. In docs/adr/0006-persistence-lives-behind-a-repository-interface.md, find:
not an edit to `Library`. The unused `sqlite-jdbc` dependency finally has a
plausible home.
- `Main` now depends on the interface and names the implementation once, at
the point where it is wired up.
- Returning a new `Library` makes the ADR 0005 clear-then-parse defect
structurally impossible rather than merely fixed: a failed load has nothing
to corrupt, because it never held the caller's collection. The test that
proved the old fix is therefore removed here — it can no longer fail.
- A whole-file rewrite on every save is fine at this size and would not be for
a real catalogue. Noted, not solved.and replace it with:
not an edit to `Library`. The unused `sqlite-jdbc` dependency finally has a
plausible home.
- `Main` now depends on the interface and names the implementation once, at
the point where it is wired up.
- Returning a new `Library` makes the clear-then-parse defect — `loadLibraryState`
emptied the collection before opening the file — structurally impossible
rather than merely fixed: a failed load has nothing
to corrupt, because it never held the caller's collection. The test that
proved the old fix is therefore removed here — it can no longer fail.
- A whole-file rewrite on every save is fine at this size and would not be for
a real catalogue. Noted, not solved.
Step 2. Verify:
mvn clean test
# Tests run: 26, Failures: 0, Errors: 0
Step 3. Commit:
git add -A
git commit -F- <<'EOF'
docs: correct a stale cross-reference in ADR 0006
The consequences section attributed the clear-then-parse defect to
ADR 0005, which is about storage exception types. The defect has no ADR
of its own; it is described in the commit that fixed it. Named directly
instead.
EOF
docs · 3fa35ac
Covers what the submission asks for: how to build and run, the approach taken, and the additional work beyond the Intermediate tier.
Step 1. In README.md, find:
This repository contains a Java challenge designed to assess the skills of a Java Guild candidate. The challenge includes problem-solving, coding, and demonstrating knowledge of Java fundamentals and best practices.
# Build a Library Management System
## Objective:
Create a simplified Library Management System (LMS) using Java that allows users to perform basic operations like adding books, borrowing books, and viewing available books.
# Requirements
## Book Class (visibility at candidate discretion)
Represents a book in the library.
### Attributes (visibility at candidate discretion)
* String title
* String author
* boolean isBorrowed
### Methods (visibility at candidate discretion)
* Book(String title, String author) - Constructor to initialise a book.
* boolean borrowBook() - Marks the book as borrowed if it's not already borrowed.
* boolean returnBook() - Marks the book as returned.
* String toString() - Returns a string representation of the book (e.g., "Title by Author (Available/Borrowed)").
## Library Class (visibility at candidate discretion)
Manages a collection of books.
### Attributes (visibility at candidate discretion)
* ArrayList<Book> books
### Methods (visibility at candidate discretion)
* void addBook(Book book) - Adds a new book to the collection.
* List<Book> viewAvailableBooks() - Returns a list of books that are not borrowed.
* Book searchBook(String title) - Searches for a book by title.
* boolean borrowBook(String title) - Allows a user to borrow a book by title.
* boolean returnBook(String title) - Allows a user to return a book by title.
## Main Class (visibility at candidate discretion)
Provides a menu-driven interface for the user to interact with the Library Management System.
Options include:
* Add a new book.
* View available books.
* Search for a book.
* Borrow a book.
* Return a book.
* Exit the application.
## Incremental role-based extra features (eg. an intermediate is expected to build Graduate+Intermediate)
* Graduate
* Unit Tests: Write test cases for Book and Library classes.
* Intermediate
* Exception Handling: Handle scenarios where a book is already borrowed, does not exist, or cannot be returned.
* Persistence: Save the library state to a file and reload it upon application restart.
* Advanced roles
* Persistence: Save the library state to a portable file-based database.
* Search: Find a book by title or author
* Sorting: Provide an option to view books sorted by title or author.
## Instructions for Submission
Create a zip file (.7z or .zip) containing the code and any other files for your project.
Include:
* The complete Java source code.
* A README.md file with instructions on how to run the program.
* A brief description of the approach taken and any additional features implemented.
* Email the zip file to your talent acquisition contact as an attachment when completed.
## Evaluation Criteria
* Code readability and organisation.
* Proper use of object-oriented programming principles.
* Handling edge cases and exceptions.
* Implementation of bonus features (if any).
* Clarity and thoroughness of the README file.and replace it with:
# Library Management System
Solirius Java Guild candidate test, Intermediate tier.
The original brief is preserved unedited in [docs/BRIEF.md](docs/BRIEF.md).
## Build and run
Requires JDK 11 or later. Maven does not need to be installed; the wrapper
fetches the pinned version.
```bash
./mvnw test # 26 tests
./mvnw -q compile exec:java -Dexec.mainClass=com.solirius.intermediate.library.Main
```
Or without the exec plugin:
```bash
./mvnw -q compile
java -cp target/classes com.solirius.intermediate.library.Main
```
State is written to `libraryState.txt` in the working directory when the
session ends. Deleting that file resets the library.
## Approach
The brief reads as a blank-page exercise, but the repository contains a
finished, working, defective program. So this is a legacy-code exercise, and it
is worked in that order:
1. **Pin the behaviour.** A golden master drives the real `main()` through
thirteen scripted journeys and approves the transcript — crashes included.
Nothing is changed until an unintended change would show up as a diff.
([ADR 0001](docs/adr/0001-pin-behaviour-with-a-golden-master-first.md))
2. **Characterise the units.** Unit tests for `Book` and `Library` that record
what the supplied code does, each chosen so a plausible wrong
implementation fails it.
3. **Fix one defect per commit**, each with the test that proves it and a
re-approved transcript showing exactly what the user now sees instead.
4. **Refactor behind the net.** Every structural commit leaves the approved
transcript untouched, which is the evidence that it changed no behaviour.
5. **Write this file last**, so it describes what the code does rather than
what was planned.
The commit log is meant to be read. Thirty-one commits, each one idea, each
building and passing.
```bash
git log --oneline # the story
git log --stat # one logical change per commit
```
## Defects found in the supplied code
Each was pinned by the golden master before being fixed, so the transcript diff
in the fixing commit shows the exact change in what the user sees.
| # | Defect | Consequence |
|---|--------|-------------|
| 1 | `Scanner.nextInt` on the menu, uncaught | Any typo killed the application and **discarded the whole session** — state is written only on a clean exit |
| 2 | `Main` rethrew a failed borrow as `RuntimeException` | Asking for a book the library lacked ended the session the same way |
| 3 | `borrowBook` tested `book == null \|\| !book.borrowBook()` | Borrowing a book already on loan reported *"Book not found"* for a book the library was holding |
| 4 | `returnBook` had the same conflated branch | Returning an un-borrowed book reported it as missing |
| 5 | `IOException` signalled domain failures | A type describing a mechanism not involved, and one type for three distinct cases |
| 6 | Fields written raw and split on `;` | **Silent data loss.** `"Coming Home; A Novel"` by Rosamunde Pilcher reloaded as `Coming Home` by ` A Novel` |
| 7 | `parts[2]` with no field count check | A truncated file raised `ArrayIndexOutOfBoundsException` from inside the parser |
| 8 | `loadLibraryState` called `books.clear()` first | A corrupt file **destroyed the in-memory library**, then reported "Starting fresh" |
| 9 | `searchBook` returned `null` | Every caller had to remember a check the compiler could not enforce |
| 10 | Three supplied exception classes referenced by nothing | The named handling the tier asks for was absent |
| 11 | Menu held in a string *and* six `int` constants | Two lists to keep in step by hand |
| 12 | No tests; `groupId` left as the literal `groupId` | `mvn test` reported *No tests to run* |
## Architecture decisions
Recorded as they were made, in [docs/adr/](docs/adr/). Each is committed
alongside the change it justifies.
| ADR | Decision |
|-----|----------|
| [0001](docs/adr/0001-pin-behaviour-with-a-golden-master-first.md) | Pin behaviour with a golden master before changing anything |
| [0002](docs/adr/0002-domain-failures-are-checked-exceptions.md) | Domain failures are checked exceptions, not return codes |
| [0003](docs/adr/0003-borrow-and-return-do-not-return-a-boolean.md) | `borrowBook` and `returnBook` return `void`, not `boolean` |
| [0004](docs/adr/0004-searchbook-returns-optional.md) | `searchBook` returns `Optional`, not a nullable `Book` |
| [0005](docs/adr/0005-storage-failures-are-library-exceptions.md) | Storage failures are `LibraryException`s, not `IOException`s |
| [0006](docs/adr/0006-persistence-lives-behind-a-repository-interface.md) | Persistence lives behind a `LibraryRepository` interface |
| [0007](docs/adr/0007-the-console-takes-its-streams-as-parameters.md) | The console takes its streams as constructor parameters |
| [0008](docs/adr/0008-the-menu-is-an-enum-rendered-from-its-values.md) | The menu is an enum, rendered from its own values |
## Deviations from the brief
Two signatures in the brief are not implemented as written. Both are deliberate
and both are argued in an ADR.
| Brief | Implemented | Why |
|-------|-------------|-----|
| `boolean borrowBook(String)` <br> `boolean returnBook(String)` | `void` | With failure signalled by an exception, the only reachable return is `true`. A value no caller can act on invites an `else` branch the compiler has already proved unreachable. ([ADR 0003](docs/adr/0003-borrow-and-return-do-not-return-a-boolean.md)) |
| `Book searchBook(String)` | `Optional<Book>` | The supplied version returns `null` and its own Javadoc says so. `Optional` makes absence part of the type, so the compiler asks the question at every call site. ([ADR 0004](docs/adr/0004-searchbook-returns-optional.md)) |
`Book` keeps its `boolean` returns: at that level the answer really is
two-valued, and `Library` uses it to choose which exception to raise. `Book`
reports; `Library` decides how to escalate.
## Structure
```
com.solirius.intermediate.library
├── Main wiring: load, run, save
├── LibraryConsole the menu loop, over injected streams
├── MenuOption the options, and the menu rendered from them
├── domain/ the rules, depending on nothing
│ ├── Book, Library
│ └── exceptions/ LibraryException and its four subtypes
└── repository/ LibraryRepository, FileLibraryRepository
```
The dependency rule runs one way: `domain` depends on nothing, `repository`
depends on `domain`, the root package depends on both. A cycle would show up
as an import pointing the wrong way.
## Testing
26 tests, in four groups:
- **`GoldenMasterTest`** — thirteen end-to-end journeys through `main()`,
compared against an approved transcript. Catches *anything* that changes what
a user sees, including changes nobody intended.
- **`BookTest`, `LibraryTest`** — the domain rules.
- **`FileLibraryRepositoryTest`** — the file format, including a title
containing the field separator, a title containing a percent sign, a
truncated file and a missing file.
- **`LibraryConsoleTest`** — the menu loop over byte arrays.
Cases were checked by hand against deliberately broken implementations: removing
the guard in `Book.borrowBook` fails `refusesASecondBorrow`, making
`viewAvailableBooks` skip its filter fails `hidesABorrowedBookAndOnlyThatBook`,
and making `searchBook` case-sensitive fails `matchesATitleRegardlessOfCase`.
One trap worth naming: `assertNotNull` on an `Optional` can never fail, so the
search assertions go through `orElseThrow()` and `isEmpty()` instead.
## Deliberately not built
The Intermediate tier is complete. Beyond it:
- **SQLite persistence** (Advanced). The seam is in place —
`LibraryRepository` — and `sqlite-jdbc` is already a declared dependency, so
this is a new class rather than an edit to any existing one. Not written,
because the tier does not ask for it and an unused implementation is
a liability rather than a feature.
- **Search by author, and sorting** (Advanced). Both are small once
`searchBook` returns `Optional`; both were left out for the same reason.
- **A whole-file rewrite on every save** is what `FileLibraryRepository` does.
Fine at this size, wrong for a real catalogue. Noted rather than solved.
Step 2. Create docs/BRIEF.md:
This repository contains a Java challenge designed to assess the skills of a Java Guild candidate. The challenge includes problem-solving, coding, and demonstrating knowledge of Java fundamentals and best practices.
# Build a Library Management System
## Objective:
Create a simplified Library Management System (LMS) using Java that allows users to perform basic operations like adding books, borrowing books, and viewing available books.
# Requirements
## Book Class (visibility at candidate discretion)
Represents a book in the library.
### Attributes (visibility at candidate discretion)
* String title
* String author
* boolean isBorrowed
### Methods (visibility at candidate discretion)
* Book(String title, String author) - Constructor to initialise a book.
* boolean borrowBook() - Marks the book as borrowed if it's not already borrowed.
* boolean returnBook() - Marks the book as returned.
* String toString() - Returns a string representation of the book (e.g., "Title by Author (Available/Borrowed)").
## Library Class (visibility at candidate discretion)
Manages a collection of books.
### Attributes (visibility at candidate discretion)
* ArrayList<Book> books
### Methods (visibility at candidate discretion)
* void addBook(Book book) - Adds a new book to the collection.
* List<Book> viewAvailableBooks() - Returns a list of books that are not borrowed.
* Book searchBook(String title) - Searches for a book by title.
* boolean borrowBook(String title) - Allows a user to borrow a book by title.
* boolean returnBook(String title) - Allows a user to return a book by title.
## Main Class (visibility at candidate discretion)
Provides a menu-driven interface for the user to interact with the Library Management System.
Options include:
* Add a new book.
* View available books.
* Search for a book.
* Borrow a book.
* Return a book.
* Exit the application.
## Incremental role-based extra features (eg. an intermediate is expected to build Graduate+Intermediate)
* Graduate
* Unit Tests: Write test cases for Book and Library classes.
* Intermediate
* Exception Handling: Handle scenarios where a book is already borrowed, does not exist, or cannot be returned.
* Persistence: Save the library state to a file and reload it upon application restart.
* Advanced roles
* Persistence: Save the library state to a portable file-based database.
* Search: Find a book by title or author
* Sorting: Provide an option to view books sorted by title or author.
## Instructions for Submission
Create a zip file (.7z or .zip) containing the code and any other files for your project.
Include:
* The complete Java source code.
* A README.md file with instructions on how to run the program.
* A brief description of the approach taken and any additional features implemented.
* Email the zip file to your talent acquisition contact as an attachment when completed.
## Evaluation Criteria
* Code readability and organisation.
* Proper use of object-oriented programming principles.
* Handling edge cases and exceptions.
* Implementation of bonus features (if any).
* Clarity and thoroughness of the README file.
Step 3. Verify:
mvn clean test
# Tests run: 26, Failures: 0, Errors: 0
Step 4. Commit:
git add -A
git commit -F- <<'EOF'
docs: document the approach, decisions and deviations
Covers what the submission asks for: how to build and run, the approach
taken, and the additional work beyond the Intermediate tier.
Adds the things a reviewer will actually want: the twelve defects found
in the supplied code and what each cost the user, an index of the eight
architecture decisions, the two places this deviates from the brief's
stated signatures and why, and what was deliberately left unbuilt.
The supplied brief moves to docs/BRIEF.md unedited, so the README can be
the submission's own document without losing the original.
Written last, so it describes what the code does rather than what was
planned.
EOF