Java Guild course · The Rebuild · Phase 4
Phase 4 · commits 20–25
Three defects in the file format, each fixed where it lives before anything is moved. Then the two structural moves that the golden master proves changed nothing.
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.
fix · e031fa3
Fields were written raw and split on ';', so any title or author containing that character was torn across the field boundary. The golden master recorded the result:
Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Represents a library that holds a collection of books.
*/
public class Library {
/**
* Library Books.
*/
private final List<Book> books;and replace it with:
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Represents a library that holds a collection of books.
*/
public class Library {
/** Field separator used by the flat-file format. */
private static final String SEPARATOR = ";";
/**
* Library Books.
*/
private final List<Book> books;
Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:
public void saveLibraryState(final String filePath) throws IOException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : books) {
writer.write(book.getTitle() + ";"
+ book.getAuthor() + ";"
+ book.isBorrowed());
writer.newLine();
}
}and replace it with:
public void saveLibraryState(final String filePath) throws IOException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : books) {
writer.write(encode(book.getTitle()) + SEPARATOR
+ encode(book.getAuthor()) + SEPARATOR
+ book.isBorrowed());
writer.newLine();
}
}
Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(";");
String title = parts[0];
String author = parts[1];
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Book book = new Book(title, author);
if (isBorrowed) {
book.borrowBook();and replace it with:
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(SEPARATOR);
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Book book = new Book(title, author);
if (isBorrowed) {
book.borrowBook();
Step 4. In src/main/java/com/solirius/intermediate/library/Library.java, find:
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("no book titled '" + title + "'"));
}
}and replace it with:
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("no book titled '" + title + "'"));
}
private static String encode(final String field) {
return URLEncoder.encode(field, StandardCharsets.UTF_8);
}
private static String decode(final String field) {
return URLDecoder.decode(field, StandardCharsets.UTF_8);
}
}
Step 5. Create src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java:
package com.solirius.intermediate.library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DisplayName("The saved library state")
class LibraryStateFileTest {
@TempDir
Path directory;
private String stateFile() {
return directory.resolve("libraryState.txt").toString();
}
private Book reloadOnly(final Book book) throws Exception {
Library saved = new Library();
saved.addBook(book);
saved.saveLibraryState(stateFile());
Library reloaded = new Library();
reloaded.loadLibraryState(stateFile());
return reloaded.viewAvailableBooks().get(0);
}
@Test
@DisplayName("round-trips an ordinary title and author")
void roundTripsAnOrdinaryTitleAndAuthor() throws Exception {
Book book = reloadOnly(new Book("Dune", "Frank Herbert"));
assertEquals("Dune", book.getTitle());
assertEquals("Frank Herbert", book.getAuthor());
}
@Test
@DisplayName("round-trips a title containing the field separator")
void roundTripsATitleContainingTheFieldSeparator() throws Exception {
Book book = reloadOnly(
new Book("Coming Home; A Novel", "Rosamunde Pilcher"));
assertEquals("Coming Home; A Novel", book.getTitle());
assertEquals("Rosamunde Pilcher", book.getAuthor());
}
@Test
@DisplayName("round-trips a title containing a percent sign")
void roundTripsATitleContainingAPercentSign() throws Exception {
Book book = reloadOnly(new Book("100% Pure", "Anon"));
assertEquals("100% Pure", book.getTitle());
assertEquals("Anon", book.getAuthor());
}
}
Step 6. 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 7. Verify:
mvn clean test
# Tests run: 19, Failures: 0, Errors: 0
Step 8. Commit:
git add -A
git commit -F- <<'EOF'
fix: stop a separator in a title from corrupting the saved file
Fields were written raw and split on ';', so any title or author
containing that character was torn across the field boundary. The golden
master recorded the result:
added "Coming Home; A Novel" by Rosamunde Pilcher
reloaded Coming Home by A Novel
The title was truncated, the author was replaced by the tail of the
title, and the real author was discarded. Nothing reported a problem on
either side.
Fields are now URL-encoded on the way out and decoded on the way in, so
the separator cannot occur inside a field and every character is legal
in every field. A percent sign is covered too, since encoding introduces
one as its own escape character.
Chosen over picking a rarer delimiter, which only moves the problem to a
less likely input, and over quoting rules, which need their own parser.
Golden master re-approved: the semicolon journey now reloads intact.
EOF
fix · 5e7d5ff
loadLibraryState read parts[0], parts[1] and parts[2] without checking how many fields the line actually had, so a truncated or hand-edited file raised ArrayIndexOutOfBoundsException from inside the parser. The user saw a stack trace naming an array index, which says nothing about the file that caused it.
Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:
/** Field separator used by the flat-file format. */
private static final String SEPARATOR = ";";
/**
* Library Books.
*/
private final List<Book> books;and replace it with:
/** Field separator used by the flat-file format. */
private static final String SEPARATOR = ";";
/** Number of fields on every well-formed line. */
private static final int FIELD_COUNT = 3;
/**
* Library Books.
*/
private final List<Book> books;
Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:
books.clear();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(SEPARATOR);
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Book book = new Book(title, author);and replace it with:
books.clear();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
String[] parts = line.split(SEPARATOR, -1);
if (parts.length != FIELD_COUNT) {
throw new IOException("Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + parts.length);
}
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Book book = new Book(title, author);
Step 3. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DisplayName("The saved library state")
class LibraryStateFileTest {and replace it with:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayName("The saved library state")
class LibraryStateFileTest {
Step 4. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
assertEquals("100% Pure", book.getTitle());
assertEquals("Anon", book.getAuthor());
}
}and replace it with:
assertEquals("100% Pure", book.getTitle());
assertEquals("Anon", book.getAuthor());
}
@Test
@DisplayName("reports a truncated line instead of crashing on an index")
void reportsATruncatedLineInsteadOfCrashingOnAnIndex() throws Exception {
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
IOException thrown = assertThrows(IOException.class,
() -> new Library().loadLibraryState(stateFile()));
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());
}
}
Step 5. Verify:
mvn clean test
# Tests run: 20, Failures: 0, Errors: 0
Step 6. Commit:
git add -A
git commit -F- <<'EOF'
fix: report a truncated state file instead of crashing on an index
loadLibraryState read parts[0], parts[1] and parts[2] without checking
how many fields the line actually had, so a truncated or hand-edited
file raised ArrayIndexOutOfBoundsException from inside the parser. The
user saw a stack trace naming an array index, which says nothing about
the file that caused it.
The field count is validated and reported with the line number, so the
message points at the line to fix.
split now passes -1 as its limit. The default drops trailing empty
fields, so a line whose author and borrowed flag were both empty would
have arrived as one field and been reported as corrupt when it is
merely empty.
EOF
fix · 3f64eca
loadLibraryState called books.clear() before opening the file, so a missing, unreadable or corrupt file destroyed the in-memory library and left the caller with nothing. The failure was silent at the console: Main catches the exception and prints "Starting fresh", which was then literally true.
Step 1. In src/main/java/com/solirius/intermediate/library/Library.java, find:
* @param filePath the path to load the file
* @throws IOException if an I/O error occurs
*/
public void loadLibraryState(final String filePath) throws IOException {
books.clear();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
int lineNumber = 0;and replace it with:
* @param filePath the path to load the file
* @throws IOException if an I/O error occurs
*/
public void loadLibraryState(final String filePath) throws IOException {
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
int lineNumber = 0;
Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:
Book book = new Book(title, author);
if (isBorrowed) {
book.borrowBook();
}
books.add(book);
}
}
}
/**
* Finds a book by title or fails.and replace it with:
Book book = new Book(title, author);
if (isBorrowed) {
book.borrowBook();
}
loaded.add(book);
}
}
books.clear();
books.addAll(loaded);
}
/**
* Finds a book by title or fails.
Step 3. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());
}
}and replace it with:
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());
}
@Test
@DisplayName("leaves the library untouched when the file will not parse")
void leavesTheLibraryUntouchedWhenTheFileWillNotParse() throws Exception {
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
Library library = new Library();
library.addBook(new Book("Emma", "Jane Austen"));
assertThrows(IOException.class,
() -> library.loadLibraryState(stateFile()));
assertEquals(1, library.viewAvailableBooks().size(),
"a failed load must not empty the library it was loading into");
assertEquals("Emma",
library.viewAvailableBooks().get(0).getTitle());
}
}
Step 4. Verify:
mvn clean test
# Tests run: 21, Failures: 0, Errors: 0
Step 5. Commit:
git add -A
git commit -F- <<'EOF'
fix: keep the library intact when the state file will not parse
loadLibraryState called books.clear() before opening the file, so a
missing, unreadable or corrupt file destroyed the in-memory library and
left the caller with nothing. The failure was silent at the console:
Main catches the exception and prints "Starting fresh", which was then
literally true.
The replacement list is built first and swapped in only once the whole
file has parsed, so a load either fully succeeds or changes nothing.
This is the strong exception guarantee.
Now provable: a library holding Emma, told to load a corrupt file, still
holds Emma.
EOF
fix · d1101d6 · ADR 0005 →
The console had two unrelated failure types to handle — LibraryException for a broken rule, IOException for broken storage — for what is, to the user, the same event: something failed and there is a sentence saying what.
Step 1. Create docs/adr/0005-storage-failures-are-library-exceptions.md:
# 5. Storage failures are LibraryExceptions, not IOExceptions
Date: 2026-09-02
## Status
Accepted
## Context
`saveLibraryState` and `loadLibraryState` genuinely do perform I/O, so
`IOException` is not the category error it was on `borrowBook`. But it leaves
the console with two unrelated failure types to handle — `LibraryException` for
a domain rule, `IOException` for storage — for what is, from the user's side,
the same event: something went wrong and there is a sentence explaining it.
It also leaks the mechanism. A caller that catches `IOException` is coupled to
the fact that state happens to live in a file today. ADR 0006 replaces that
file with an interface precisely so it can be something else tomorrow.
## Decision
Wrap storage failures in `LibraryStorageException`, a subclass of
`LibraryException`, preserving the original as its cause.
## Consequences
- The console has exactly one failure type to catch, whatever went wrong.
- The cause is retained, so the underlying `IOException` is still available in
a stack trace when debugging.
- A future SQLite implementation raises the same type from `SQLException`; no
caller changes.
- A missing state file is reported rather than silently treated as an empty
library. On a genuinely first run the user sees *"Could not load library
state. Starting fresh."*, which is noise exactly once, and is preferred to a
corrupt file being indistinguishable from no file at all.
Step 2. In src/main/java/com/solirius/intermediate/library/Library.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import java.io.BufferedReader;
import java.io.BufferedWriter;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:
/**
* Saves the state of the library to a file.
*
* @param filePath the path to save the file
* @throws IOException if an I/O error occurs
*/
public void saveLibraryState(final String filePath) throws IOException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : books) {
writer.write(encode(book.getTitle()) + SEPARATOR
+ encode(book.getAuthor()) + SEPARATOR
+ book.isBorrowed());
writer.newLine();
}
}
}
/**
* Loads the state of the library from a file.
*
* @param filePath the path to load the file
* @throws IOException if an I/O error occurs
*/
public void loadLibraryState(final String filePath) throws IOException {
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;and replace it with:
/**
* Saves the state of the library to a file.
*
* @param filePath the path to save the file
* @throws LibraryStorageException if the state cannot be written
*/
public void saveLibraryState(final String filePath)
throws LibraryStorageException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : books) {
writer.write(encode(book.getTitle()) + SEPARATOR
+ encode(book.getAuthor()) + SEPARATOR
+ book.isBorrowed());
writer.newLine();
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not save library state to " + filePath, e);
}
}
/**
* Loads the state of the library from a file.
*
* @param filePath the path to load the file
* @throws LibraryStorageException if the state cannot be read or parsed
*/
public void loadLibraryState(final String filePath)
throws LibraryStorageException {
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
Step 4. In src/main/java/com/solirius/intermediate/library/Library.java, find:
while ((line = reader.readLine()) != null) {
lineNumber++;
String[] parts = line.split(SEPARATOR, -1);
if (parts.length != FIELD_COUNT) {
throw new IOException("Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + parts.length);
}
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);and replace it with:
while ((line = reader.readLine()) != null) {
lineNumber++;
String[] parts = line.split(SEPARATOR, -1);
if (parts.length != FIELD_COUNT) {
throw new LibraryStorageException(
"Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + parts.length);
}
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Step 5. In src/main/java/com/solirius/intermediate/library/Library.java, find:
book.borrowBook();
}
loaded.add(book);
}
}
books.clear();
books.addAll(loaded);
}and replace it with:
book.borrowBook();
}
loaded.add(book);
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not read library state from " + filePath, e);
}
books.clear();
books.addAll(loaded);
}
Step 6. In src/main/java/com/solirius/intermediate/library/Main.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryException;
import java.io.IOException;
import java.util.Scanner;
public final class Main {and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryException;
import java.util.Scanner;
public final class Main {
Step 7. In src/main/java/com/solirius/intermediate/library/Main.java, find:
// Load library state
try {
library.loadLibraryState(filePath);
System.out.println("Library state loaded successfully.");
} catch (IOException e) {
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);and replace it with:
// Load library state
try {
library.loadLibraryState(filePath);
System.out.println("Library state loaded successfully.");
} catch (LibraryException e) {
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);
Step 8. In src/main/java/com/solirius/intermediate/library/Main.java, find:
System.out.println("Saving library state...");
try {
library.saveLibraryState(filePath);
System.out.println("Library state saved successfully.");
} catch (IOException e) {
System.out.println(e.getMessage());
}
running = false;
System.out.println("Thank you for using "and replace it with:
System.out.println("Saving library state...");
try {
library.saveLibraryState(filePath);
System.out.println("Library state saved successfully.");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}
running = false;
System.out.println("Thank you for using "
Step 9. Create src/main/java/com/solirius/intermediate/library/exceptions/LibraryStorageException.java:
package com.solirius.intermediate.library.exceptions;
/**
* Thrown when the library's saved state cannot be read or written.
*
* <p>A LibraryException rather than an IOException so that a caller which
* only wants to report a failure to the user has one type to catch.
*/
public class LibraryStorageException extends LibraryException {
/**
* Constructs a new LibraryStorageException with the specified message.
*
* @param message the detail message
*/
public LibraryStorageException(final String message) {
super(message);
}
/**
* Constructs a new LibraryStorageException with a message and a cause.
*
* @param message the detail message
* @param cause the underlying failure
*/
public LibraryStorageException(final String message,
final Throwable cause) {
super(message);
initCause(cause);
}
}
Step 10. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
package com.solirius.intermediate.library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
Step 11. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
@DisplayName("reports a truncated line instead of crashing on an index")
void reportsATruncatedLineInsteadOfCrashingOnAnIndex() throws Exception {
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
IOException thrown = assertThrows(IOException.class,
() -> new Library().loadLibraryState(stateFile()));
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());and replace it with:
@DisplayName("reports a truncated line instead of crashing on an index")
void reportsATruncatedLineInsteadOfCrashingOnAnIndex() throws Exception {
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
LibraryStorageException thrown = assertThrows(
LibraryStorageException.class,
() -> new Library().loadLibraryState(stateFile()));
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());
Step 12. In src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java, find:
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
Library library = new Library();
library.addBook(new Book("Emma", "Jane Austen"));
assertThrows(IOException.class,
() -> library.loadLibraryState(stateFile()));
assertEquals(1, library.viewAvailableBooks().size(),
"a failed load must not empty the library it was loading into");and replace it with:
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
Library library = new Library();
library.addBook(new Book("Emma", "Jane Austen"));
assertThrows(LibraryStorageException.class,
() -> library.loadLibraryState(stateFile()));
assertEquals(1, library.viewAvailableBooks().size(),
"a failed load must not empty the library it was loading into");
Step 13. Verify:
mvn clean test
# Tests run: 21, Failures: 0, Errors: 0
Step 14. Commit:
git add -A
git commit -F- <<'EOF'
fix: raise LibraryStorageException from save and load
The console had two unrelated failure types to handle — LibraryException
for a broken rule, IOException for broken storage — for what is, to the
user, the same event: something failed and there is a sentence saying
what.
Storage failures are now wrapped in LibraryStorageException, a subclass
of LibraryException, keeping the original exception as its cause. Main
catches one type for everything and IOException no longer appears in it.
This also stops the persistence mechanism leaking through the signature.
A caller that catches IOException is coupled to state living in a file,
which the next commit is about to stop being true.
Decision recorded in docs/adr/0005-storage-failures-are-library-exceptions.md
EOF
refactor · 344f8f3 · ADR 0006 →
Library was a collection of books and a file format in one class, so it had two reasons to change and could not be unit tested without a disk.
Step 1. Move the file:
git mv src/test/java/com/solirius/intermediate/library/LibraryStateFileTest.java \
src/test/java/com/solirius/intermediate/library/FileLibraryRepositoryTest.java
Step 2. Create docs/adr/0006-persistence-lives-behind-a-repository-interface.md:
# 6. Persistence lives behind a LibraryRepository interface
Date: 2026-09-02
## Status
Accepted
## Context
The supplied `Library` was two things at once: a collection of books, and a
flat-file format. `addBook` and `borrowBook` sat in the same class as
`BufferedWriter`, `URLEncoder` and a field separator.
That gives the class two reasons to change — a new library rule, or a new
storage format — and it makes the domain untestable without touching a disk.
The brief's Advanced tier asks for state to be kept in "a portable file-based
database", and the supplied `pom.xml` already declares `sqlite-jdbc` as a
dependency, unused. The shape of the next requirement is visible from here.
## Decision
Introduce a `LibraryRepository` interface with `save(Library)` and
`load()`, implemented by `FileLibraryRepository`. `Library` keeps only the
collection behaviour and exposes `getBooks()` as an unmodifiable view for a
repository to read.
`load()` returns a **new** `Library` rather than filling in an existing one.
## Consequences
- `Library` has one reason to change, and its unit tests touch no file.
- Satisfying the Advanced tier becomes a new class implementing this interface,
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.
Step 3. In src/main/java/com/solirius/intermediate/library/Library.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Represents a library that holds a collection of books.
*/
public class Library {
/** Field separator used by the flat-file format. */
private static final String SEPARATOR = ";";
/** Number of fields on every well-formed line. */
private static final int FIELD_COUNT = 3;
/**
* Library Books.
*/
private final List<Book> books;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* Represents a library that holds a collection of books.
*/
public class Library {
/**
* Library Books.
*/
private final List<Book> books;
Step 4. In src/main/java/com/solirius/intermediate/library/Library.java, find:
public void addBook(final Book book) {
books.add(book);
}
/**
* Gets a list of available books.
*
* @return a list of books that are not borrowedand replace it with:
public void addBook(final Book book) {
books.add(book);
}
/**
* Gets every book the library holds, on loan or not.
*
* @return an unmodifiable view of the collection
*/
public List<Book> getBooks() {
return Collections.unmodifiableList(books);
}
/**
* Gets a list of available books.
*
* @return a list of books that are not borrowed
Step 5. In src/main/java/com/solirius/intermediate/library/Library.java, find:
"'" + book.getTitle() + "' is not currently borrowed");
}
}
/**
* Saves the state of the library to a file.
*
* @param filePath the path to save the file
* @throws LibraryStorageException if the state cannot be written
*/
public void saveLibraryState(final String filePath)
throws LibraryStorageException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : books) {
writer.write(encode(book.getTitle()) + SEPARATOR
+ encode(book.getAuthor()) + SEPARATOR
+ book.isBorrowed());
writer.newLine();
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not save library state to " + filePath, e);
}
}
/**
* Loads the state of the library from a file.
*
* @param filePath the path to load the file
* @throws LibraryStorageException if the state cannot be read or parsed
*/
public void loadLibraryState(final String filePath)
throws LibraryStorageException {
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
String[] parts = line.split(SEPARATOR, -1);
if (parts.length != FIELD_COUNT) {
throw new LibraryStorageException(
"Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + parts.length);
}
String title = decode(parts[0]);
String author = decode(parts[1]);
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Book book = new Book(title, author);
if (isBorrowed) {
book.borrowBook();
}
loaded.add(book);
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not read library state from " + filePath, e);
}
books.clear();
books.addAll(loaded);
}
/**
* Finds a book by title or fails.
*
* @param title the title to look forand replace it with:
"'" + book.getTitle() + "' is not currently borrowed");
}
}
/**
* Finds a book by title or fails.
*
* @param title the title to look for
Step 6. In src/main/java/com/solirius/intermediate/library/Library.java, find:
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("no book titled '" + title + "'"));
}
private static String encode(final String field) {
return URLEncoder.encode(field, StandardCharsets.UTF_8);
}
private static String decode(final String field) {
return URLDecoder.decode(field, StandardCharsets.UTF_8);
}
}and replace it with:
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("no book titled '" + title + "'"));
}
}
Step 7. In src/main/java/com/solirius/intermediate/library/Main.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryException;
import java.util.Scanner;
public final class Main {and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
import java.util.Scanner;
public final class Main {
Step 8. In src/main/java/com/solirius/intermediate/library/Main.java, find:
* Initialises the LMS program.
* @param args from the command line.
*/
public static void main(final String[] args) {
Library library = new Library();
String filePath = "libraryState.txt";
// Load library state
try {
library.loadLibraryState(filePath);
System.out.println("Library state loaded successfully.");
} catch (LibraryException e) {
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);and replace it with:
* Initialises the LMS program.
* @param args from the command line.
*/
public static void main(final String[] args) {
String filePath = "libraryState.txt";
LibraryRepository repository = new FileLibraryRepository(filePath);
Library library;
// Load library state
try {
library = repository.load();
System.out.println("Library state loaded successfully.");
} catch (LibraryException e) {
library = new Library();
System.out.println("Could not load library state. Starting fresh.");
}
Scanner scanner = new Scanner(System.in);
Step 9. In src/main/java/com/solirius/intermediate/library/Main.java, find:
case EXIT_OPTION:
System.out.println("Saving library state...");
try {
library.saveLibraryState(filePath);
System.out.println("Library state saved successfully.");
} catch (LibraryException e) {
System.out.println(e.getMessage());
}and replace it with:
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());
}
Step 10. Create src/main/java/com/solirius/intermediate/library/repository/FileLibraryRepository.java:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.Book;
import com.solirius.intermediate.library.Library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Keeps the library in a flat file, one URL-encoded record per line.
*/
public class FileLibraryRepository implements LibraryRepository {
/** Field separator used by the flat-file format. */
private static final String SEPARATOR = ";";
/** Number of fields on every well-formed line. */
private static final int FIELD_COUNT = 3;
/** Where the state is kept. */
private final String filePath;
/**
* Creates a repository backed by the given file.
*
* @param theFilePath the file to read from and write to
*/
public FileLibraryRepository(final String theFilePath) {
this.filePath = theFilePath;
}
@Override
public void save(final Library library) throws LibraryStorageException {
try (BufferedWriter writer =
new BufferedWriter(new FileWriter(filePath))) {
for (Book book : library.getBooks()) {
writer.write(encode(book.getTitle()) + SEPARATOR
+ encode(book.getAuthor()) + SEPARATOR
+ book.isBorrowed());
writer.newLine();
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not save library state to " + filePath, e);
}
}
@Override
public Library load() throws LibraryStorageException {
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader =
new BufferedReader(new FileReader(filePath))) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
loaded.add(parse(line, lineNumber));
}
} catch (IOException e) {
throw new LibraryStorageException(
"Could not read library state from " + filePath, e);
}
Library library = new Library();
for (Book book : loaded) {
library.addBook(book);
}
return library;
}
private Book parse(final String line, final int lineNumber)
throws LibraryStorageException {
String[] fields = line.split(SEPARATOR, -1);
if (fields.length != FIELD_COUNT) {
throw new LibraryStorageException("Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + fields.length);
}
Book book = new Book(decode(fields[0]), decode(fields[1]));
if (Boolean.parseBoolean(fields[2])) {
book.borrowBook();
}
return book;
}
private static String encode(final String field) {
return URLEncoder.encode(field, StandardCharsets.UTF_8);
}
private static String decode(final String field) {
return URLDecoder.decode(field, StandardCharsets.UTF_8);
}
}
Step 11. Create src/main/java/com/solirius/intermediate/library/repository/LibraryRepository.java:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.Library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
/**
* Somewhere a library's state can be kept between runs.
*/
public interface LibraryRepository {
/**
* Writes the whole library, replacing anything already stored.
*
* @param library the library to store
* @throws LibraryStorageException if the state cannot be written
*/
void save(Library library) throws LibraryStorageException;
/**
* Reads a library back.
*
* @return the stored library
* @throws LibraryStorageException if the state cannot be read or parsed
*/
Library load() throws LibraryStorageException;
}
Step 12. In src/test/java/com/solirius/intermediate/library/FileLibraryRepositoryTest.java, add:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayName("The file-backed library repository")
class FileLibraryRepositoryTest {
@TempDir
Path directory;
private String stateFile() {
return directory.resolve("libraryState.txt").toString();
}
private LibraryRepository repository() {
return new FileLibraryRepository(stateFile());
}
private Book reloadOnly(final Book book) throws Exception {
Library saved = new Library();
saved.addBook(book);
repository().save(saved);
return repository().load().viewAvailableBooks().get(0);
}
@Test
@DisplayName("round-trips an ordinary title and author")
void roundTripsAnOrdinaryTitleAndAuthor() throws Exception {
Book book = reloadOnly(new Book("Dune", "Frank Herbert"));
assertEquals("Dune", book.getTitle());
assertEquals("Frank Herbert", book.getAuthor());
}
@Test
@DisplayName("round-trips a title containing the field separator")
void roundTripsATitleContainingTheFieldSeparator() throws Exception {
Book book = reloadOnly(
new Book("Coming Home; A Novel", "Rosamunde Pilcher"));
assertEquals("Coming Home; A Novel", book.getTitle());
assertEquals("Rosamunde Pilcher", book.getAuthor());
}
@Test
@DisplayName("round-trips a title containing a percent sign")
void roundTripsATitleContainingAPercentSign() throws Exception {
Book book = reloadOnly(new Book("100% Pure", "Anon"));
assertEquals("100% Pure", book.getTitle());
assertEquals("Anon", book.getAuthor());
}
@Test
@DisplayName("reports a truncated line instead of crashing on an index")
void reportsATruncatedLineInsteadOfCrashingOnAnIndex() throws Exception {
Files.write(Path.of(stateFile()), List.of("Dune;Frank Herbert"));
LibraryStorageException thrown = assertThrows(
LibraryStorageException.class,
() -> repository().load());
assertEquals("Corrupt library state at line 1: "
+ "expected 3 fields but found 2", thrown.getMessage());
}
@Test
@DisplayName("reports a state file that is not there")
void reportsAStateFileThatIsNotThere() {
LibraryRepository absent =
new FileLibraryRepository(directory.resolve("gone.txt").toString());
assertThrows(LibraryStorageException.class, absent::load,
"a missing file must be reported, not read as an empty library");
}
}
Step 13. Verify:
mvn clean test
# Tests run: 21, Failures: 0, Errors: 0
Step 14. Commit:
git add -A
git commit -F- <<'EOF'
refactor: extract LibraryRepository from Library
Library was a collection of books and a file format in one class, so it
had two reasons to change and could not be unit tested without a disk.
The file logic moves to FileLibraryRepository behind a LibraryRepository
interface. Library keeps the collection behaviour and exposes getBooks()
as an unmodifiable view for a repository to read. Main names the
implementation once, where it wires things up.
The brief's Advanced tier asks for a portable file-based database and
the supplied pom already declares sqlite-jdbc, unused. With this seam
that is a new class, not an edit to Library.
load() returns a new Library rather than filling in an existing one,
which makes the earlier clear-then-parse defect impossible instead of
merely fixed. The test that covered it is dropped: a failed load has no
caller collection to damage, so the test can no longer fail. A test that
cannot fail is worse than no test, because it reads like cover.
No behaviour change. The tests moved to FileLibraryRepositoryTest with
no assertion altered, and the golden master needed no re-approval.
Decision recorded in
docs/adr/0006-persistence-lives-behind-a-repository-interface.md
EOF
refactor · edd7a1a
Book, Library and the exceptions now sit in .domain, alongside .repository added in the previous commit. The root package keeps the entry point and the user interface.
Step 1. Move the file:
git mv src/main/java/com/solirius/intermediate/library/Book.java \
src/main/java/com/solirius/intermediate/library/domain/Book.java
Step 2. Move the file:
git mv src/main/java/com/solirius/intermediate/library/Library.java \
src/main/java/com/solirius/intermediate/library/domain/Library.java
Step 3. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/AlreadyBorrowedException.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/AlreadyBorrowedException.java
Step 4. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/BookNotFoundException.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/BookNotFoundException.java
Step 5. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/LibraryException.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/LibraryException.java
Step 6. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/LibraryStorageException.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/LibraryStorageException.java
Step 7. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/NotBorrowedException.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/NotBorrowedException.java
Step 8. Move the file:
git mv src/main/java/com/solirius/intermediate/library/exceptions/package-info.java \
src/main/java/com/solirius/intermediate/library/domain/exceptions/package-info.java
Step 9. In src/main/java/com/solirius/intermediate/library/Main.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
import java.util.Scanner;and replace it with:
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;
import java.util.Scanner;
Step 10. In src/main/java/com/solirius/intermediate/library/domain/Book.java, add:
package com.solirius.intermediate.library.domain;
/**
* Represents a book in the library.
*/
public class Book {
/**
* The book's title.
*/
private final String title;
/**
* The book's author.
*/
private final String author;
/**
* Defines whether the book has been borrowed.
*/
private boolean isBorrowed;
/**
* Creates a book with a title and author.
* The book is initially not borrowed.
*
* @param theTitle the title of the book
* @param theAuthor the author of the book
*/
public Book(final String theTitle, final String theAuthor) {
this.title = theTitle;
this.author = theAuthor;
this.isBorrowed = false;
}
/**
* Borrows the book if it's not already borrowed.
*
* @return true if the book is successfully borrowed, otherwise false
*/
public boolean borrowBook() {
if (!isBorrowed) {
isBorrowed = true;
return true;
}
return false;
}
/**
* Returns the book if it's currently borrowed.
*
* @return true if the book is successfully returned, otherwise false
*/
public boolean returnBook() {
if (isBorrowed) {
isBorrowed = false;
return true;
}
return false;
}
/**
* Helps printing a book's features.
* @return a string representation of the book.
*/
@Override
public String toString() {
return title + " by " + author
+ " (" + (isBorrowed ? "Borrowed" : "Available") + ")";
}
/**
* Gets the title of the book.
*
* @return the title of the book
*/
public String getTitle() {
return title;
}
/**
* Checks if the book is borrowed.
*
* @return true if the book is borrowed, otherwise false
*/
public boolean isBorrowed() {
return isBorrowed;
}
/**
* Gets the author of the book.
*
* @return the author of the book
*/
public String getAuthor() {
return author;
}
}
Step 11. In src/main/java/com/solirius/intermediate/library/domain/Library.java, add:
package com.solirius.intermediate.library.domain;
import com.solirius.intermediate.library.domain.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.domain.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.domain.exceptions.NotBorrowedException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* Represents a library that holds a collection of books.
*/
public class Library {
/**
* Library Books.
*/
private final List<Book> books;
/**
* Creates an empty library.
*/
public Library() {
this.books = new ArrayList<>();
}
/**
* Adds a book to the library.
*
* @param book the book to add
*/
public void addBook(final Book book) {
books.add(book);
}
/**
* Gets every book the library holds, on loan or not.
*
* @return an unmodifiable view of the collection
*/
public List<Book> getBooks() {
return Collections.unmodifiableList(books);
}
/**
* Gets a list of available books.
*
* @return a list of books that are not borrowed
*/
public List<Book> viewAvailableBooks() {
List<Book> availableBooks = new ArrayList<>();
for (Book book : books) {
if (!book.isBorrowed()) {
availableBooks.add(book);
}
}
return availableBooks;
}
/**
* Searches for a book by its title.
*
* @param title the title of the book to search
* @return the book if the library holds it, otherwise empty
*/
public Optional<Book> searchBook(final String title) {
for (Book book : books) {
if (book.getTitle().equalsIgnoreCase(title)) {
return Optional.of(book);
}
}
return Optional.empty();
}
/**
* Borrows a book by its title.
*
* @param title the title of the book to borrow
* @throws BookNotFoundException if no book has that title
* @throws AlreadyBorrowedException if the book is already on loan
*/
public void borrowBook(final String title)
throws BookNotFoundException, AlreadyBorrowedException {
Book book = requireBook(title);
if (!book.borrowBook()) {
throw new AlreadyBorrowedException(
"'" + book.getTitle() + "' is already borrowed");
}
}
/**
* Returns a book by its title.
*
* @param title the title of the book to return
* @throws BookNotFoundException if no book has that title
* @throws NotBorrowedException if the book is not currently on loan
*/
public void returnBook(final String title)
throws BookNotFoundException, NotBorrowedException {
Book book = requireBook(title);
if (!book.returnBook()) {
throw new NotBorrowedException(
"'" + book.getTitle() + "' is not currently borrowed");
}
}
/**
* Finds a book by title or fails.
*
* @param title the title to look for
* @return the book the library holds under that title
* @throws BookNotFoundException if the library holds no such book
*/
private Book requireBook(final String title) throws BookNotFoundException {
return searchBook(title).orElseThrow(() ->
new BookNotFoundException("no book titled '" + title + "'"));
}
}
Step 12. In src/main/java/com/solirius/intermediate/library/domain/exceptions/AlreadyBorrowedException.java, add:
package com.solirius.intermediate.library.domain.exceptions;
/**
* Exception thrown when attempting to borrow a book that is already borrowed.
*/
public class AlreadyBorrowedException extends LibraryException {
/**
* Constructs a new AlreadyBorrowedException with the specified message.
*
* @param message the detail message
*/
public AlreadyBorrowedException(final String message) {
super(message);
}
}
Step 13. In src/main/java/com/solirius/intermediate/library/domain/exceptions/BookNotFoundException.java, add:
package com.solirius.intermediate.library.domain.exceptions;
/**
* Exception thrown when a book is not found in the library.
*/
public class BookNotFoundException extends LibraryException {
/**
* Constructs a new BookNotFoundException with the specified message.
*
* @param message the detail message
*/
public BookNotFoundException(final String message) {
super(message);
}
}
Step 14. In src/main/java/com/solirius/intermediate/library/domain/exceptions/LibraryException.java, add:
package com.solirius.intermediate.library.domain.exceptions;
/**
* Base type for every failure of a library rule.
*
* <p>Lets a caller that only wants to report the problem catch one type,
* while a caller that needs to tell the cases apart still can.
*/
public class LibraryException extends Exception {
/**
* Constructs a new LibraryException with the specified message.
*
* @param message the detail message
*/
public LibraryException(final String message) {
super(message);
}
}
Step 15. In src/main/java/com/solirius/intermediate/library/domain/exceptions/LibraryStorageException.java, add:
package com.solirius.intermediate.library.domain.exceptions;
/**
* Thrown when the library's saved state cannot be read or written.
*
* <p>A LibraryException rather than an IOException so that a caller which
* only wants to report a failure to the user has one type to catch.
*/
public class LibraryStorageException extends LibraryException {
/**
* Constructs a new LibraryStorageException with the specified message.
*
* @param message the detail message
*/
public LibraryStorageException(final String message) {
super(message);
}
/**
* Constructs a new LibraryStorageException with a message and a cause.
*
* @param message the detail message
* @param cause the underlying failure
*/
public LibraryStorageException(final String message,
final Throwable cause) {
super(message);
initCause(cause);
}
}
Step 16. In src/main/java/com/solirius/intermediate/library/domain/exceptions/NotBorrowedException.java, add:
package com.solirius.intermediate.library.domain.exceptions;
/**
* Exception thrown when attempting to return a book that was not borrowed.
*/
public class NotBorrowedException extends LibraryException {
/**
* Constructs a new NotBorrowedException with the specified message.
*
* @param message the detail message
*/
public NotBorrowedException(final String message) {
super(message);
}
}
Step 17. In src/main/java/com/solirius/intermediate/library/domain/exceptions/package-info.java, add:
/**
* This package contains custom exceptions used in
* the Library Management System.
*
* <ul>
* <li>{@link
* com.solirius.intermediate.library.domain.exceptions.AlreadyBorrowedException} -
* Thrown when attempting to borrow a book that is already borrowed.</li>
* <li>{@link
* com.solirius.intermediate.library.domain.exceptions.BookNotFoundException} -
* Thrown when a requested book is not found in the library.</li>
* <li>{@link
* com.solirius.intermediate.library.domain.exceptions.NotBorrowedException} -
* Thrown when attempting to return a book that was not borrowed.</li>
* </ul>
*/
package com.solirius.intermediate.library.domain.exceptions;
Step 18. Create src/main/java/com/solirius/intermediate/library/domain/package-info.java:
/**
* The library domain: the rules about books and lending, and nothing else.
*
* <p>The core types are:
* </p>
* <ul>
* <li>{@code Book} — a title and an author, and whether it is on loan.</li>
* <li>{@code Library} — the collection, and the rules for borrowing from
* and returning to it.</li>
* </ul>
* <p>
* Nothing here knows how the collection is stored or how a user reaches it.
* Storage is reached through
* {@code com.solirius.intermediate.library.repository.LibraryRepository};
* the console lives in the parent package.
* </p>
* <p>
* Example usage:
* </p>
* <pre>
* Library library = new Library();
* library.addBook(new Book("The Great Gatsby", "F. Scott Fitzgerald"));
* library.borrowBook("The Great Gatsby");
* </pre>
*
* @since 1.0
*/
package com.solirius.intermediate.library.domain;
Step 19. Delete src/main/java/com/solirius/intermediate/library/package-info.java:
git rm src/main/java/com/solirius/intermediate/library/package-info.java
Step 20. In src/main/java/com/solirius/intermediate/library/repository/FileLibraryRepository.java, find:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.Book;
import com.solirius.intermediate.library.Library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;and replace it with:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.domain.Book;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryStorageException;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
Step 21. In src/main/java/com/solirius/intermediate/library/repository/LibraryRepository.java, find:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.Library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
/**
* Somewhere a library's state can be kept between runs.
*/and replace it with:
package com.solirius.intermediate.library.repository;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryStorageException;
/**
* Somewhere a library's state can be kept between runs.
*/
Step 22. In src/test/java/com/solirius/intermediate/library/BookTest.java, find:
package com.solirius.intermediate.library;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Book;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Step 23. In src/test/java/com/solirius/intermediate/library/FileLibraryRepositoryTest.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.LibraryStorageException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Book;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.LibraryStorageException;
import com.solirius.intermediate.library.repository.FileLibraryRepository;
import com.solirius.intermediate.library.repository.LibraryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Step 24. In src/test/java/com/solirius/intermediate/library/LibraryTest.java, find:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.exceptions.NotBorrowedException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;and replace it with:
package com.solirius.intermediate.library;
import com.solirius.intermediate.library.domain.Book;
import com.solirius.intermediate.library.domain.Library;
import com.solirius.intermediate.library.domain.exceptions.AlreadyBorrowedException;
import com.solirius.intermediate.library.domain.exceptions.BookNotFoundException;
import com.solirius.intermediate.library.domain.exceptions.NotBorrowedException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
Step 25. Verify:
mvn clean test
# Tests run: 21, Failures: 0, Errors: 0
Step 26. Commit:
git add -A
git commit -F- <<'EOF'
refactor: move the domain types into a domain package
Book, Library and the exceptions now sit in .domain, alongside .repository
added in the previous commit. The root package keeps the entry point and
the user interface.
The layout now states the dependency rule that the last two commits
established: .domain depends on nothing, .repository depends on .domain,
and the root depends on both. A cycle would be visible as an import
pointing the wrong way.
The supplied package-info described attributes and operations the code
has never had — an ISBN field, user registration, removing and updating
books — and calling Library "the controller of the system" is the
arrangement this refactor undid. Rewritten to describe what is actually
in the package.
No behaviour change: package declarations and imports only, and the
golden master needed no re-approval.
EOF