Java Guild course · Lesson 0005

Lesson 0005 · the second intermediate requirement

Persistence Without Data Loss

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.

Write the failing tests first

Task · 30 minutes

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.

The red bar

[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.


Defect one · a delimiter that occurs in the data

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.

The class of bug

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.

Fixing it: three options, ranked

OptionVerdict
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 delimiterYes. ~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);
}
The ordering bug inside the fix

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.


Defect two · a parser that trusts its input

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.

Where does 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.


Defect three · destroy first, fail second

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
Name the principle

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.

Green, and then the refactor

[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 { ... }
Why this is worth doing, in one sentence

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.”

Sequencing matters, and it is visible in your commits

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.

Drill

Why is the semicolon bug worse than the array-index crash?

  1. It affects a greater number of the books that a typical library will hold
  2. It cannot be reproduced reliably, which makes it much harder to fix later
  3. It destroys data without any signal, so nothing prompts anyone to look
  4. It occurs when saving rather than loading, so the original file is altered

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?

  1. Isolation between tests and a clean repository, with automatic cleanup
  2. Permission to write files during a test, which JUnit otherwise forbids
  3. A guarantee the tests can be executed in parallel across several threads
  4. Automatic mocking of the filesystem so that no real disk write happens

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?

  1. Interfaces are required whenever a class performs any kind of file access
  2. It permits the persistence logic to be mocked out in the unit test suite
  3. Single Responsibility says every class should do exactly one single thing
  4. The Advanced tier swaps the storage, and this makes that an added class

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.

Commit it

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.

Read this

Primary source

JUnit 5 User Guide — the @TempDir extension. Short. Covers field versus parameter injection, per-test versus per-class lifetime, and the cleanup modes.

Carry on

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.