Java Guild course · The Rebuild · ADRs
Reference · docs/adr/
Eight records, each committed alongside the change it justifies. Context, decision, consequences — including the costs, which is the half most candidates leave out.
An assessor asking “why did you do it this way?” is asking whether you knew there was a choice. A decision record answers that in writing, at the moment it was taken, and its Consequences section is where you name the price you paid. Two of these record deliberate deviations from the brief.
Date: 2026-09-02
Accepted
The supplied project has no tests at all — mvn test reports No tests to run. The work ahead touches every class in it: the exception types, the persistence format, and the whole menu loop.
Unit tests alone are not enough to start with. Writing them requires deciding what each unit should do, and those decisions are exactly what the refactor is going to change. I need something that tells me when I have changed behaviour I did not mean to change, before I have any opinion about what the behaviour ought to be.
Drive the real main() with scripted keystrokes, capture everything the user would see, and approve that transcript as the baseline.
The approved file records current behaviour, defects included. Where the application crashes, the crash is captured in the transcript rather than failing the test. It asserts what the program does, not what it should do.
ApprovalTests is used at test scope, with QuietReporter so a failing run writes a .received.txt file instead of trying to launch a diff tool on the reviewer's machine.
Date: 2026-09-02
Accepted
The supplied Library signals every failure by throwing java.io.IOException from borrowBook and returnBook, neither of which performs any I/O. It also returns boolean from both. The repository ships AlreadyBorrowedException, BookNotFoundException and NotBorrowedException — named after exactly the three cases the Intermediate tier is asked to handle — and nothing references them.
IOException is wrong twice over. It describes a mechanism that is not involved, and it is a single type for three distinct situations, so the console cannot tell the user which one happened. The golden master records the result: asking to borrow a book that is already on loan reports Book not found for a book the library is holding.
Use the three supplied exceptions, under a new LibraryException supertype.
They stay checked. The usual argument against checked exceptions is that callers cannot do anything useful with them, but here the caller has a real recovery: print the message and carry on serving the user. Making them checked puts that recovery in the compiler's hands rather than in a reviewer's.
The supertype exists so the console can catch one type to report any domain failure uniformly, while a caller that needs to distinguish the cases still can.
catch, not three.borrowBook and returnBook can no longer usefully return boolean — with failure signalled by an exception, the only reachable return is true. That is a deviation from the method signatures in the brief and is recorded in ADR 0003.throws clauses is more ceremony than a single unchecked type would be. Accepted deliberately: the ceremony is the point.Date: 2026-09-02
Accepted. Deviates from the brief.
The brief specifies:
boolean borrowBook(String title)
boolean returnBook(String title)
Once ADR 0002 is in force, every failure leaves these methods by way of an exception. The only path that reaches a return statement is the successful one, so both methods can only ever return true.
A boolean no caller can act on is worse than no return value. It invites a reader to write if (library.borrowBook(title)) and to believe the else branch is reachable, when the compiler has already guaranteed it is not.
Return void. Success is the absence of an exception.
Book.borrowBook and Book.returnBook keep their booleans: at that level the answer is genuinely two-valued, and Library uses it to decide which exception to raise. The rule is that Book reports and Library decides how to escalate.
tryBorrow), not a resurrected return code.Date: 2026-09-02
Accepted. Deviates from the brief.
The brief specifies Book searchBook(String title), and the supplied implementation returns null when nothing matches. Its own Javadoc says so: "@return the book if found, otherwise null".
Every caller therefore has to remember a check the compiler cannot enforce. The supplied Main does remember it. Library.borrowBook and Library.returnBook remember it too, in duplicated book == null branches. Nothing stops the next caller forgetting.
Return Optional<Book>.
Absence becomes part of the type, so the compiler asks the question at every call site instead of trusting each author to ask it.
borrowBook and returnBook express "find it or fail" as orElseThrow, which is a single expression rather than a branch, and can therefore share one private requireBook helper..map(Object::toString).orElse("Book not found."), with no null anywhere.assertNotNull on an Optional can never fail, because searchBook never returns null now. The tests here use orElseThrow() and isEmpty() so that a broken search still fails them.Date: 2026-09-02
Accepted
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.
Wrap storage failures in LibraryStorageException, a subclass of LibraryException, preserving the original as its cause.
IOException is still available in a stack trace when debugging.SQLException; no caller changes.Date: 2026-09-02
Accepted
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.
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.
Library has one reason to change, and its unit tests touch no file.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.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.Date: 2026-09-02
Accepted
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.
LibraryConsole takes its Library, its Scanner and its PrintStream as constructor parameters and touches no global. Main becomes wiring: load, run, save.
System.setOut.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.Date: 2026-09-02
Accepted
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.
One MenuOption enum carrying each option's code and label. The menu is rendered from values(), and the console switches on the enum.
switch cannot disagree: both are derived from the same list, so an option that exists is displayed and an option displayed exists.'\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.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.