Java Guild course · Lesson 0005
Lesson 0005 · the second intermediate requirement
A delimiter that appears in the data, a parser with no validation, and a load that destroys the library before it fails.
The save/load code works — for well-behaved input. The Intermediate requirement asks for persistence, and the starter has persistence, so it is tempting to tick it and move on. Three bugs say otherwise, and all three are the kind that reach production.
New file: LibraryPersistenceTest. Use @TempDir so no
test ever writes into your repository.
@TempDir
private Path directory;
@Test
@DisplayName("preserves a title that contains the field separator")
void preservesATitleContainingTheSeparator() throws Exception {
Path file = directory.resolve("state.txt");
Library saved = new Library();
saved.addBook(new Book("Coming Home; A Novel", "Rosamunde Pilcher"));
saved.saveLibraryState(file.toString());
Library reloaded = new Library();
reloaded.loadLibraryState(file.toString());
Book book = reloaded.viewAvailableBooks().get(0);
assertEquals("Coming Home; A Novel", book.getTitle());
assertEquals("Rosamunde Pilcher", book.getAuthor());
}
Then two more: a corrupt file should raise a meaningful exception, and a load that fails should leave the in-memory library untouched.
[ERROR] Tests run: 13, Failures: 3, Errors: 0, Skipped: 0
preservesATitleContainingTheSeparator:33
expected: <Coming Home; A Novel> but was: <Coming Home>
rejectsACorruptFile:44
Unexpected exception type thrown, expected: <...LibraryStorageException>
but was: <java.lang.ArrayIndexOutOfBoundsException>
leavesTheLibraryUntouchedWhenTheFileCannotBeRead:57
expected: <1> but was: <0>
Three failures, three distinct defects. Take them in order.
writer.write(book.getTitle() + ";" + book.getAuthor() + ";" + book.isBorrowed());
Book titles contain semicolons. Coming Home; A Novel is a real book. So is Roget’s Thesaurus; or, Classified Collection of Words. When one is saved, the file contains four fields on a line the reader splits into three, and the reader takes the first three:
file: Coming Home; A Novel;Rosamunde Pilcher;false
split(";"): ["Coming Home", " A Novel", "Rosamunde Pilcher", "false"]
parsed as: title="Coming Home" author=" A Novel" isBorrowed=false
displayed: Coming Home by A Novel (Available)
Rosamunde Pilcher is gone. No exception, no warning, no log line. The user sees a book with a mangled title and an author that is half a title, and nothing anywhere records that data was destroyed.
This is silent data corruption, and it is the worst category
there is. A crash is found in minutes. Corruption is found months later, by
which time the backups have rotated. When you describe this defect in interview,
use those words — the severity judgement is the thing being assessed, not the
split.
| Option | Verdict |
|---|---|
| Pick a rarer delimiter (a tab, a pipe, a control character) | No. It shrinks the bug, it does not fix it. “Users probably won’t type a tab” is not a correctness argument. |
| Escape the delimiter | Yes. ~20 lines, no dependency, and it makes any character legal in any field. This is what the reference solution does. |
| Use a real format (JSON, a CSV library) | Correct in principle, but the pom has no JSON or CSV dependency, and hand-rolling RFC 4180 quoting is more code than escaping. |
/** Protects separator and escape characters inside a field. */
private static String escape(final String field) {
return field.replace(ESCAPE, ESCAPE + ESCAPE) // backslash first!
.replace(SEPARATOR, ESCAPE + SEPARATOR);
}
Escape the escape character before the separator. Do it the other way round and you escape the backslashes you just inserted, so a title containing a backslash round-trips wrong. This is the classic off-by-one of escaping schemes, and a test with a backslash in the title is worth adding purely to pin it.
String[] parts = line.split(";");
String title = parts[0];
String author = parts[1];
boolean isBorrowed = Boolean.parseBoolean(parts[2]);
Truncate the file — a disk fills, a process is killed mid-write, someone edits it by hand — and you get:
java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
Which tells the user nothing at all. Validate the field count, and say what is wrong and where:
if (fields.size() != FIELD_COUNT) {
throw new LibraryStorageException("Corrupt library state at line "
+ lineNumber + ": expected " + FIELD_COUNT
+ " fields but found " + fields.size());
}
Note the line number. An exception that names the line is an exception someone can act on.
LibraryStorageException belong?
Make it extends LibraryException, so the console still catches
one type. The purist objection is real and you should know it: storage failure is
infrastructure, not domain, and bundling them means a caller cannot
distinguish “your input was wrong” from “the disk is
broken”.
The defence is that in this application the response is identical — tell the user, keep running — and the subtype is still there for anyone who needs the distinction. Say both halves in interview and you have shown you understand the trade-off rather than stumbled into one side of it.
This is the subtlest of the three and the best one to have spotted.
public void loadLibraryState(final String filePath) throws IOException {
books.clear(); // happens first
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
... // this is what can fail
}
}
The library is emptied before the file is opened. If the file is missing, unreadable, or corrupt halfway through, the books already in memory are gone and the exception arrives too late to matter. Verified:
library.addBook(new Book("Dune", "Frank Herbert"));
library.loadLibraryState("/nonexistent/nope.txt"); // throws
library.viewAvailableBooks().size(); -> 0
The fix is to build the replacement completely, and only then swap it in:
List<Book> loaded = new ArrayList<>();
try (BufferedReader reader = ...) {
while ((line = reader.readLine()) != null) {
loaded.add(parseLine(line, ++lineNumber)); // may throw; nothing lost yet
}
} catch (IOException e) {
throw new LibraryStorageException("Could not read library state from " + filePath, e);
}
books.clear();
books.addAll(loaded); // only reached on success
This is the strong exception guarantee: if an operation throws, the object is left in the state it started in. Being able to name it — and to point at the two lines that provide it — is a genuinely senior signal in an interview, because most candidates fix the ordering by instinct without knowing there is a term for it.
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Make it work, then make it right. With the tests green you can now
address the design problem you have been ignoring: Library is
both a collection of books and a file format. Two reasons to change, one
class.
public interface LibraryRepository {
void save(Library library) throws LibraryStorageException;
Library load() throws LibraryStorageException;
}
public class FileLibraryRepository implements LibraryRepository { ... }
Because the Advanced tier of the brief asks for a
“portable file-based database”, and the pom already declares
sqlite-jdbc. With this interface in place, that tier is a new class
— SqliteLibraryRepository implements LibraryRepository — and
zero changes to Library. Without it, it is a rewrite.
That is the Dependency Inversion Principle earning its keep in a way you can point at, rather than recite. Even if you never write the SQLite class, say this in the README: “the repository interface is there so the Advanced persistence tier is an added class rather than a change to the domain.”
Do not extract the repository in the same commit as the bug fixes. Two commits: first the three defects (behaviour changes, tests change), then the extraction (behaviour identical, tests move but do not change their assertions). A reviewer can then verify the refactor is safe by checking that no assertion was touched.
Why is the semicolon bug worse than the array-index crash?
Far fewer, in fact — most titles have no semicolon. Rarity makes it worse, not better, because it survives casual testing.
It reproduces perfectly: save a title with a semicolon, load it. Reproducibility is not the issue.
Right. The crash announces itself with a stack trace and gets fixed the same day. The corruption produces a plausible-looking book with the wrong author, and is discovered — if ever — long after the correct data is unrecoverable. Severity is about detectability, not frequency.
The save is faithful; the file genuinely contains every character. The information is lost at parse time, on the way back in.
What does @TempDir buy you that a hardcoded path does not?
All three matter here. Each test gets its own directory, so one cannot see another’s leftovers; nothing lands in your working tree to be accidentally committed; and JUnit deletes it afterwards. The starter’s own libraryState.txt landing in the project root is exactly the mess this avoids.
JUnit forbids nothing. Any test can write anywhere the JVM has permission to write — which is the problem, not the restriction.
It helps, but JUnit 5 does not run tests in parallel unless you configure it to, and @TempDir makes no such guarantee on its own.
Real directory, real disk, real files. It is a genuine temporary directory, not a fake filesystem.
Which is the strongest argument for extracting LibraryRepository?
No such rule exists, and inventing rules is how codebases fill with interfaces that have exactly one implementation forever.
You can, but you should not want to here — a real file in a @TempDir tests the actual format, and mocking the thing under test proves nothing about whether the file parses.
A common misquote. Martin’s own formulation is about reasons to change, not counting responsibilities. Say “one thing” in interview and expect to be asked what a thing is.
This is the argument with teeth, because it names a concrete future change that the brief itself asks for and the pom already ships the driver for. “Design for change” is only meaningful when you can name the change.
Stop the saved format losing and corrupting data
Three defects in save/load, each found by a test added in this commit:
- Titles containing the ';' separator were split across fields, so
"Coming Home; A Novel" by Rosamunde Pilcher reloaded as "Coming Home"
by " A Novel". Fields are now escaped, so any character is legal.
- A truncated line raised ArrayIndexOutOfBoundsException. The field
count is now validated and reported with its line number.
- loadLibraryState cleared the collection before opening the file, so
a failed load destroyed the in-memory library. The replacement is
built first and swapped in only once the whole file has parsed.
JUnit 5 User Guide — the @TempDir extension.
Short. Covers field versus parameter injection, per-test versus per-class
lifetime, and the cleanup modes.
If you want to attempt the Advanced SQLite repository, tell me — it is a good use of an evening only once everything else is finished and committed.