Skip to content
Modern Front-End Engineering - Front Cover

Code, Explanations and Solutions Companion

Java versions, incremental deltas, lab solutions, and instructor verification records

Companion to Object Oriented Development and Agile Practices

This section contains the official code companions, lab walkthroughs, and verified solutions for each chapter of Object Oriented Development and Agile Practices.

Companion Content Structure

Every chapter’s companion provides:

  1. Executable Java Code Listings: Production-quality Java classes and suites conforming to the running Campus Equipment Lending System.
  2. Step-by-Step Code Walkthroughs: Line-by-line analyses of design decisions, type safety, invariants, and exception handling.
  3. Lab Solutions & Exercises: Model answers for both required core labs and optional team extensions.
  4. Verification Records & Fault Experiments: Documented test suite outputs (mvn verify), mutation/fault analysis records, and reproducible compiler checks.
  5. Delta Versions: Clear listings of new or modified files built on top of preceding chapter versions.

Select a chapter or appendix companion from the sidebar or table of contents to view its complete code listings and solutions.

📘 Note on Appendices: Code companions are provided for Appendices A, B, C, and F (which contain runnable listings, diagnostic code, and validation tools). Appendix D (Project Versions and Glossary) and Appendix E (Critical Reading and Project Evidence) are comprehensive non-code reference guides contained directly within the main textbook.

Chapter 1 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 1 Companion: Code, Explanations and Solutions.

Companion to Chapter 1: Developing Software That Can Change
OO Development and Agile Practices

This file brings together the chapter’s Java code, explanations, suggested solutions and teaching notes. All code is included directly below; no archive or separate source files are needed to read it. To run an example, copy each complete listing into the filename shown above it.

The solutions are intended for the instructor or for students after attempting the activities. They support the existing chapter and do not introduce a new assessment. The Java examples are small orientation exercises, not the complete Campus Equipment Lending System.

1 Java code and explanations

1.1 The reservation rule

Filename: ReservationRules.java

public final class ReservationRules {
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

The method answers whether two supplied numbers satisfy the initial student policy. The count is measured before adding a proposed reservation. A maximum of three active reservations therefore requires the existing count to be less than three.

Expression or declarationMeaning
public final class ReservationRulesDeclares an accessible class that cannot be subclassed; final does not itself make a class’s instances immutable
public boolean mayRequest(...)Declares an accessible method that returns true or false
int activeReservationsReceives the existing count; it does not retrieve or update that count
int requestedDaysReceives the requested integer duration in days
activeReservations >= 0Rejects an invalid negative count
activeReservations < 3Allows another reservation only when the current count is below the limit
requestedDays >= 1Rejects zero and negative durations
requestedDays <= 7Includes the seven-day boundary in the permitted range
&&Requires every condition to be true; evaluation stops when a condition is false

For mayRequest(2, 7), all four conditions hold. For mayRequest(3, 7), the second condition fails. The method has no fields to modify and makes no database or service calls. It does not identify a borrower, check equipment availability or record a reservation.

1.2 Calling the rule

Filename: Chapter01Demo.java

public class Chapter01Demo {
    public static void main(String[] args) {
        ReservationRules rules = new ReservationRules();
        System.out.println(rules.mayRequest(2, 7));
        System.out.println(rules.mayRequest(3, 7));
    }
}

new ReservationRules() creates the object on which the method is called. The first call uses a permitted count and the maximum permitted duration. The second call uses an existing count that has already reached the limit. Printing the returned values makes the difference visible without building a user interface.

From a directory containing both files, use a Java Development Kit:

javac ReservationRules.java Chapter01Demo.java
java Chapter01Demo

Expected output:

true
false

The first call does not increase any count. Each call evaluates only its own supplied arguments. Consequently, calling mayRequest(2, 7) repeatedly returns the same result; the complete application would have to update its reservation records separately.

1.3 Repeatable checks

Filename: Chapter01Checks.java

This instructor example makes the expected results explicit. It needs no testing library and is not intended to teach the full testing approach introduced in Chapter 7.

public class Chapter01Checks {
    public static void main(String[] args) {
        ReservationRules rules = new ReservationRules();
        // Explicit expected results derived from the chapter's initial rules.
        int[][] cases = {
            {0, 1, 1},
            {2, 7, 1},
            {3, 1, 0},
            {1, 8, 0},
            {-1, 1, 0},
            {1, 0, 0},
            {3, 7, 0},
            {0, 7, 1},
            {2, 1, 1},
            {4, 2, 0},
            {1, -1, 0},
            {0, 14, 0}
        };
        for (int[] example : cases) {
            boolean expected = example[2] == 1;
            boolean actual = rules.mayRequest(example[0], example[1]);
            if (actual != expected) {
                throw new AssertionError(
                    "mayRequest(" + example[0] + ", " + example[1]
                    + ") expected " + expected + " but got " + actual);
            }
        }
        System.out.println("All " + cases.length + " chapter checks passed.");
    }
}

Each row contains an existing count, a requested duration and an expected result. In this small data table, 1 means expected true and 0 means expected false. The last value is converted to a Boolean before comparison; this is a convenience for the example, not a general domain-modelling recommendation.

The loop calls the rule for each row and compares the actual result with the expectation. If they differ, it throws an AssertionError with the input and both results. This explicit exception works without enabling Java’s optional assert statements. The runner stops at the first mismatch.

Compile and run all three files:

javac ReservationRules.java Chapter01Demo.java Chapter01Checks.java
java Chapter01Demo
java Chapter01Checks

The check runner’s successful output is:

All 12 chapter checks passed.

These checks cover the diagnostic cases and several nearby boundaries. They do not prove that the larger application is correct, and they do not test the future staff policy, which the original class does not implement.

2 A deliberate fault and its correction

Make a temporary copy of the source before trying this example. Inside mayRequest, change only the final comparison:

// Deliberately faulty replacement expression, not a complete class.
&& requestedDays < 7;

The modified class still compiles, but it rejects a seven-day request. For an existing count of two and a requested duration of seven, the original returns true while the faulty version returns false.

Recompile the temporary copy and run Chapter01Checks. Its first failing case reports:

mayRequest(2, 7) expected true but got false

The correction is to restore requestedDays <= 7. The requirement includes seven days, so the expectation comes from the stated policy. A check using only a two-day request would not distinguish the two versions.

An additional count-boundary example

Consider a different edit: replacing activeReservations < 3 with activeReservations <= 3. A borrower who already has three active reservations would then be permitted to request another. Running Chapter01Checks against this version stops at the first affected row:

mayRequest(3, 1) expected false but got true

The case mayRequest(3, 7) would also fail, but the runner stops at the first mismatch, and (3, 1) appears earlier in the table. This illustrates why we test the point where a new request would exceed the total allowance.

3 Suggested solutions and teaching notes

Suggested facilitation

Start by asking students to explain what “the program works” means in the opening scenario. Ask for one successful case, then one case that should be rejected. Reveal the staff change and let students identify what information is missing from the method.

Use paper tracing or a short live demonstration for the code. Give students time to predict the output before running it. Ask a second student to review the reason for the answer rather than merely announce agreement.

Complete the AI critique using the supplied teaching response, which is constructed for the exercise and labelled as such in the chapter. Live AI access is optional and unnecessary. Do not present the example data as evidence about a real model’s measured performance. Finish by asking each student for one specific next learning action.

Use the Common mistakes table in Section 1.8 of the chapter as a debrief: ask students which row they were closest to making during the diagnostic.

Diagnostic answers

Task A

CallExpected resultReason
mayRequest(0, 1)trueCount and duration are within the permitted ranges
mayRequest(2, 7)trueTwo existing reservations permit one more; seven days is inclusive
mayRequest(3, 1)falseThree existing reservations already reach the limit
mayRequest(1, 8)falseEight days exceeds the student duration limit
mayRequest(-1, 1)falseA negative count is explicitly rejected
mayRequest(1, 0)falseThe duration must be at least one day

The class is ReservationRules; the method is mayRequest; both parameters are int; the return type is boolean. There are no fields, assignments to shared state, storage calls or reservation creation operations. Calling the method neither increments a count nor reserves equipment. Its returned value is narrower than overall eligibility or confirmation.

Avoid teaching that final makes all Java objects immutable. Here it prevents subclassing. The absence of mutable state and the body of the method explain the relevant absence of state changes.

Task B

mayRequest(2, 7) distinguishes the implementations. The original returns true; the faulty < 7 implementation returns false. The original agrees with the explicit inclusive maximum of seven days. Any input using seven days and a valid count from zero to two is an acceptable counterexample.

The supplied checks also catch an accidental < 3 to <= 3 change, through the cases with three existing reservations. A passing check suite is evidence for these selected cases, not proof of every aspect of a future reservation application.

Task C

Students should preserve a maximum of three active reservations and seven requested days for students, while supporting five and fourteen for staff. Counts are measured before the new reservation is added. Some trustworthy representation of borrower category is required; an additional Boolean or category parameter can demonstrate the idea, but real callers must not grant privileges merely because a user claims a category.

Suitable unresolved questions include the treatment of unknown categories, how category is obtained, or whether someone holding both roles uses one particular policy. Do not require students to guess the institution’s policy.

Useful acceptance examples for the changed rule include:

CategoryExisting countDaysNumeric-rule decision
Student27Allow
Student31Reject
Student08Reject
Staff414Allow
Staff51Reject
Staff015Reject

Four well-chosen examples are enough for the student task; they should cover both categories and meaningful limits. Negative counts and non-positive durations remain invalid for both. These decisions still do not establish availability.

The evidence statement should name checks that could fail, not only reassurance. A strong answer might read: “I would need the six acceptance examples to give the expected results, the original student checks to still pass unchanged, and a reviewer to confirm from the diff that student limits were not edited. This would not show that the category is trustworthy, that availability is checked, or that the examples match the equipment officer’s intent.” Weak answers say only that the code works or that all tests pass.

A small conditional with an explicit category is an acceptable initial approach. Its advantage is directness; a cost is that repeated category conditions may spread if more policy decisions appear. Passing limits into a shared calculation can also be reasonable, but moves the obligation to select and validate those limits to a caller. Accept either with a coherent rationale. Do not reward early creation of an interface hierarchy just for using patterns.

AI activity answers

The numeric comparisons and Boolean result in the supplied explanation are supported by the code. The database claim is false as a claim about this method: its body has only comparisons and a return expression. It receives neither an item identifier nor a period, and performs no call to any database or availability service.

The larger application’s availability behaviour is unknown from this class. Strong answers distinguish “this method does not query a database” from “the whole application never queries a database.”

An acceptable corrected explanation is: “The method returns true when the supplied count is non-negative and below three, and the requested duration is from one to seven days inclusive. It does not change state or create a reservation. Availability and the accuracy of the supplied count must be handled elsewhere.”

Review-question guidance

  1. A rule duplicated across several screens and services may pass today’s examples but require coordinated edits tomorrow. Name the risk of inconsistent copies rather than simply calling the code ugly.
  2. An iteration is a repeated period of development and feedback. An increment adds usable capability, such as a reservation flow. One iteration might refine that flow without adding a new feature, so the terms are not interchangeable.
  3. Tests cover selected expectations. A test that incorrectly expects students to borrow for fourteen days could pass alongside an equally incorrect implementation. Compare expectations with the clarified policy.
  4. Allowing staff fourteen days adds behaviour. Reorganising the same rule without changing its observable outcomes is refactoring. Knowing the intention helps a reviewer decide whether a changed result is a defect or an intended feature.
  5. A student with zero active reservations requesting eight days should still be rejected. Replacing all limits with staff limits would accept that request. Staff-only checks do not protect student behaviour.
  6. Tests and implementation may share the same misunderstood requirement. Review expected results independently against agreed examples, especially category distinctions and boundaries, then execute the relevant checks.

Transfer-challenge guidance

One acceptable assumption is that durations are supplied as positive whole hours and counts refer to active confirmed bookings before adding another. Other clearly stated duration models are acceptable. Students should raise the policy for unknown groups and define what “active” means.

A society with two active bookings requesting one hour must be rejected; a department with the same count and duration may be allowed under the numeric rules. A society with zero bookings requesting three hours provides another useful distinction. Checks of room availability need a room identity and a requested time interval; count and duration alone cannot establish that a room is free.

Readiness and feedback

Do not convert the checklist into an unannounced assessment. A syntax gap suggests focused Java revision. Correct tracing with weak explanations suggests peer walkthroughs. A tendency to infer database behaviour from a name suggests more exercises separating local behaviour from system assumptions.

Useful feedback names the reasoning gap: “You used the staff limit for the student case” is more actionable than “Your answer lacks understanding.” Ask students to revise one answer after feedback so the chapter’s feedback loop becomes an experienced practice.

Scope and continuity checks

  • The orientation example is intentionally smaller than the complete project. It introduces no full reservation storage or date model.
  • The student edition contains no unseen assessment change variant. The staff variation is an introductory worked teaching case.
  • Full TDD belongs in Chapter 7, SOLID in Chapters 5–6, and systematic refactoring in Chapter 11.
  • The critical review remains governed by the current module brief. No new AI-use policy or grading rubric is introduced.

4 Use in later chapters

Chapter 1 asks students to plan the staff change, not implement a complete redesign. Suggested acceptance examples and design alternatives therefore appear in the solutions without adding a premature implementation. Later chapters will develop the running system, introduce more explicit responsibilities, and test the resulting changes.

Chapter 2 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 2 Companion: Code, Explanations and Solutions.

Companion to Chapter 2: Object-Oriented Development Beyond Syntax
OO Development and Agile Practices

This file contains the complete Java examples, instructions, explanations, suggested solutions and instructor notes. Read Sections 1–3 to assemble and run the starting version. Attempt the student chapter’s activity before reading Section 4 onwards.

All source code is included below. Copy each complete listing into the stated filename. The two coordinator listings are alternatives: keep only one ReservationCoordinator.java in a given working directory. The deliberate fault fragments are labelled and are not additional source files.

1 Starting point and execution

Create a new working directory and save these six files in it:

FileRole
ReservationRules.javaThe unchanged numeric policy from Chapter 1
LoanPeriod.javaValid dates and exclusive-end overlap semantics
Reservation.javaImmutable reservation details
ReservationCoordinator.javaThe starting request workflow from Section 2.4
Chapter02Demo.javaThree requests illustrating success, overlap and adjacency
Chapter02Checks.javaSupplied behavioural checks used before and after the edit

Use a JDK 17 or newer capable of compiling the Java 17 language/API baseline. No preview feature, external library or build-tool installation is required here. Use plain text filenames with the exact capitalisation shown.

For this small version, the files use Java’s unnamed package so they can be compiled together in one directory. This is a teaching setup, not the proposed organisation of the complete application. Later application-structure work will introduce named packages and a standard project layout.

Compile in that directory:

javac --release 17 -d out ReservationRules.java LoanPeriod.java Reservation.java ReservationCoordinator.java Chapter02Demo.java Chapter02Checks.java
java -cp out Chapter02Demo
java -cp out Chapter02Checks

The demonstration should print:

Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Rejected: item unavailable.
Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23).
Stored: 2

The checks should print:

All 34 chapter checks passed.

After changing the source, compile again before rerunning. Otherwise, the JVM may execute old class files and give misleading evidence about the new source. For a fresh output directory, choose a new directory name with -d and use that same name with -cp.

This is an orientation toolchain, not a complete release process. The later build and CI chapters will establish automated project-wide commands. The code here uses no database, network access, AI account or real university data.

2 Complete starting source

2.1 ReservationRules.java

This is exactly the class used in Chapter 1. It has no mutable instance state. Its inputs are the existing active count and requested number of days. The coordinator supplies the count; the rule does not retrieve it.

The staff-policy variation is not implemented in this version. Applying the same rule to every supplied borrower identifier is intentional and must remain clear when demonstrating the program.

public final class ReservationRules {
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

2.2 LoanPeriod.java

The constructor establishes a local invariant: both dates exist and the end is strictly after the start. Objects.requireNonNull throws NullPointerException on missing dates; a zero-length or reversed interval throws IllegalArgumentException.

The two comparisons in overlaps implement the stated half-open interval convention. Both must hold. Returning the dates is safe because LocalDate is immutable. A general getter returning a mutable object would require a different analysis.

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

2.3 Reservation.java

The identifiers must be non-null and non-blank, and the period must be non-null. The class does not verify that an identifier exists in a university catalogue. It preserves identifier text exactly, including case and any non-blank surrounding whitespace.

All fields are final, and their reachable values are immutable in this example. There are no setters. final on the class prevents subclassing but does not, by itself, create immutability. Equality by reservation identifier has not been implemented; inherited equals still uses object identity.

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

2.4 ReservationCoordinator.java starting version

The public method validates its arguments, counts this borrower’s records, applies the numeric policy, checks conflicts for the requested item and finally stores a successful reservation. It constructs a confirmation message; it does not send it.

Every stored record is considered active. The class has no cancellation, completion or expiry operation. R1, R2 and later identifiers are unique only within this coordinator while its append-only list is retained. A new coordinator starts again at R1; this is not a globally unique identifier strategy.

A policy rejection is returned before an availability rejection when both conditions would fail. The refactoring must preserve that order. Validation exceptions and rejections occur before adding a record. An unrepresentable end date, such as one day after LocalDate.MAX, also fails before storage. Date exceptions should be handled appropriately if a later user interface accepts arbitrary dates.

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final ReservationRules rules = new ReservationRules();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!rules.mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

2.5 Chapter02Demo.java

The first request occupies CAM1 on 20 and 21 October. The second intersects that period and is rejected. The third begins on the exclusive end date and is accepted. The different borrower identifiers ensure that this demonstration examines equipment availability rather than reaching one borrower’s reservation limit.

import java.time.LocalDate;

public class Chapter02Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = new ReservationCoordinator();
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored: " + coordinator.reservations().size());
    }
}

3 Supplied checks

This runner makes expectations explicit without introducing a test framework before Chapter 7. You may use it without understanding every helper immediately. The check helper throws on a false condition. The expect helper verifies that an operation throws the expected exception type or a subtype. It uses explicit AssertionError instances rather than Java’s optional assert statements.

The checks cover interval relationships, constructor validity, exact confirmations, failed requests, counts, snapshots and selected exceptional inputs. Inspect the expected values against the requirements; the mere existence of a runner does not establish that its expectations are correct.

3.1 Chapter02Checks.java

The counter measures successful checks for this supplied example. It is not a design-quality score, a coverage percentage or a measure of a student’s contribution.

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = new ReservationCoordinator();
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = new ReservationCoordinator();
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4 Worked refactoring solution

4.1 The intended change

Extract the successful confirmation-string assembly into a private method. Pass the already-created reservation to that helper. Keep the storage statement before the call. Do not change the text, public API, policy, conflict calculation, validation order or identifier allocation.

This improves the readability of the request method by naming one step. It does not separate presentation into another component or eliminate the coordinator’s storage responsibility. A separate formatter class is possible future work, but it adds a collaborator and is unnecessary for the stated exercise.

Replace only ReservationCoordinator.java with the version below, then compile and run both programs again. All common files remain unchanged.

4.2 ReservationCoordinator.java completed version

This is the complete replacement file, not an additional class to place alongside the starting version.

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final ReservationRules rules = new ReservationRules();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!rules.mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return confirmationFor(reservation);
    }

    private String confirmationFor(Reservation reservation) {
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

4.3 What the reviewer should inspect

The structural change is one call plus one private method. reservations.add(reservation) remains in the successful path. The helper only reads the already-created immutable record. Both rejection strings and their positions are unchanged. The confirmation still uses the same identifiers, dates, spaces and punctuation.

In Git, the extraction appears as four added lines and no removed lines, because the existing return "Confirmed: "... expression is kept and now sits inside the new method. Point this out to reviewers: a diff showing only additions still moves behaviour, so the reviewer must read where the retained lines ended up.

The expected output is identical to Section 1. The same 34 checks should pass. This evidence is stronger than inspecting only the helper’s text, because it also examines the stored state and rejection paths. It remains limited to the supplied cases and operating assumptions.

5 Git exercise and review record

5.1 Establish a baseline

Use a new local directory containing the starting six source files. Do not run git init inside an unrelated existing project. If working in an instructor-provided repository, use its established baseline instead of initialising another repository.

Create a file called .gitignore containing:

out/
*.class

This prevents generated files from being selected as new tracked files. It does not remove files that have already been committed. Compile the starter, run the checks, then initialise and record it:

git init -b main
git status
git add .gitignore ReservationRules.java LoanPeriod.java Reservation.java ReservationCoordinator.java Chapter02Demo.java Chapter02Checks.java
git diff --staged
git commit -m "Add chapter 2 reservation starter"
git switch -c refactor/confirmation-text

Git needs a configured author identity to create commits. If none is configured, use your own appropriate name and email according to your course workflow. Do not copy another person’s identity. No remote repository or network operation is required here.

5.2 Make and inspect the edit

Perform the extraction, then rerun the compilation and both Java programs from Section 1. Inspect the working change before staging:

git status
git diff

Ask a peer to inspect the change and evidence. Address any relevant concern, then stage the exact file and inspect again:

git add ReservationCoordinator.java
git diff --staged
git commit -m "Extract reservation confirmation formatting"
git log --oneline -2
git status

An empty ordinary git diff after staging is expected when all tracked modifications have been staged. git diff --staged shows the proposed commit content. After the commit, a clean status means the working directory matches the recorded state; it does not prove that the code is correct.

A useful review record contains the change or commit identifier, reviewer, evidence inspected, findings and response. If you worked alone, label the review as a self-review. Creating a commit does not create a hosted pull request or establish that another person reviewed it.

5.3 Example review description

Problem: the request method contains message formatting alongside the request workflow.

Change: move only the confirmation-string construction into confirmationFor, retaining the existing record creation and storage order.

Evidence: original and revised demonstrations print identical output; the supplied 34 checks pass for both versions; the diff changes only the coordinator’s internal organisation.

Limits: persistence, concurrent booking, lifecycle operations and message delivery remain outside this version.

This is an example of what a review description can contain. Students must record their own observed results and actual review, not copy a claim that they did not verify.

6 Practical activity solutions

6.1 Part A Request trace

CallOutcomeStored count afterwardsImportant step
B1 requests CAM1 from 20 October for two daysConfirmed as R11Empty history has no conflict
B2 requests CAM1 from 21 October for one dayRejected as unavailable1The period overlaps R1 for the same item
B2 requests CAM1 from 22 October for one dayConfirmed as R22Equality with R1’s exclusive end is permitted

The rejection occurs before reservation creation and before add. Identifiers derive from the number of stored records, so the rejected request consumes no identifier. Do not infer that this allocation strategy is suitable once deletion, persistence or multiple coordinators are introduced.

6.2 Diagram and invariants

One suitable compact diagram is:

classDiagram
    direction TB
    ReservationCoordinator "1" --> "1" ReservationRules : uses
    ReservationCoordinator "1" --> "0..*" Reservation : stores
    Reservation "0..*" --> "1" LoanPeriod : refers to

A local invariant is that a period’s end follows its start. The LoanPeriod constructor enforces it. The condition that confirmed reservations for the same item do not overlap requires comparing a requested period with existing records; the coordinator performs that check in this sequential version.

The links are associations, not inheritance. A caller can share an immutable LoanPeriod between reservations, so the diagram need not assert exclusive ownership through a composition diamond. Other diagrams are acceptable if their labels, direction and multiplicities are explained and agree with the code.

6.2a Scenario-modelling exercise (equipment kits)

One suitable model:

classDiagram
    direction LR
    class EquipmentKit {
        -String name
        +summary(Exporter exporter) String
    }
    class EquipmentItem {
        -String itemId
    }
    class MaintenanceRecord {
        -LocalDate performedOn
        -String note
    }
    class Exporter {
        <<interface>>
        +format(List~String~ lines) String
    }
    class CsvExporter
    EquipmentKit "0..1" o-- "1..*" EquipmentItem : groups
    EquipmentItem "1" *-- "0..*" MaintenanceRecord : keeps
    EquipmentKit ..> Exporter : uses
    Exporter <|.. CsvExporter : implements

What a strong answer shows:

Requirement in the scenarioModel decision
A kit holds one or more items; removing it does not delete themAggregation, with 1..* at the items end
An item belongs to at most one kit0..1 at the kit end
A record belongs to exactly one item and has no meaning without itComposition from item to record, with 1 at the item end and 0..* at the records end
A summary is produced through an exporter; other formats expected laterExporter as an interface, realised by CsvExporter; the kit depends on the interface, not on CsvExporter

Accept diagrams that add a separate export service class if the dependency and the interface realisation are still drawn correctly. Common errors are: drawing the interface realisation as inheritance with a solid line, reversing the diamond, putting 1..* at the wrong end, and making the kit depend directly on CsvExporter. The assumption can be anything stated and consistent, for example that an item with no kit is allowed, or that a record’s date is required. Do not credit extra classes for their own sake.

6.2b Answers to the “Try it” tasks in Section 2.6

Try it 1: reading Figure 2.1 from both ends.

LineReading from each endWhere the code enforces itWhat is only assumed
ReservationCoordinator "1" --> "1" ReservationRulesEach coordinator uses exactly one rules object. Each rules object is used by one coordinator.The private final ReservationRules rules = new ReservationRules(); field gives exactly one per coordinator.That a rules object is never shared. Each coordinator creates its own, but nothing in the types forbids a future constructor from sharing one.
ReservationCoordinator "1" --> "0..*" ReservationEach coordinator stores zero or more reservations. Each reservation is stored by exactly one coordinator.The list starts empty and has no upper limit, which matches 0..*.That a reservation is held by only one coordinator. Reservation’s constructor is public, and snapshot lists also hold references to the same objects.
Reservation "0..*" --> "1" LoanPeriodEach reservation refers to exactly one period. A period can be referred to by any number of reservations.Objects.requireNonNull(period, "period") in the constructor and the final field give exactly one.The 0..*: nothing prevents sharing a period, although the coordinator currently gives each reservation its own.

A strong answer separates “enforced by the code” from “true of how the code happens to be used”.

Try it 2: details in the Java but absent from Figure 2.1. Any two of these, with a reason:

  • The constructors of all four classes, including the validation in LoanPeriod and Reservation. The figure is about which classes collaborate, not how they are built.
  • The accessors id(), borrowerId(), itemId(), start() and endExclusive(). They expose data and add nothing to the collaboration.
  • Reservation’s period field. The association line to LoanPeriod already states it, and showing both would say the same thing twice.
  • The coordinator’s private fields reservations and rules, and the private confirmationFor method in the completed version. The two fields appear as lines, and a private helper is not part of what other classes can see.
  • The order of the checks and the exceptions thrown. A class diagram does not show sequence or behaviour.

Try it 3: mistakes in Figure 2.4.

#Mistake in the flawed diagramWhy it is wrongCorrect notation
1-Locker locker attribute in TechnicianIt repeats the association line, and it implies one locker per technicianDelete the attribute and keep one line
2Technician "0..*" *-- "1" Locker: reversed multiplicitiesAs drawn, each locker has any number of technicians and each technician looks after exactly one locker1 at the Technician end and 0..* at the Locker end
3The same line has a filled diamond at TechnicianIt claims lockers are discarded with their technician, but the scenario says a locker can be reassignedA plain association with no diamond
4`Locker <– InspectionEntry`Inheritance says an entry is a kind of locker, but the scenario says an entry belongs to a locker
5Locker ..> PlainTextPrinter and report(PlainTextPrinter printer)The locker depends on the concrete class, so it cannot accept another formatDepend on ReportPrinter and type the parameter as ReportPrinter
6`ReportPrinter <– PlainTextPrinter`A solid line with a triangle means inheritance; implementing an interface is realisation

The corrected diagram is Figure 2.3 in the student chapter. Mistakes 2 and 3 sit on the same line, and some students will list them as one; count them as two ideas if both are explained. Minor omissions, such as missing attributes on Tool or InspectionEntry, are not errors, because the scenario does not require them.

Marking and feedback for diagram answers. Use the module leader’s assessment criteria and decide tolerances before marking. For the workshop scenario, the technician’s name, the tool’s name and the single print operation on the printer are design assumptions, not facts stated in the description; accept other justified attributes or operations, and judge the relationships and multiplicities, which the description does determine.

AcceptDo not accept without a justification
Hand-drawn or Mermaid diagrams, since the notation matters, not the toolMultiplicities at the wrong end
A plain association with or without arrowheads where the description does not say who holds the referenceAn arrowhead that implies navigation the description or code does not support
Alternative class names and sensible extra attributesA composition diamond with no lifetime argument
Different but defensible choices, such as Tool as an attributeInheritance used for “has” or “uses”
Omitting classes the description does not needA solid line for interface realisation
A dependency on a concrete class when the description expects the format to vary

Ask students for the decision behind each relationship, not only the symbol. “I chose composition because an entry cannot exist without its locker” earns more than a correct diamond with no reason.

6.3 Part B Responsibility diagnosis

The coordinator validates request inputs, counts stored records, invokes policy, checks item availability, allocates an identifier, stores a record and formats a message. Two distinct reasons it could change are a new storage mechanism and changed confirmation wording.

LoanPeriod is cohesive because construction rules and overlap concern one period concept. The coordinator depends on both reservation representation and message wording; changing wording requires editing a class that also mutates storage. Simply counting lines does not explain that consequence.

6.4 Part C Refactoring acceptance

Use the complete solution in Section 4 as one model. Accept alternative private-method names with a clear purpose if the requested behaviour is preserved. A new interface or formatter class is not needed for this lab. If a student chooses one, ask for evidence that its additional boundary addresses a current need rather than awarding credit for extra structure.

Review returned text and stored records, not merely whether the program compiles. There should be one focused source change in the final commit. Do not require an exact commit hash, which depends on the local history.

6.5 Part D Team arrangement

There is no unique answer. The team should contain three or four members as directed, name one of the module’s provided starter domains, and make the next author, reviewer and demo responsibilities explicit. A useful issue-board item has a concrete completion condition, such as “extract confirmation formatting with unchanged output and checks,” rather than “improve OO.” Look for three such cards, columns that the team can explain, and a written branch policy with a reason. Either a short-lived-branch or a feature-branch policy is acceptable; the reason matters more than the choice. Rotate roles and preserve evidence of review and reasoning.

7 AI activity solution

The supplied response is a teaching example constructed for this exercise. It is not evidence about the observed performance of a real AI model. The student chapter deliberately does not name the three faulty areas (email delivery, durable storage and concurrency or immutability claims); students should find them by tracing control flow, state changes and side effects.

ClaimJudgementEvidence or qualification
Validates identifiersSupported within scopeRejects null or blank borrower/item identifiers; does not validate them against a catalogue
Counts a borrower’s stored reservationsSupportedLoops over stored records and compares borrower identifiers
Applies the numeric ruleSupportedCalls the unchanged ReservationRules.mayRequest
Checks conflicts for the same itemSupportedRequires matching item identifiers and an overlapping period
Adds before returning a confirmationSupportedSuccessful path calls add before constructing or returning the message
Sends emailContradicted as a description of this codeThere is no sender or delivery call; the method returns a string
Persists to a databaseContradicted as a description of this codeStorage is an in-memory ArrayList; no database operation occurs
Final list means callers receive a deeply immutable copyIncorrect explanationfinal prevents field reassignment; List.copyOf protects the returned list structure and is shallow; the element types separately provide immutability
Concurrent booking is safeNot established and not provided by this implementationChecking and adding are not one synchronised operation; concurrent use is outside the contract

An acceptable corrected four-sentence explanation is:

The coordinator rejects missing or blank identifiers and requires a start date, then counts the borrower’s stored records and applies the numeric rule. It checks overlapping periods for the same item before recording a successful reservation and returning confirmation text. Policy and availability rejections do not add a record, and the returned reservation list is an unmodifiable snapshot containing immutable elements. The implementation has in-memory storage only, sends no email, and is intended for sequential use rather than concurrent booking.

A student may mention the date-range exception or missing lifecycle support as further qualifications. They should not invent an external application’s behaviour from this isolated code.

8 Review-question solutions

  1. Identity: a reference identifies a Java object instance; id represents domain identity in the teaching model. Two separately constructed reservations can contain the same identifier text yet remain distinct objects under the inherited equals. Identifier-based equality would require an explicit contract and implementation.
  2. Invariants: LoanPeriod protects its own valid date relationship. Conflict prevention needs other reservations and the requested item, so it belongs at a boundary with that information. Local validation cannot ensure consistency across independent coordinators.
  3. Cohesion and coupling: changed message wording affects presentation but currently requires editing the coordinator. A private helper makes that purpose easier to locate; it does not remove the class-level dependency. Zero dependencies would prevent meaningful collaboration, while extra classes can add indirection without solving a problem.
  4. Composition and inheritance: a reservation has or refers to a period. It is not a period subtype. Inheriting from a period would conflate reservation identity and borrower/item details with an interval, and suggest inappropriate substitutability. The actual final LoanPeriod class also prohibits subclassing.
  5. Leaking state: returning the mutable internal list allows clear or add outside the coordinator’s validation. List.copyOf blocks structural mutation and isolates later additions to the source list. It does not deep-copy mutable elements. This starter’s element types are separately immutable.
  6. Missing storage: a success string is now returned for a reservation that the coordinator does not retain. The next request can incorrectly find no conflict, and the stored count does not increase. The one success stored check exposes the fault immediately, before relying on a second request.

9 Transfer-challenge solution

One reasonable model uses RoomBookingCoordinator, RoomBooking, BookingPeriod and BookingRules. The coordinator holds records and uses rules; a booking refers to a period. A diagram may omit a separate rules class if no relevant policy behaviour exists yet.

State an explicit convention, for example start-inclusive/end-exclusive periods in local building time with a stated time granularity. If using times rather than dates, specify how time zones or daylight-saving transitions are handled or excluded from the exercise. The four-class suggestion is not an instruction to implement a production scheduling system.

Protect the stored booking collection from callers. A formatting-method extraction can be sufficient for a wording-related area of code, but distinguish the extraction from the wording change itself: first preserve the old output while extracting, then make the requested text change as a separate behaviour change with updated expectations.

Two observations for the extraction are identical confirmation text and identical stored records for the same successful input. Also checking that a conflicting request still adds nothing strengthens the evidence. A remaining limitation might be that a single process’s list cannot coordinate bookings from multiple application instances.

A review description should identify which commit merely extracts formatting and which later change intentionally alters wording. This distinction is an important part of a strong answer to a scenario whose eventual goal changes visible text.

10 Additional fault demonstrations

Use disposable copies and never retain the faulty version as your next project version.

10.1 Exposing the mutable collection

Replace only the getter body with the following fragment:

// Deliberately unsafe method replacement.
public List<Reservation> reservations() {
    return reservations;
}

Recompile and rerun the checks. snapshot::clear now succeeds instead of throwing. The runner reports that snapshot unmodifiable expected an UnsupportedOperationException. Records have become externally mutable even though the original field remains private and final.

10.2 Omitting the storage operation

In another disposable copy, remove this line from the successful request path:

// Line to remove only for the deliberate fault exercise.
reservations.add(reservation);

The first confirmation can still look correct, but the checks fail with one success stored. A demonstration that inspects only printed text would miss the changed state.

Both faults were actually introduced in separate temporary copies during verification. Neither is present in the complete starting or completed listings above.

11 Instructor guidance and version record

For the UML work, spend class time on reading before drawing. A useful warm-up is to put Figure 2.1 on screen and have students read one line aloud in both directions, then ask where the code enforces each number. Next let them hand-draw the workshop scenario using the six steps, and compare their drawings with Figure 2.3 by asking why each line differs, not whether it matches. Finish with the flawed Figure 2.4 as a pair exercise. Hand drawing is worth the class time because it makes students decide each relationship deliberately; the module leader has not stated how diagrams are produced in the midterm, so do not tell students that hand drawing, or any particular tool, will be required.

Ask students to trace one request before discussing any principle names. Have one learner identify the successful path and another inspect what a failed path leaves behind. Use the adjacency example to expose hidden date assumptions before drawing the diagram.

Do not require full understanding of every line in the check runner this week. Its role is to supply behavioural protection while students learn a small refactoring. Teach the relevant check invocation if a learner needs to understand a failure; the full testing discipline comes later.

Keep the code change small. Review can reveal further design opportunities, but adding lifecycle states, repositories, factories and notification infrastructure would obscure this chapter’s learning goal. Ask students to record those opportunities rather than implement all of them.

The starting state is Chapter 2 before extraction. The completed state is Chapter 2 after confirmation extraction. They use the same five common files and alternative coordinator versions. Later chapters should continue from the completed version. The isolated faults are teaching experiments, not running project changes.

The original Chapter 1 rule is reused without alteration. The date convention is now explicit. The early system still lacks staff policies, borrower/item catalogues, authentication, cancellation/completion, durable storage, notification delivery and concurrency control. These are scope facts, not hidden promises that the program already meets.

The Java 17 baseline and the plain javac workflow are working choices. The sequence is deliberate: Chapter 2 uses flat files compiled by javac; Chapter 4 moves them into conventional source folders with a plain-Java Bash gate; Chapter 7 introduces Maven and JUnit. Chapter 4 explains the migration. Which Java idioms are permitted in assessments is for the module leader to decide.

12 Sources

Technical source references are in the student chapter. The examples and suggested solutions here are original teaching material.

Chapter 3 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 3 Companion: Code, Explanations and Solutions.

Companion to Chapter 3: Agile Development and the Nature of Change
OO Development and Agile Practices

This chapter’s main outputs are stories, acceptance examples, a backlog, completion conditions and workflow policies. This file provides worked versions and suggested answers, followed by an optional Java demonstration of how acceptance examples connect to the existing code.

The examples are proposals for the controlled teaching project. They do not establish new university assessment rules or claim that planned features have been built. Keep this file separate from the student chapter until learners have attempted its activities.

1 Project baseline and a proposed goal

Continue from the completed confirmation-extraction version of Chapter 2. The code supports the initial numeric student policy, sequential conflict checks, storing confirmed reservations in memory and returning a snapshot of all stored records. It does not provide a dedicated availability query, cancellation, catalogue validation or durable storage.

A suitable proposed Product Goal is:

Enable an equipment officer, in a controlled demonstration, to create and inspect student reservations with clear eligibility decisions and no overlapping reservations for the same item within one coordinator.

This is a future outcome to work toward, not a declaration that the whole goal is achieved. It explicitly bounds the demonstration. A different goal is acceptable if the team explains its audience, benefit and scope.

A candidate next-iteration goal is “Let the officer inspect an item’s availability without changing reservations.” This is an example for discussion; Chapter 4 will perform actual iteration planning. Neither statement chooses a new assessment date or fixes a Sprint duration.

2 A worked six-item backlog

The order below is one defensible teaching choice, not the only correct ordering. We review the inherited reservation flow first, then make an availability enquiry possible, and prepare repeatable checks before expanding the lifecycle.

OrderIDItemTypeCurrent support and remaining work
1US-01Record an eligible, non-conflicting student reservationUser storyCore behaviour exists; clarify the user interaction and assess the complete delivery evidence before marking Done
2US-02Inspect availability without reserving an itemUser storyNew query behaviour; must read current records without mutating them
3EN-01Automate the existing build and checksTechnical enablerLocal commands exist; an automated workflow is planned for the kickoff work
4US-03Inspect one borrower’s active reservationsUser storyAn all-records snapshot exists; a defined borrower-focused interaction and filter are still needed
5US-04Cancel a confirmed reservation under an agreed policyUser storyNew lifecycle behaviour; cancellation authority and permitted states require clarification
6US-05Reject unknown equipment identifiers using an agreed catalogueUser storyCurrent validation only checks non-blank text; catalogue source and failure policy remain open

Rationale and dependencies

US-01 prevents the team from building on a falsely reported baseline. Its remaining work should be recorded explicitly; do not recreate existing logic or claim it as newly developed. If the team verifies that it already meets the agreed delivery conditions, move it to completed history with evidence and select remaining work.

US-02 answers the workflow gap discovered at demonstration. It depends on understanding the existing records and period convention, but it need not wait for a production database. EN-01 supports reliable repeated work and should be coordinated with Chapter 4’s kickoff requirements; its place here does not forbid parallel preparation when capacity permits.

US-03 may be a small useful improvement, although access rules matter outside the controlled officer-operated demonstration. US-04 changes the meaning of “active” and therefore affects counts and availability. US-05 matters before accepting arbitrary identifiers in a broader deployment. Its lower position assumes that early demonstrations use controlled sample identifiers; change that ordering if real users or uncontrolled input enter scope.

The backlog order is not a calendar schedule. Some items need policy decisions, and the six items are not a promise that all can be completed in one Sprint.

3 Refined story cards

3.1 US-01 Record a reservation

Actor and outcome: as an equipment officer, I want to record an eligible, non-conflicting student reservation so that the selected item is allocated for the requested period.

Scope: one item per request, controlled non-blank sample identifiers, whole-day durations and representable calendar dates. The initial student rules apply. Requests are processed sequentially by one coordinator. No real catalogue, identity provider, database, payment or email service is implied.

Decisions carried from earlier chapters: at most three active reservations; duration from one to seven days inclusive; start-inclusive/end-exclusive periods; all stored records remain active until a later lifecycle feature changes that assumption.

CriterionExample and expected observation
AC-01With no records, B1 requests CAM1 from 20 October 2026 for two days; one reservation is recorded and its confirmation returned
AC-02With that record present, B2 requests CAM1 from 21 October for one day; it is rejected, the count stays one, and B1’s record is unchanged
AC-03B2 instead requests CAM1 from 22 October for one day; it is accepted because the earlier end is exclusive. A request from 19 to 20 October for the same item is also accepted, because it ends exactly where R1 begins
AC-04With three active reservations for B1 across different items, a fourth is rejected and the count remains three
AC-05One-day and seven-day requests can pass the duration rule; zero, negative and eight-day requests are rejected without adding records

Further clarification: whether past start dates are allowed; what the agreed user interaction should be; how future cancellation affects the active count. The first two need explicit treatment when preparing a complete user-facing flow. Cancellation policy belongs to US-04 unless the stakeholder changes current scope.

Existing evidence: Chapter 2 contains checks for the core behaviour. Section 8 supplies an additional direct mapping from these acceptance examples to the current coordinator. Neither check set alone establishes that a peer review, shared integration or usable input interaction has happened.

3.2 US-02 Inspect availability

Actor and outcome: as an equipment officer, I want to inspect whether a known item is available for a proposed period without reserving it, so that I can discuss options with a borrower.

Scope: enquiry against the current coordinator’s records for controlled sample identifiers. It does not promise that the physical item is operational, not under maintenance, or available in another application instance.

Core distinction: a successful enquiry never confirms a reservation and never guarantees that a later request will succeed. The later request must perform its own checks against its then-current state.

CriterionGiven and whenExpected observations
AQ-01No stored reservations; inspect CAM1 for [20 Oct, 22 Oct)Available within this record set; count and records unchanged
AQ-02CAM1 is reserved for [20 Oct, 22 Oct); inspect it for [21 Oct, 23 Oct)Unavailable; existing record unchanged
AQ-03The same existing reservation; inspect CAM1 for [22 Oct, 23 Oct)Available under the current adjacency convention; records unchanged
AQ-04CAM1 is reserved; inspect a different known item MIC1 for the same periodAvailable if MIC1 has no conflict; records unchanged
AQ-05Inspect an invalid period with equal or reversed datesAn identifiable invalid-input outcome, rather than a valid availability answer; no records changed

AQ-05 intentionally states the user-observable meaning without prescribing an exception class or interface design. The team must make that technical contract explicit during implementation. It must not silently return “available” for an invalid period.

Unknowns: the future behaviour for catalogue identifiers outside the controlled examples; maintenance rules; restrictions on past dates. Record these as questions or related work. Do not add a hidden maintenance buffer: the present interval convention allows adjacency.

Status: this query is not implemented by the Chapter 2 code. reserve cannot be reused as an enquiry by calling it and undoing the result; that would introduce a mutation and does not satisfy the story.

4 INVEST reasoning and completion conditions

4.1 INVEST applied to US-02

  • Independent: the enquiry can be demonstrated with existing records without implementing cancellation. It still depends on the agreed period convention.
  • Negotiable: the input mechanism and presentation can be discussed while preserving non-mutation and a clear result.
  • Valuable: the officer can compare periods without creating speculative reservations.
  • Estimable: a query against a controlled local list is sufficiently bounded for discussion. A live inventory integration would add uncertainty and belongs in separate scope.
  • Small: one item and one period avoid a full multi-item scheduling interface.
  • Testable: compare the result and the complete relevant record state before and after the enquiry.

This reasoning is more useful than marking six boxes without explanation. A student can challenge the story’s scope or dependency assumptions and still provide a strong answer.

4.2 A proposed Definition of Done

For the controlled teaching increment, the team proposes:

  1. Applicable acceptance examples are verified, including important rejection paths and state effects.
  2. Source compiles and executes using the documented environment and commands.
  3. A peer review has occurred and findings that prevent acceptance are resolved.
  4. Work is integrated into the shared codebase, and existing checks still pass.
  5. Usage notes, domain decisions and known limitations are updated where needed.
  6. Another team member can reproduce the intended demonstration within its stated scope.

This is an agreement to adopt or refine, not a claim that inherited work already satisfies all six items. Independent study can use a clearly labelled self-review for practice, but should not falsely record peer approval. A future CI gate must be introduced explicitly rather than claimed before it exists.

4.3 Distinguishing the three questions

Kind of conditionExampleQuestion answered
Acceptance criterionAn enquiry leaves reservations unchangedDoes this behaviour satisfy the story?
Definition of DoneThe integrated change has been reviewed and its relevant checks passDoes the increment meet the shared quality standard?
Local Ready policyKnown scope, example inputs and identified blocking questionsIs the item sufficiently understood to begin?

A Ready policy should help a conversation, not become a ritual that demands every detail months in advance. It is not a fourth Scrum artefact. Equally, no amount of formatting can make an unresolved cancellation-authority rule disappear.

5 A complete example workflow policy

Work item: a small user story or an explicitly identified technical enabler. Do not mix large epics and tiny subtasks into the same WIP count without explaining how they are treated.

States: Backlog, Ready, Building, Review and Done.

Start and finish: work starts on entering Building and finishes on entering Done. Ready items are not yet started. Building and Review both contribute to WIP.

WIP control: at most two items across Building and Review combined. This number is invented for the classroom exercise, not claimed to be optimal for every team.

Movement rules: an item enters Ready when its outcome, scope and useful examples are understood well enough to start. It enters Building when the team has capacity under the limit. It enters Review when the change and checking evidence can be inspected. It enters Done only when the applicable acceptance conditions and agreed DoD have been met. A failed review returns the item to Building and it remains within WIP.

Blocked work: retain the item in its actual workflow state, mark its blocker and who is investigating it, and keep it within WIP. Do not hide it in an excluded column or mark it Done to free capacity. If the team explicitly abandons an item, record that disposition separately from completed value and preserve the reason.

Service-level expectation: provisionally, 85% of these small work items within five working days from Building to Done. No historical evidence supports that number yet; it is a working hypothesis to revise, not a promise to users or a grading target. State how working days are counted before collecting timing data.

Inspection: when meeting to coordinate, first inspect blocked or waiting active work and agree a concrete next action. Later flow analysis should use consistent start/finish definitions. Detailed metrics and forecasting are deferred to Chapter 12.

Board exercise solution

In the supplied hypothetical snapshot, US-02 is in Building and EN-01 is blocked in Review. Two items have started and neither has finished, so the limit is reached. The team should investigate the setup blocker, help review or complete the existing feature before pulling another ordinary item from Ready.

If the blocker genuinely prevents progress, discuss and record the trade-off instead of silently changing the count. A WIP policy can be revised deliberately, but an invisible exception destroys the information needed to evaluate it. US-01’s Done position belongs only to the hypothetical board simulation; it is not an assertion about the actual project.

6 Relative estimation example

The following numbers are invented for a discussion; no empirical velocity or delivery forecast is being claimed.

Item or remaining workExample estimateAssumption or uncertainty
US-02 local availability enquiry3 pointsKnown sample identifiers, existing date convention, no external catalogue, narrow agreed interaction
US-03 inspect one borrower’s active records2 pointsCurrent definition of active remains unchanged; officer-operated controlled demonstration
US-04 cancellationNot yet estimatedAuthority, permitted lifecycle states and consequences for counts remain unresolved

Include implementation, meaningful checking, review and needed documentation in the estimate. Different values are acceptable when assumptions and comparison logic are clear. These numbers must not be converted to hours or used to rank students.

A useful investigation for US-05 would ask whether an authorised read-only sample catalogue can be obtained and what identifier fields it contains. Specify a bounded effort and a written result, including failure to obtain access. Do not claim such access already exists or ask students to probe systems without authorisation.

Critiquing the two process recommendations

Advice A tries to avoid ambiguity but assumes that all relevant detail can be known before implementation. In this project, the availability enquiry itself emerged from demonstration. Freezing every detail could prevent useful learning or preserve a mistaken assumption.

Advice B tries to encourage fast feedback but leaves the team unable to distinguish successful behaviour from a persuasive demonstration. Without prior examples, a query that mutates state might look acceptable.

A stronger agreement is to clarify the next small outcome and its key examples before implementation, then revise the backlog and decisions as new evidence appears. Keep later work less detailed until it approaches selection. This does not mean ignoring major dependencies or domain rules.

7 AI activity decision log

The student chapter words the suggestions with “refused” and “specify” so that they do not echo the classification labels (accept, reject for this story, clarify).

SuggestionDispositionReason and next action
Refuse a request for the same item and an identical periodAcceptAn identical period overlaps; this is a useful special case of AC-02
Refuse a request beginning exactly when an earlier period endsReject for this storyContradicts the current exclusive-end convention and AC-03
Email every confirmation within thirty secondsReject for this storyAdds delivery infrastructure and a timing requirement not agreed in the scope; retain as a candidate only if a stakeholder wants it
A refused request leaves stored records unchangedAcceptMatches existing state-preservation expectations, including AC-02 and AC-05
Specify how past start dates are treatedClarifyThe current policy does not establish a past-date restriction; do not invent one. Ask the stakeholder
Take a deposit payment before confirmingReject for this storyIntroduces a new business process and unapproved scope
Specify whether cancelled reservations count toward the limitClarify and link to US-04Cancellation is not implemented; the future rule matters when that lifecycle is introduced, but does not automatically block today’s bounded demonstration

No suggestion becomes a rule merely because AI produced it. The correction may be to preserve an accurate observation, narrow its claim, record a question or keep a possible future feature outside the current story.

A sufficient student record gives the suggestion, classification, relevant source of authority and next action. “AI is wrong” without identifying the conflicting rule is not enough.

8 Java bridge from acceptance examples to code

8.1 Purpose and prerequisites

The full source below runs AC-01 through AC-05 against the completed Chapter 2 coordinator. It introduces no new application behaviour and makes no claim to implement US-02. Plain Java checks are used so the relation between an example and an observation remains visible before formal testing techniques in Chapter 7.

Copy these four inherited files from the Chapter 2 code-and-solutions file: ReservationRules.java, LoanPeriod.java, Reservation.java, and the completed ReservationCoordinator.java. Put them in one directory with the new file below. Preserve their content; the example data rules and message contract have not changed.

Filename: Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = new ReservationCoordinator();

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = new ReservationCoordinator();
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = new ReservationCoordinator();
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

8.2 Compile and run

Use the same Java 17 language/API baseline as Chapter 2:

javac --release 17 -d out ReservationRules.java LoanPeriod.java Reservation.java ReservationCoordinator.java Chapter03AcceptanceExamples.java
java -cp out Chapter03AcceptanceExamples

Expected output:

AC-01 passed
AC-02 passed
AC-03 passed
AC-04 passed
AC-05 passed
All 5 acceptance examples passed.

8.3 What the code demonstrates

Each labelled group corresponds to a criterion, with setup, action and observations. AC-01 through AC-03 intentionally use one coordinator to inspect a sequence. AC-04 and AC-05 use fresh coordinators so existing state from another scenario does not accidentally decide their outcomes.

AC-04 uses different item identifiers, ensuring that the fourth refusal comes from the borrower’s total count rather than a conflict on one item. AC-05 uses two valid requests and then invalid durations while the borrower remains below the count limit. That makes the duration rule the relevant reason for rejection.

The require helper throws AssertionError on a mismatch. It does not depend on the JVM’s optional assertion flag. Some observations compare exact strings because the current public method returns strings. This couples those checks to the current message contract; a later typed outcome could separate a business decision from its wording. Do not redesign that API merely to complete this planning chapter.

AC-03 includes a request that ends exactly where an existing reservation begins because verification showed a gap without it. Changing the first overlap comparison in LoanPeriod from start.isBefore(other.endExclusive) to !start.isAfter(other.endExclusive) passed all five groups when AC-03 covered only a request starting at an existing end. Chapter 2’s adjacency symmetric check caught that change, which is a useful illustration of why example sets are chosen to expose specific faults, not merely to be numerous.

These five groups are examples, not exhaustive proofs. They do not check catalogue membership, concurrent access, durable storage or a real user’s entire interaction. A passing run does not automatically satisfy the team’s Definition of Done: review, integration, reproducibility and the agreed delivery scope still matter.

9 Review-question solutions

  1. Iteration and increment: revising the reservation messages after feedback is an iteration on an existing capability. Adding an integrated, usable enquiry that leaves records untouched is an increment. A cycle can produce an increment, but the words do not mean the same thing.
  2. Agile values: favouring usable software over exhaustive documents still permits a short written period convention. The documentation makes behaviour and tests understandable; it should support the outcome rather than become a substitute for examining it. Equivalent reasoned examples are acceptable.
  3. Events: a Review examines product outcomes with stakeholders and influences next product choices. A Retrospective selects improvements in how the team works. A Daily Scrum helps Developers adapt their near-term plan toward the Sprint Goal. A status report to the lecturer does not automatically fulfil all of those purposes.
  4. Conditions: acceptance criteria are item-specific behavioural expectations; DoD is the shared quality standard for the increment; Ready is this team’s local policy for starting work. The latter is not another formal Scrum artefact.
  5. False Done claim: the enquiry violates its non-mutation acceptance condition, and missing review violates the example DoD. Inspect records before and after the enquiry, repeat relevant examples, and obtain an actual review. A correct answer for one date is insufficient evidence for either issue.
  6. Hidden WIP: the blocked review item remains unfinished, so starting a third item exceeds the stated combined limit. Help investigate or resolve the blockage and finish existing work. If changing policy is necessary, record the change and its reason rather than relabelling the item to hide it.

10 Transfer-challenge solution

Candidate stories and enabler

RB-01: as a laboratory coordinator, I want to inspect availability for one known room and period without booking it, so that I can offer an appropriate slot.

RB-02: as a laboratory coordinator, I want to confirm an eligible, non-conflicting booking, so that the room is allocated for an agreed session.

RE-01: make the room-booking project’s existing build and checks reproducible in its shared workflow, reducing dependence on one developer’s environment. This is a justified enabler, not a fictional room user’s request for a build tool.

RB-01 acceptance examples

For the exercise, assume known example room identifiers and a declared start-inclusive/end-exclusive convention:

  • With no existing booking, a valid enquiry reports available and creates no booking.
  • With a booking from 10:00 to 11:00, an enquiry for the same room from 10:30 to 11:30 reports unavailable and preserves the existing record.
  • An enquiry starting at 11:00 may report available when there is no other conflict, and it changes no records.
  • An invalid interval with end before start produces an identifiable invalid-input result and does not change records.

These are proposed rule invented for this books, not a claim about institutional room policy. Confirm whether a cleaning or preparation buffer is required before accepting adjacency for a real system.

Completion, flow and uncertainty

A reasonable DoD includes checked acceptance examples, a reproducible build, actual review, integration and updated decisions or usage notes. A simple workflow can limit the combined Building and Review states to two items, counting blocked work until it is finished or explicitly withdrawn.

A stakeholder question is whether room capacity or organiser authority restricts booking. A technical question is whether an approved read-only room catalogue can be obtained. Investigate the latter with a bounded task; do not invent an API.

A small demonstration with synthetic room records can elicit feedback before these wider integrations exist. Present the limitations explicitly and ask the stakeholder to examine the intended interaction, not to assume a production service is already operating.

11 Reusable working templates

Story card

FieldWhat to record
ID and titleStable identifier and a short outcome
Actor and valueWho needs it and why
ScopeWhat this item includes and the boundaries of the demonstration
Rules and assumptionsConfirmed decisions separated from temporary assumptions
Acceptance examplesInputs, action, expected result and relevant state effects
Current supportExisting code or evidence, if any
Dependencies and questionsBlocking decisions, related items and responsible follow-up
EstimateOptional local relative size, assumptions and date of discussion
Completion evidenceLinks or notes for checks, review, integration and demonstration

Decision record for a proposed criterion

ProposalAccept, reject or clarifyReason and evidenceFollow-up or related item
Write the proposed behaviourChoose oneIdentify the rule, scope boundary or missing decisionRecord the next action

Peer story review

Ask another team to describe the story’s outcome, choose an ordinary case and a failure case, and identify the expected state afterwards. If they must invent a cancellation policy, duration convention or catalogue assumption, refine the story or explicitly defer the missing decision. Record the actual reviewer and questions; a template is not evidence that review occurred.

12 Instructor guidance

Start with the equipment officer’s enquiry request. Ask students to find the exact difference between the current reserve operation and a non-mutating question. This connects process work to a real behaviour gap rather than beginning with meeting names.

Use a short role-play to clarify the story: one learner represents the officer, another asks about boundaries, and another records examples and open questions. Label this as a classroom arrangement, not a redefinition of Scrum accountabilities. Keep the critical-reading exercise focused on assumptions and consequences instead of choosing a slogan.

The six backlog items, top-story criteria, board snapshot and AI classifications are consistent with the student chapter. Proposed or hypothetical states are identified; no claim is made that US-02 through US-05 or EN-01 are implemented. The period convention and numeric rules match Chapter 2, and the example DoD does not pretend that an automated pipeline already exists.

12.1 Do the acceptance examples detect faults?

Each change below was made on its own to a copy of LoanPeriod or ReservationRules, and the Chapter 3 acceptance runner was run. Where the Chapter 2 runner is named, it was run as well.

Version of LoanPeriod or ReservationRulesChapter 3 runnerChapter 2 runner
OriginalAll 5 acceptance examples passedAll 34 checks passed
First overlap comparison changed to !start.isAfter(other.endExclusive)Failed at AC-03 request ending at an existing startFailed at adjacency symmetric
Second overlap comparison changed to !other.start.isAfter(endExclusive)Failed at AC-03 adjacent confirmationNot run
activeReservations < 3 changed to <= 3Failed at AC-04 fourth rejectedNot run
requestedDays <= 7 changed to < 7Failed at AC-05 seven daysNot run

Suppose AC-03 contained only a request that starts at an existing end. The first change would then pass every Chapter 3 acceptance example. The extra request in AC-03, one that ends exactly where an existing reservation begins, closes that gap.

Martin (2002) and Rubin (2012) are cited in the student chapter; this book treats Agile practices and Scrum in Chapter 3 and sprint planning in Chapter 4.

The practical activity runs on your team’s project, with the lending backlog as the worked model and a transfer note for other provided domains. The process note asks teams to separate Scrum as defined from classroom adaptations and optional practices. When marking the process note, accept any honest statement. A team that uses stories, points and a board but no designated Scrum Master has described a legitimate adaptation, whereas a team that calls an academic deadline a Sprint without naming that adaptation has not.

This chapter adds planning documents and acceptance examples. The running application code remains at the completed Chapter 2 version. Keep these backlog decisions available for Chapter 4’s kickoff planning, and revise them explicitly when stakeholder answers arrive.

Authoritative concept references are provided in the student chapter. All worked project material and suggested solutions here are original examples.

Chapter 4 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 4 Companion: Code, Explanations and Solutions.

From Requirements to an Iteration
OO Development and Agile Practices

How to use this file

This file accompanies Chapter 4, From Requirements to an Iteration. It contains the complete starter listings, build instructions, a hosted-workflow template, explanations, model planning artefacts, exercise solutions and teaching notes. Read the student chapter before using the solutions.

Version: Java 17; Campus Equipment Lending System; unchanged Chapter 2–3 reservation rules. New material organises the starter and makes verification repeatable. US-02 availability enquiry remains planned and is not implemented here. No catalogue, persistence, cancellation, email or concurrent reservation protection has been added.

All people, workload figures and review scenarios in the model answers are fictional.

1. Reconstructing the starter

Create one directory, such as equipment-lending, and save each listing below at its labelled relative path. A heading gives the filename; do not copy that heading into the file. Create intermediate directories as required.

Relative pathPurpose
src/main/java/ReservationRules.javaExisting borrower-count and duration rule
src/main/java/LoanPeriod.javaExisting immutable interval and overlap behaviour
src/main/java/Reservation.javaExisting immutable reservation data
src/main/java/ReservationCoordinator.javaExisting sequential, in-memory reservation operation
src/main/java/Chapter04Demo.javaFixed three-request demonstration
src/test/java/Chapter02Checks.javaExisting 34 regression checks
src/test/java/Chapter03AcceptanceExamples.javaExisting five acceptance example groups
scripts/check.shCompile and execute the agreed gate
.github/workflows/java-check.ymlHosted CI template
.gitignoreExclude generated class output
.gitattributesKeep shell scripts in LF form on every platform
README.mdSetup, execution and scope instructions

Application and check classes still use the unnamed package. The directory names separate source roles without changing the class design. Named packages and broader application structure will be introduced deliberately later. This is not a Maven build: there is no pom.xml, and the check classes use main methods rather than JUnit discovery. Do not expect mvn test to discover them automatically. Maven and JUnit remain the planned fuller toolchain for the testing stage.

1.1 Requirements and commands

Use a Java 17 JDK and Bash. Check that both java -version and javac -version identify the intended installation. A runtime without a compiler is insufficient for the ordinary command. On Windows, use a Bash environment with the JDK on its path, such as Git Bash or WSL; native PowerShell cannot interpret this Bash script. Section 10 records runs on Linux and in Git Bash on Windows.

From the project root:

bash scripts/check.sh
java -cp build/classes Chapter04Demo

The workflow also invokes bash scripts/check.sh, so no executable permission bit is required for that file. Store it with ordinary UTF-8 text and LF line endings. Do not copy Markdown fence markers into the script or YAML file.

The gate prints Java version information, then these success lines:

All 34 chapter checks passed.
AC-01 passed
AC-02 passed
AC-03 passed
AC-04 passed
AC-05 passed
All 5 acceptance examples passed.
BUILD AND CHECKS PASSED

The five acceptance groups contain multiple assertions; do not add 34 and 5 and describe the total as 39 individual assertions. They are two differently organised sets of checks.

The demonstration prints:

Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Rejected: item unavailable.
Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23).
Stored reservations: 2

The second request must not consume a reservation identifier or create a record. The third request receives R2 and starts exactly at the earlier exclusive end. All three calls run in the same coordinator; rerunning the program creates a new empty coordinator.

2. Complete Java listings

These listings preserve the existing baseline. Repetition makes this Markdown file independently reconstructable and prevents accidental mixing of earlier coordinator versions.

2.1 src/main/java/ReservationRules.java

public final class ReservationRules {
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

2.2 src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

2.3 src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

2.4 src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final ReservationRules rules = new ReservationRules();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!rules.mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return confirmationFor(reservation);
    }

    private String confirmationFor(Reservation reservation) {
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

2.5 src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = new ReservationCoordinator();
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

2.6 src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = new ReservationCoordinator();
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = new ReservationCoordinator();
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

2.7 src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = new ReservationCoordinator();

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = new ReservationCoordinator();
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = new ReservationCoordinator();
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

2.8 What the supplied checks protect

ReservationRules permits a request only when the existing count is nonnegative and below three, and the requested duration is one through seven days. LoanPeriod rejects invalid bounds and implements half-open overlap. ReservationCoordinator checks eligibility and conflict before storing a record, then builds the confirmation through the private method extracted in Chapter 2.

reservations() returns an unmodifiable snapshot. The reservation and period objects are immutable at this version. The checks cover rejection without storage, adjacency, identifier allocation after rejection and snapshot behaviour. The acceptance examples connect those behaviours to US-01.

The helper methods throw AssertionError explicitly when a condition is false. They do not use Java’s assert keyword, so they do not depend on the -ea flag. An uncaught failure makes the Java process return a nonzero exit status. The shell gate must preserve that failure.

The coordinator does not validate known borrowers or equipment, expire old records, cancel reservations or provide a read-only availability operation. Do not turn a passing regression suite into a claim that these features exist.

3. Build and repository files

3.1 scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."
mkdir -p build/classes
# Remove only generated files from this starter's output directory.
find build/classes -type f -name '*.class' -delete

java -version
"${CHAPTER_JAVAC:-javac}" --release 17 -encoding UTF-8 -d build/classes \
  src/main/java/*.java src/test/java/*.java
java -cp build/classes Chapter02Checks
java -cp build/classes Chapter03AcceptanceExamples
echo "BUILD AND CHECKS PASSED"

3.2 .github/workflows/java-check.yml

name: Java starter checks

on:
  push:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
      - name: Compile and check
        run: bash scripts/check.sh

3.3 .gitignore

/build/
*.class

3.3a .gitattributes

*.sh text eol=lf

On Windows, Git can convert line endings to CRLF when it checks files out. Git Bash tolerates a CRLF script, but WSL and Linux bash do not: set -euo pipefail then fails with set: pipefail: invalid option name. This one-line file keeps shell scripts in LF form so a fresh clone behaves the same everywhere. Commit it with the other files.

3.4 README.md

# Campus Equipment Lending: Chapter 4 checkpoint

## Requirements

Use Java 17 JDK and Bash (on Windows, Git Bash or WSL). Check `java -version` and `javac -version`.
There are no external Java dependencies in this checkpoint.
The classes use the unnamed package. This is not yet a Maven/JUnit build.

## Verify

From this directory:

```bash
bash scripts/check.sh
```

Expected: all 34 Chapter 2 checks and all 5 Chapter 3 acceptance example
 groups pass, followed by `BUILD AND CHECKS PASSED`.
A compilation or check failure must give a nonzero exit status.

## Demonstrate

After verification:

```bash
java -cp build/classes Chapter04Demo
```

Expect confirmation R1, rejection for an overlapping CAM1 request,
confirmation R2 for an adjacent CAM1 request, and two stored records.
The demonstration has fixed synthetic inputs; it is not interactive.

## Scope

Data lives in one in-memory coordinator and disappears on restart.
Loan periods include the start date and exclude the end date.
All stored reservations count as active. Identifiers are checked for
nonblank text, not against a directory or catalogue. Requests are
sequential; no concurrency guarantee is made. There is no cancellation,
email delivery, database or read-only availability query yet.

## Review and CI

Open a small change for review, explain its purpose and include local
check results. Resolve blocking findings before integrating it.
The supplied workflow checks pushes and pull requests. Keep the passing
run for the integrated revision. A workflow file alone is not evidence
that hosted checks ran. Record real run URLs and review details in the
team evidence notes; do not fabricate them.

## Next slice

US-02: inspect availability for one fixture item and interval without
changing stored reservations. The planned acceptance examples are
AQ-01 through AQ-05 in Chapter 4. They are not implemented by this starter.

3.5 Why the gate is structured this way

The script first changes to the project root relative to its own location. This makes its source paths independent of the caller’s working directory. It removes generated .class files beneath this starter’s build/classes, preventing a deleted source from surviving only as an old compiled class. It does not delete source files.

The shell options stop this straight-line script on a failing command, reject unset variables and propagate failures through pipelines if pipelines are added. The compilation uses UTF-8 source encoding and the Java 17 release target. Every Java file directly within the two listed source directories is compiled together. If named packages or nested source directories are added later, update the source-discovery approach or migrate the gate to the planned build tool; these globs are not recursive.

CHAPTER_JAVAC is an optional path to a compiler executable. Ordinary use leaves it unset, selecting javac. It is a single executable path, not a place to put a multiword shell command. Some environments provide java but no javac launcher, and the override is for them; learners with a normal JDK should not need it.

The script runs the two check programs as separate Java processes. Its final success message follows both. There is no || true, catch-and-ignore handler or success message issued before checking. Adding another check class also requires adding its execution command: compilation alone does not run a class’s main method.

3.6 What the workflow adds

The workflow requests runs for pushes, pull requests and manual dispatch. It grants read access to repository contents, uses a hosted Ubuntu runner, installs Temurin Java 17 and invokes the same gate. A push associated with a pull request may produce both kinds of run; this simple template accepts that duplication. Optimising triggers is outside the kickoff exercise.

The example uses actions/checkout@v7 and actions/setup-java@v6, checked against the current official action sources on 12 October 2026 [4, 5]. It requests the Java 17 line rather than fixing a patch build. Major action tags and the hosted runner image may change; record resolved versions from the run log. For an institution-managed runner, consult the current action requirements before reuse. This file has not been executed on a hosted runner by the author.

A passing workflow does not automatically configure merge requirements. Configure the shared branch to require the agreed review and verify job where the repository supports it, or document the manual policy. Do not describe a requested policy as enforced unless it actually is.

3.7 A small repository workflow

In a new directory containing only the reconstructed starter, initialise a repository if one does not already exist:

git init -b main
git add README.md .gitignore .gitattributes src scripts .github
git commit -m "Establish Chapter 4 reservation baseline and checks"

Git needs your normal author identity configured. Do not use another person’s identity or invent review attribution. In an existing course repository, use its established base branch and workflow instead of reinitialising it.

For subsequent work:

git switch -c docs/clarify-reservation-scope

Make a bounded documentation change, inspect git diff, run the gate, stage the specific changed file and commit. Push to the team’s already established remote, then open the change for review using the institution’s normal interface. Remote creation and authentication are environment-specific; there is no pre-existing remote URL supplied by this book.

A useful review description contains the problem, the change, the behaviour or limitation being clarified, the command run and its result. A reviewer should record an actual observation, such as finding that the README omitted the session-only storage limit. Resolve the comment and retain the real exchange. Model descriptions in this file are examples, not evidence of a performed review.

4. Deliberate failure experiment

This exercise checks the execution path from a failed assertion to a failed gate. It is separate from introducing a defect into the application.

  1. Obtain a passing baseline with bash scripts/check.sh.
  2. Create a disposable practice branch, for example git switch -c practice/ci-failure.
  3. In src/test/java/Chapter03AcceptanceExamples.java, change only this line:
require(coordinator.reservations().size() == 1, "AC-01 one record");

Replace it with the intentionally incorrect expectation:

require(coordinator.reservations().size() == 2, "AC-01 one record");
  1. Run the gate and inspect the error. In an interactive Bash terminal, the command below prints the status of the preceding gate:
bash scripts/check.sh
echo "$?"

The author run returned 1 and included:

All 34 chapter checks passed.
Exception in thread "main" java.lang.AssertionError: AC-01 one record

The stack trace points to the acceptance program. BUILD AND CHECKS PASSED must be absent. This is the intended failure, because one successful reservation should produce one record.

  1. For hosted evidence, commit this labelled practice change and push the practice branch to the team’s authorised repository. Inspect the failed check step, not merely the overall colour of the run. Keep its revision and result. Do not merge this deliberately wrong expectation into the shared branch.
  2. Restore == 1, commit the restoration on the practice branch and run the same checks again. Retain the restored passing run.
  3. Return to the normal baseline and verify that it also remains green. Close the practice review without merging the deliberate defect, following the team’s workflow.

The local experiment can also be done in a separate copy if no remote is available. That proves local failure propagation but does not supply hosted CI evidence. Report the distinction explicitly.

What this proves: the acceptance program runs, its failed condition causes a Java process failure, and the shell gate returns failure instead of hiding it. What it does not prove: every defect will be detected, every acceptance criterion is covered, or the hosted workflow has executed. Chapter 7 develops test quality; Chapter 13 develops the delivery pipeline.

5. Model kickoff documents

These are suggested solutions. They are not signed-off stakeholder decisions, a real team’s plan or assessed submission evidence. Alternative solutions are valid when their behaviour, scope and trade-offs are coherent.

5.1 Vision and scope

Working vision: help an equipment officer demonstrate clear reservation decisions for a controlled set of student borrowers and equipment, and inspect accepted records without creating overlapping reservations for the same item in one coordinator.

Kickoff goal: make the existing baseline reproducible and reviewable, and prepare the next availability slice.

Candidate next iteration goal: let the officer inspect availability for one sample item and interval without changing reservations.

In scope for the demonstration: one item per request; inclusive start and exclusive end; student rules of fewer than three existing reservations and durations from one to seven days; deterministic synthetic examples; sequential calls in one process.

Outside this version: persistent storage, actual directory or catalogue validation, cancellation, return processing, email delivery, multi-user concurrency and a polished web or mobile interface. These boundaries do not settle the production design.

Open questions: treatment of past dates; operational turnaround between loans; authority to cancel; definition of an active reservation after cancellation or completion; catalogue source and its failure behaviour. Resolve only those that block the selected next outcome; attach an owner and a review trigger to the others.

5.2 Two decision records

FieldD-01: storageD-02: availability interaction
QuestionMust kickoff preserve records across restarts?What input expresses the query interval?
Proposed decisionKeep one in-memory coordinatorUse start and explicitly labelled exclusive end
ReasonDemonstration evaluates reservation decisions in one sessionMakes the half-open interval visible during discussion
ConsequenceRestart loses records and identifier sequence restartsOfficer needs clear date guidance; equal/reversed bounds must be reported
Review triggerA selected outcome requires a session to survive restartUser walkthrough reveals persistent confusion or prefers duration
StatusExisting teaching baselineProposed interaction decision; obtain team agreement before implementation

D-02 does not change the existing reserve signature, which still accepts start and duration. Separate operations may have different interaction needs, but the team should justify the difference. A duration-based query sketch is also reasonable if its conversion to an interval is explicit and checked.

5.3 Epics and ordered backlog

GroupItemState and next action
Reservation decisionsUS-01: eligible reservationCore exists; collect actual delivery and review evidence
Reservation decisionsUS-02: availability enquiryRefine AQ-01–AQ-05; select as candidate next slice
Delivery supportEN-01: automated build/checksLocal gate supplied; obtain hosted run evidence
Record lifecycleUS-03: borrower record inspectionDefer filtering and interaction until selected
Record lifecycleUS-04: cancellationClarify authority and count effects before estimating
Equipment identityUS-05: unknown-item rejectionClarify catalogue contract before implementing

The row order retains Chapter 3’s backlog order. Scheduling EN-01 during kickoff is a visible enabling dependency, not a claim that it is a newly requested user feature. US-01 should not move to Done solely because its implementation already exists: the agreed delivery conditions still need evidence.

5.4 Proposed US-02 contract

Story: as an equipment officer, I want to inspect whether one sample item is available for a stated interval, so I can discuss a possible reservation without creating one.

Acceptance: AQ-01–AQ-04 report availability correctly for empty state, overlap, adjacency and a different sample item. AQ-05 reports an identifiable invalid-period outcome for equal or reversed bounds. Every enquiry leaves all reservation contents unchanged.

Interface decision still required: choose and document an invalid-input representation, such as a caught argument exception translated at the interaction boundary, or a result value that distinguishes invalid from unavailable. Do not present both as simultaneously implemented. No new API is supplied in this version.

Necessary tasks: agree the contract; add a read-only operation; add the thin interaction; test results and non-mutation; review and integrate; update instructions and demonstrate. Deferred tasks: calendar styling, bulk search, live catalogue integration and persistent storage.

Review trap: availability does not imply eligibility. If B1 already holds three active reservations, an unreserved item can be available even though B1 cannot successfully reserve it. Reusing reserve as the enquiry mixes those questions and changes state.

5.5 Capacity model and replanning example

Available person-hours are 6 + 6 + 5 + 5 = 22. Reserve four for coordination, integration support and administration, leaving 18. The selected forecast is 2 + 4 + 3 + 3 + 2 = 14: contract, query behaviour, interaction, checks, then review/integration/documentation/demo. Four hours remain unallocated. These are invented figures for reasoning, not prescribed team availability.

Suppose implementation is revised from four to nine hours after discovering a contract ambiguity. Selected work becomes 19 hours, exceeding the 18-hour budget even before retaining any margin. Do not preserve the original status by removing checks. Revisit the ambiguity and reduce an optional part of the interaction, or revise the forecast transparently. If no coherent, verified slice fits, report that the outcome is at risk and renegotiate the selected work with the appropriate decision-maker.

A plan might remain infeasible even after the total fits. Check review windows, knowledge concentration and dependencies. If the reviewer is only available early, reserve an early contract review and a second person for final verification, rather than assuming the same reviewer can appear at the last minute.

5.6 Team agreement example

  • Keep stories and acceptance examples in shared project notes; preserve identifiers when refining wording.
  • Use Backlog, Ready, Building, Review and Done. Limit Building plus Review to two items; blocked items count.
  • Keep branches small enough for a reviewer to explain their purpose and effects.
  • Authors run the gate before review and after corrections. Reviewers inspect behaviour, state effects and evidence.
  • Resolve blocking findings before integration. Record the shared branch’s check result after integration.
  • Mark unavailable review or CI evidence as a gap with a next action; never manufacture it.

Suggested Definition of Done addition: “The agreed automated gate passed on the integrated revision, and the team can locate its result.” Retain Chapter 3’s example verification, actual review, updated notes and reproducible demo requirements. This is a model team policy, not a replacement for the module’s assessment rules.

5.7 Evidence-note template

Create an evidence note using real values. The following is a blank template, deliberately not completed evidence:

FieldTeam entry
Repository and baseline revisionFill with the actual repository and commit
Implemented behaviourList only the behaviour present at that revision
Local environment and commandRecord Java version, shell and command
Local check resultRecord observed exit status and relevant output
Hosted passing runActual run URL or retained log, tested revision and date
Deliberate failurePractice revision, failed assertion and failed run
Restored passRestored revision and passing run
Independent reviewActual reviewer, finding, correction and follow-up
Unresolved questionsOwner, impact and next decision point
Known limitationsScope boundaries relevant to interpreting the demo

For traceability, link US-01 AC-01 to its block in Chapter03AcceptanceExamples, then to a run that executed that file. Do not link US-02 to the old checks as if they exercise a query that does not yet exist.

6. AI activity: model critique

The student chapter’s example is author-written fictional AI output. The following is a model evaluation, not evidence of an actual AI session.

Fixture proposalVerdictReason and correction
Allocate all 22 hours to featuresRejectCoordination, review and uncertainty consume capacity; use the explicit budget
Deliver query, cancellation, mobile, database and email togetherRejectThe selected goal is one query slice; several additions lack agreed requirements
Give each member one layer and integrate at the endReviseOwnership can help, but final-day integration delays learning; collaborate around the thin journey
Convert three story points into three hoursRejectNo valid conversion was established; estimate tasks using actual understanding
Call reserve and then delete the record to check availabilityRejectQuery must not change state; deletion is not even a supplied operation; eligibility also differs
Defer failure casesRejectInvalid intervals and non-mutation are part of this outcome’s acceptance
Mark EN-01 complete when YAML is committedRejectConfiguration must execute successfully and detect the intended failure
A green badge proves the whole storyRejectThe gate only checks executed conditions; current checks cover reservation, not US-02

A corrected response should state the candidate availability goal, AQ-01–AQ-05, the 18-hour planning budget and 14-hour selected forecast, as well as review and observed CI evidence. It should state its invented planning assumptions rather than describe them as the team’s actual capacity.

Independent verification includes tracing reserve to its reservations.add call, observing the absence of a query method, checking the arithmetic, and inspecting actual check output. Asking a second AI model whether the first plan is good does not replace those observations.

An acceptable AI-use record names the prompt or example, claims examined, decisions, evidence and remaining uncertainty. A paid service is unnecessary. The offline example supports the same critique and correction.

7. Review-question solutions

7.1 Concept questions

1. Vision, goal and task. The vision describes the officer’s improved ability to make clear reservation decisions. The next iteration goal concerns inspecting availability without changing records. Implementing a read-only operation is one task supporting that goal. A list of technical tasks alone does not express the user outcome.

2. Console vertical slice. A slice is complete relative to its promised outcome when input reaches the relevant behaviour and storage decision and produces an inspectable result. A console can support that journey. Adding a database or graphical interface is only necessary when the outcome requires it. The supplied fixed demo is evidence of the existing journey; a genuinely interactive new slice must deliver its promised input handling too.

3. Capacity versus feasibility. Summed hours omit dependencies, calendar overlap, skill availability and review timing. Four people available on different days may be unable to pair or review when needed. Check the sequence and collaboration windows as well as the total; account for pairing in person-hours.

4. Meaning of a pass. The particular checks executed successfully against the compiled revision in the recorded environment. The pass leaves unproven untested cases, unspecified rules, hosted execution if only run locally, independent review, concurrency safety, and user acceptance of the proposed interaction. It does not certify that US-02 exists.

7.2 Applied diagnosis

5. Mutating availability enquiry. Calling reserve violates the read-only requirement before any attempted cleanup. A successful reservation appends a record. Eligibility can also reject the request for borrower-specific reasons even when the item is available. The current public snapshot cannot be used to delete the new record because it is unmodifiable.

Inspect a before-and-after snapshot of record identifiers, borrower/item identifiers and period bounds, not only the number of records. For a future query test, preserve all those values and compare them after every success, unavailable result and invalid-input outcome. The corrected implementation should inspect availability directly without inserting and removing records. A transaction or rollback design is unnecessary for this small query and would not explain the eligibility mismatch.

6. Ignored exit status. The || true branch turns the failing Java command into a successful shell list. A subsequent echo can then make the job look successful. Remove the failure suppression and run the acceptance program as a normal command in the supplied gate. Repeat the deliberately wrong AC-01 expectation and verify nonzero exit with no final success marker. Restore the expectation and verify a passing result. A compile failure is insufficient because the defect concerns assertion-failure propagation.

8. Practical activity: model reasoning and feedback

A satisfactory solution has agreement across its documents. The scope identifies controlled examples and session-only storage. The sketches label exclusive ends. The backlog separates existing reservation capability from proposed query behaviour. The plan budgets checks and review. The README lets another person run the intended revision. Actual logs and review records support completion claims.

A useful walkthrough asks one student to play the equipment officer and another to record ambiguities. For example, the officer may interpret “end date” as the last borrowing day. Correct the label and recheck AQ-03. Record that observation as interface feedback only if the walkthrough actually occurred. The example describes a possible finding, not a pre-filled review record.

For the small repository change, reviewing the build script is enough to practise the workflow. A reviewer could ask why the checks are compiled but not executed, or why success is printed before the second runner. The author must correct any actual defect found. Do not intentionally insert those defects into the shared baseline to manufacture review activity.

Give feedback on these questions rather than counts of documents:

DimensionUseful feedback question
ScopeCan the team explain the conditions under which its claim is valid?
SliceDoes the result support one observable outcome, including necessary failures?
PlanningIs review and uncertainty accounted for, and can people collaborate when needed?
TraceabilityDoes the cited check actually exercise the criterion?
WorkflowIs there real evidence of review, corrections and the integrated result?
HonestyAre proposed features, local checks and hosted runs labelled accurately?

Do not impose an invented numerical rubric or require a specific board product. Use the module leader’s assessment criteria. When hosted execution is blocked, the team should preserve useful local evidence and identify the exact missing result; whether an alternative satisfies assessment requirements is the module leader’s decision.

If you want more solution: duration matches the current reservation input and can be easier for an officer thinking “two days”. An exclusive end makes overlap boundaries explicit but may be misread as an inclusive last day. Either choice needs labels, conversion rules, invalid-input handling and examples at adjacent boundaries. The appropriate choice follows a user walkthrough, not a universal claim that one representation is always better.

9. Transfer challenge: one possible clinic plan

Bounded goal: let reception demonstrate an availability enquiry for one synthetic appointment slot without creating or altering bookings, within one running coordinator.

Proposed sketch: practitioner/resource identifier, appointment date, start time, duration, a Check action and a result that clearly distinguishes availability from a confirmed booking. Display the applicable time zone once the team has agreed it. Do not allow the interface to imply that the enquiry reserves the slot.

Assumptions to revisit: the demonstration uses one known synthetic resource and a single agreed scheduling zone; it excludes real appointment authority, patient identity and clinical workflow decisions. Before real use, clarify the authoritative schedule and booking permissions with the responsible stakeholders. These are open operational requirements, not policies for students to invent.

Boundary example: one appointment occupies [09:00, 09:30) and another requests [09:30, 10:00). They do not overlap under that interval model. A required preparation interval could still make the second appointment invalid, so clarify it rather than copying the equipment answer. The equipment starter uses whole dates; an appointment system must decide how local times, dates and zones represent slots. Do not paste LocalDate.plusDays into a minute-based rule.

Plan: determine actual individual availability; budget coordination, review and uncertainty; select the single enquiry outcome with checks for overlap, adjacency and unchanged bookings. Defer reminders, payments, multiple clinics and calendars unless the selected outcome depends on them. Identify review windows before committing to the forecast.

Gate evidence: compile the starter and run supplied checks for the agreed booking semantics, then prove assertion-failure propagation. Human evidence: a walkthrough confirms that reception understands the result and time labels; stakeholder discussion resolves authority and operational assumptions. The automated gate cannot supply either conversation.

This solution illustrates transfer of planning principles while revisiting domain rules. Students should not be rewarded for reproducing the equipment project’s exact class names or interval representation.

10. Teaching notes

Martin (2002) and Rubin (2012) are cited in the student chapter; this book treats sprint planning and iteration evidence in Chapter 4, and Scrum in Chapter 3.

The practical activity runs on your team’s project with the lending starter as the model. The starter test is introduced in Arrange–Act–Assert terms for Chapter 7, and the Chapter 2 flat directory is mapped onto the conventional source layout. Milestone 1 needs hosted CI evidence, which depends on a repository the whole team can use. Ask teams to create that repository in the first session, so a late access problem does not become the only gap in their evidence.

Keep the chapter centred on planning a believable outcome and making evidence reproducible. Students can run supplied checks before learning TDD. They should understand which behaviours the checks protect, without being required to invent a testing framework.

Do not solve US-02 merely to make the repository look busier. Its refined story is the planning exercise; the running code remains a stable starting point for the design work in Chapters 5–6. Likewise, do not add interfaces solely because the repository now has separate source directories.

For a team with stronger tooling experience, adapting the same gate to the institution’s CI service is acceptable. Require equivalent observable behaviour: the intended revision is compiled, the checks execute, assertion failures fail the run and restored code passes. Do not confuse sophistication of pipeline configuration with quality of the engineering explanation.

The next chapter, Responsibilities, Extension and Substitutability, will use this baseline to inspect reasons for change and make small, protected design improvements. Comprehensive CI/CD, deployment and integration-testing choices remain for Chapter 13.

References

The sources below support the referenced process and tool behaviour; the planning documents and solutions are author-created examples. Checked 12 October 2026.

  1. Schwaber, K. and Sutherland, J., The Scrum Guide, November 2020.
  2. Oracle, The javac command, Java SE 17.
  3. GitHub, Building and testing Java with Maven. This version uses its own shell gate; the page supplies CI context rather than a claim that Maven is configured here.
  4. GitHub Actions, setup-java.
  5. GitHub Actions, checkout.

Chapter 5 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 5 Companion: Code, Explanations and Solutions.

Responsibilities, Extension and Substitutability
OO Development and Agile Practices

1. How to use this file

This file contains complete source listings, version instructions, explanations, exercise solutions and teaching notes for Chapter 5. Attempt the student activities before reading the model answers in Sections 8–11.

The running project remains the Campus Equipment Lending System. Its Java 17 code begins at Chapter 4. We distinguish three versions:

Version directoryMeaningBehaviour
beforeChapter 4 starting pointExisting student reservation rule and private confirmation method
refactoredStage ASame behaviour; concrete formatter extracted
afterStage B; running finishing versionStudent behaviour preserved; explicit staff policy added

Only after is the running Chapter 5 result. The separate LSP example is a teaching counterexample; it does not become a reservation collaborator. US-02 availability enquiry remains planned. No actual staff directory, authentication, catalogue, persistence, cancellation or notification delivery is introduced.

1.1 Assemble the directories

For a fully independent reconstruction, create the three version directories above. The listings are divided as follows:

  1. Section 2 uses paths beginning with shared/. Save those files there, then copy the contents of shared into each version. For example, shared/src/main/java/LoanPeriod.java becomes before/src/main/java/LoanPeriod.java, refactored/src/main/java/LoanPeriod.java and after/src/main/java/LoanPeriod.java.
  2. Section 3 supplies before-specific files. Copy its ReservationRules.java and scripts/check.sh into the same relative locations in refactored too. Do not copy its coordinator into refactored.
  3. Section 4 supplies the extracted coordinator and formatter. Copy that formatter into after/src/main/java/ConfirmationFormatter.java too.
  4. Section 5 supplies the remaining after files, including its changed student rules and updated gate.
  5. Section 6 supplies the isolated LSP example under after/examples/lsp.

Create intermediate directories as needed. Copy the content inside each code fence, not the fence or heading. Keep only one class with each name inside a version. Compile each version separately; do not compile every .java file beneath the parent directory at once.

The directory prefixes describe alternative teaching versions, not Java packages. Classes remain in the unnamed package. The common application layout is src/main/java; executable checks are in src/test/java. These are plain Java programs, not JUnit tests, and this chapter does not introduce a Maven configuration.

If you already have the Chapter 4 repository, you may instead make successive commits for Stage A and Stage B. The separate directories are a reconstruction aid, not a requirement to maintain duplicate application trees in your team’s repository.

1.2 Run each version

Use Java 17 JDK and Bash. Confirm that java -version and javac -version refer to the intended installation. From the directory containing the three versions:

bash before/scripts/check.sh
bash refactored/scripts/check.sh
bash after/scripts/check.sh
java -cp after/build/classes Chapter04Demo
java -cp after/build/classes Chapter05Demo

The scripts change to their own version root, compile application and check sources, then run the relevant checks. Ordinary learner setup uses javac; no compiler override is needed with a normal JDK installation.

1.3 Run PMD

Install PMD once. Download pmd-dist-7.28.0-bin.zip from the project’s GitHub release page (https://github.com/pmd/pmd/releases, tag pmd_releases/7.28.0). The release page shows a SHA-256 digest for each file; the digest recorded for this file when this chapter was checked was:

f974ba571f7bc01c73fffe11bade25fbc1f438698f9e9c00e416e8d65e9fbac2

Compare it with the digest of your download (sha256sum pmd-dist-7.28.0-bin.zip), unzip the file into its own new folder, and set PMD_HOME to the pmd-bin-7.28.0 folder inside it. PMD needs a Java runtime of version 8 or above. From each version directory:

export PMD_HOME=/path/to/pmd-bin-7.28.0
bash scripts/check.sh
bash scripts/pmd.sh

Expected output for before is in Section 7.1b. The ruleset and script are listings 2.8 and 2.9 and are copied into every version with the other shared files.

The first two gates should report the original 34 checks and five acceptance example groups passing. The final gate additionally prints:

All 44 Chapter 5 checks passed.
BUILD AND CHECKS PASSED

The original example groups contain multiple assertions. Report the groups and check counts separately rather than treating them as identical units.

The Chapter 4 demonstration must retain its original output:

Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Rejected: item unavailable.
Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23).
Stored reservations: 2

The new demonstration should print:

Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03).
Rejected: borrower limit or duration.
Rejected: item unavailable.
Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04).
Stored reservations: 2

The first request explicitly selects the illustrative staff policy. The second uses the original student operation and rejects fourteen days. The third is a valid-duration student request blocked by the staff reservation. The fourth begins at the staff reservation’s exclusive end, so it can succeed. Rejections do not consume identifiers or add records.

2. Shared files for all three versions

These files are unchanged from Chapter 4. Their repetition makes the examples reconstructable from this Markdown file.

2.1 shared/src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

2.2 shared/src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

2.3 shared/src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = new ReservationCoordinator();
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

2.4 shared/src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = new ReservationCoordinator();
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = new ReservationCoordinator();
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

2.5 shared/src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = new ReservationCoordinator();

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = new ReservationCoordinator();
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = new ReservationCoordinator();
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

2.6 shared/.gitignore

/build/
*.class

2.7 shared/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps shell scripts in LF form when Git on Windows converts line endings. Without it, a CRLF copy of scripts/check.sh fails under WSL or Linux bash. Commit it with the other files.

2.8 shared/config/pmd-smells.xml

<?xml version="1.0"?>
<ruleset name="SE506 design-smell lab"
         xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
  <description>
    Design-smell rules for the Chapter 5 lab. Every rule keeps PMD's default threshold.
    Choosing a small set of rules is a decision: PMD's other categories report many findings
    that this lab does not use.
  </description>

  <rule ref="category/java/design.xml/CyclomaticComplexity"/>
  <rule ref="category/java/design.xml/CognitiveComplexity"/>
  <rule ref="category/java/design.xml/NPathComplexity"/>
  <rule ref="category/java/design.xml/GodClass"/>
  <rule ref="category/java/design.xml/DataClass"/>
  <rule ref="category/java/design.xml/TooManyMethods"/>
  <rule ref="category/java/design.xml/ExcessiveParameterList"/>
  <rule ref="category/java/design.xml/ExcessiveImports"/>
</ruleset>

This is the Week 4 lab rule set: eight rules from PMD’s design category, each with PMD’s default threshold. It is deliberately small. Section 7.1b compares it with PMD’s other rule sets.

2.9 shared/scripts/pmd.sh

#!/usr/bin/env bash
# Runs PMD's design-smell rules on the application sources. Run scripts/check.sh first if you
# want type information (it creates build/classes). PMD_HOME is the unzipped pmd-bin-7.28.0 directory.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
: "${PMD_HOME:?Set PMD_HOME to the unzipped pmd-bin-7.28.0 directory}"
aux=()
if [ -d build/classes ]; then
  aux=(--aux-classpath build/classes)
fi
PMD_JAVA_DISABLE_AUX_CLASSPATH_WARNINGS=true \
  "$PMD_HOME/bin/pmd" check -d src/main/java -R config/pmd-smells.xml \
  -f text --no-progress --no-cache --no-fail-on-violation "${aux[@]}"

The script runs PMD on the application sources and prints the findings. It passes --no-fail-on-violation so that findings do not make the script fail, because the lab expects findings. Without that option PMD exits with status 4 when it reports violations. Run scripts/check.sh first if you want PMD to read the compiled classes. The setting PMD_JAVA_DISABLE_AUX_CLASSPATH_WARNINGS only silences a warning about the Java version of the compiler’s own runtime image; other [WARN] lines, such as the advice to use incremental analysis, may still appear on the error stream.

3. Starting version: before

The coordinator and numeric rule below are the complete starting versions. Copy the rule and gate to refactored as directed in Section 1; the intermediate stage changes neither of them.

3.1 before/src/main/java/ReservationRules.java

public final class ReservationRules {
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

3.2 before/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final ReservationRules rules = new ReservationRules();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!rules.mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return confirmationFor(reservation);
    }

    private String confirmationFor(Reservation reservation) {
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

3.3 before/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."
mkdir -p build/classes
# Remove only generated files from this starter's output directory.
find build/classes -type f -name '*.class' -delete

java -version
"${CHAPTER_JAVAC:-javac}" --release 17 -encoding UTF-8 -d build/classes \
  src/main/java/*.java src/test/java/*.java
java -cp build/classes Chapter02Checks
java -cp build/classes Chapter03AcceptanceExamples
echo "BUILD AND CHECKS PASSED"

4. Stage A: formatter extraction

Use the shared files, the starting rule and starting gate, then add these two files to refactored. The formatter is also used unchanged in after.

4.1 refactored/src/main/java/ConfirmationFormatter.java

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

4.2 refactored/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final ReservationRules rules = new ReservationRules();
    private final ConfirmationFormatter formatter = new ConfirmationFormatter();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!rules.mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return formatter.format(reservation);
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

4.3 Explanation of the structural change

The private confirmation expression moved into ConfirmationFormatter.format. Its string content is unchanged. The coordinator stores the accepted reservation before requesting its text, just as before. The formatter receives only the immutable accepted record, not a reference to mutable storage or an eligibility rule.

The new formatter rejects a null reservation explicitly. That method is a new collaborator API; existing coordinator calls supply a valid reservation. The extraction does not change the existing public request method’s validation or rejection behaviour.

The coordinator still creates the formatter directly. No interface was added for it because this version has one presentation behaviour and no requirement to swap implementations. The formatter is also not a notification sender: returning a string does not send email.

Compare the before and refactored coordinator listings. The only application changes are the new formatter field, the delegated return expression and removal of the old private method. Run the unchanged checks before beginning Stage B.

5. Stage B: explicit policy variation

Use the shared files and the extracted formatter, then add the following files to after. This is the complete running result. The staff limits are an illustrative teaching requirement: fewer than five existing active records and a requested duration from one to fourteen days. They are not approved institutional policy.

5.1 after/src/main/java/BorrowingPolicy.java

/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

5.2 after/src/main/java/ReservationRules.java

public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

5.3 after/src/main/java/StaffReservationRules.java

/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

5.4 after/src/main/java/BorrowerCategory.java

public enum BorrowerCategory {
    STUDENT, STAFF
}

5.5 after/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final BorrowingPolicy studentPolicy = new ReservationRules();
    private final BorrowingPolicy staffPolicy = new StaffReservationRules();
    private final ConfirmationFormatter formatter = new ConfirmationFormatter();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policyFor(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return formatter.format(reservation);
    }

    private BorrowingPolicy policyFor(BorrowerCategory category) {
        return switch (category) {
            case STUDENT -> studentPolicy;
            case STAFF -> staffPolicy;
        };
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

5.6 after/src/main/java/Chapter05Demo.java

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = new ReservationCoordinator();
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

5.7 after/src/test/java/Chapter05Checks.java

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = new ReservationCoordinator();
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = new ReservationCoordinator();
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = new ReservationCoordinator();
        ReservationCoordinator explicitStudent = new ReservationCoordinator();
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = new ReservationCoordinator();
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

5.8 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."
mkdir -p build/classes
# Remove only generated files from this starter's output directory.
find build/classes -type f -name '*.class' -delete

java -version
"${CHAPTER_JAVAC:-javac}" --release 17 -encoding UTF-8 -d build/classes \
  src/main/java/*.java src/test/java/*.java
java -cp build/classes Chapter02Checks
java -cp build/classes Chapter03AcceptanceExamples
java -cp build/classes Chapter05Checks
echo "BUILD AND CHECKS PASSED"

5.9 Why this is a modest extension point

Both policies answer the same count-and-duration question. ReservationRules preserves its original predicate and gains an interface declaration. StaffReservationRules supplies the second documented predicate. Neither asks about item availability or creates records.

The original four-argument operation delegates with STUDENT. The five-argument overload validates the category and uses the selected policy. A null category raises NullPointerException before any state mutation. This is a new overload’s documented behaviour; old callers always supply the non-null student category through delegation.

The selector remains in the coordinator, and the concrete policies are still created there. Adding a new enum category requires another selection arm. The design isolates policy evaluation; it does not claim complete closure against adding categories. Chapter 6 will examine moving object selection and assembly outward.

The category is supplied by trusted synthetic callers. The program does not authenticate it or remember it in a borrower profile. The mixed-category example in the checks intentionally documents this limitation: all records for a borrower ID count, while the category on the current request selects the limit. It is not a recommended real-world workflow for changing a borrower’s authority.

5.10 What the new checks establish

The two policies are passed through BorrowingPolicy references, exercising their common input handling and repeatability. The helper also checks some cases shared by these two documented policies, such as accepting one day at count zero. A future policy with different legitimate limits must have its own concrete expectations; the interface does not universally require every implementation to approve one day.

The integrated checks cover staff’s five-record and fourteen-day boundaries, the old student default, cross-category conflicts and adjacency, and unchanged records after rejection. The test uses List.equals on snapshots of immutable reservations. Because Reservation has identity equality here, equality also requires the same record objects in the same order; a replacement with different record objects would fail.

These finite checks do not prove every possible input and call sequence. Reading the short predicates and checking that they have no mutable state or external calls complements the boundary evidence. The original 34 checks and five acceptance groups remain unchanged and still run.

6. Separate LSP counterexample

This is an isolated teaching program, not an application dependency. The invalid subtype is deliberately labelled. Its harness succeeds only when it observes the expected counterexample and the separate positive-history type obeys its own contract.

6.1 after/examples/lsp/LspExample.java

import java.util.ArrayList;
import java.util.List;

public final class LspExample {
    // Contract: accept any int, append it once, and preserve earlier values in order.
    static class IntHistory {
        private final List<Integer> values = new ArrayList<>();

        public void add(int value) { values.add(value); }
        public List<Integer> values() { return List.copyOf(values); }
    }

    // INTENTIONALLY INVALID SUBTYPE: strengthens the precondition of add.
    static final class PositiveOnlyHistory extends IntHistory {
        @Override
        public void add(int value) {
            if (value <= 0) throw new IllegalArgumentException("Positive values only.");
            super.add(value);
        }
    }

    // Separate type with an honest, narrower contract; not an IntHistory subtype.
    static final class PositiveHistory {
        private final IntHistory entries = new IntHistory();

        public void addPositive(int value) {
            if (value <= 0) throw new IllegalArgumentException("Positive values only.");
            entries.add(value);
        }
        public List<Integer> values() { return entries.values(); }
    }

    private static void client(IntHistory history) {
        history.add(5);
        history.add(-2);
        if (!history.values().equals(List.of(5, -2))) {
            throw new AssertionError("Client expects both entries in order.");
        }
    }

    public static void main(String[] args) {
        client(new IntHistory());
        System.out.println("Base contract client passed.");
        boolean counterexampleObserved = false;
        try {
            client(new PositiveOnlyHistory());
        } catch (IllegalArgumentException expected) {
            counterexampleObserved = true;
            System.out.println("Expected counterexample: " + expected.getMessage());
        }
        if (!counterexampleObserved) {
            throw new AssertionError("Expected the invalid subtype to reject -2.");
        }
        PositiveHistory positive = new PositiveHistory();
        positive.addPositive(5);
        boolean invalidRejected = false;
        try {
            positive.addPositive(-2);
        } catch (IllegalArgumentException expected) {
            invalidRejected = true;
        }
        if (!invalidRejected || !positive.values().equals(List.of(5))) {
            throw new AssertionError("Narrower type must reject without changing entries.");
        }
        System.out.println("Separate positive-history contract passed.");
    }
}

6.2 Compile and run the isolated example

From the directory containing the three versions:

javac --release 17 -d after/build/lsp after/examples/lsp/LspExample.java
java -cp after/build/lsp LspExample

Expected output:

Base contract client passed.
Expected counterexample: Positive values only.
Separate positive-history contract passed.

client accepts an IntHistory reference. It validly adds 5 and -2, then expects both values in order. The subclass rejects -2, strengthening the accepted-input requirement. Java accepts the override because the signature fits; that does not establish behavioural substitutability.

The corrected PositiveHistory has its own public addPositive operation and does not claim to be an IntHistory. It uses composition internally and rejects invalid values without changing existing entries. It could still be badly designed if it advertised the broad contract through another interface; the key repair is agreement between the public promise and the type relationship.

The harness catches the expected exception outside the base client so it can demonstrate and explain the failure. An overall exit status of zero here means the counterexample was successfully observed, not that the invalid subtype satisfies the base contract. The application gate does not compile or run this isolated teaching directory.

7. Model design diagnosis and rationale

7.1 Responsibility diagnosis

ElementPresent responsibilityRelevant change pressureDecision in this chapter
ReservationCoordinatorSequence validation, eligibility, conflict detection and storageRequest workflow and collaborator selectionPreserve sequencing; delegate formatting and policy evaluation
ReservationRulesStudent count/duration decisionStudent borrowing limitsPreserve predicate; implement the shared policy contract
LoanPeriodValid interval and overlap semanticsMeaning of dates and boundariesLeave unchanged
ReservationImmutable accepted-record detailsData carried by a reservationLeave unchanged
Confirmation expressionRender an accepted record as textWording/layout needsExtract a concrete formatter
Staff eligibilityNew count/duration variationExplicit illustrative staff ruleAdd a separate policy implementation

The original coordinator already delegated numeric rules. Claiming that this chapter first extracts them would misdescribe the starting point. The real design pressure is independent presentation change plus a newly stated second policy.

7.1a Model manual smell review of the starting coordinator

This is the answer to the manual smell review in Section 5.1 of the student chapter, applied to the listing in Section 3.2. It is a model of the reading step, done before any tool is run; the tool comparison follows in Section 7.1b. javac -Xlint:all printed nothing on this code.

SmellWhere it appears in reserveMatches a change pressure?Act now?
More than one reason to changeOne method validates identifiers, counts the borrower’s records, applies eligibility, searches for a conflict, creates and stores the record and, through confirmationFor, produces the wordingYes: wording and staff eligibility are listed pressuresYes for wording (Stage A) and eligibility (Stage B); no for storage
Hidden constructionprivate final ReservationRules rules = new ReservationRules(); fixes the policy inside the coordinatorYes: the staff variation needs another policyOnly as far as Stage B needs; full dependency choice is Chapter 6
Repeated or similar logicTwo loops over reservations differ only in the test (borrowerId count versus itemId overlap); rejections are returned as two plain stringsWeakly: no listed pressure asks for itNo: two short loops are clearer than a new abstraction here
Primitive values standing for conceptsborrowerId, itemId and requestedDays are strings and an integer; the rejection reasons are free textNo pressure in this chapterNo: record it as a limitation

What -Xlint:all did not show: every row above. What reading alone did not settle: whether the repeated loops justify an abstraction, which depends on future change, so a correct answer may reasonably leave them.

Potential storage extraction is a reasonable observation, but persistence is not part of the selected change. A diagnosis should distinguish “this may matter later” from “we have evidence that this boundary is needed now”. A proposed repository interface earns no credit merely because a diagram can accommodate it.

7.1b Model PMD comparison

PMD 7.28.0 was run on the before version with the lab rule set (config/pmd-smells.xml) after the gate had compiled the classes. The output is exactly what the chapter shows (with Windows path separators in the recorded run):

src\main\java\Reservation.java:3:	DataClass:	The class 'Reservation' is suspected to be a Data Class (WOC=0.000%, NOPA=0, NOAM=4, WMC=9)
src\main\java\ReservationCoordinator.java:10:	CyclomaticComplexity:	The method 'reserve(String, String, LocalDate, int)' has a cyclomatic complexity of 12.
[INFO] Found 2 violations.
SourceFindingGroupInterpretation and action
PMD, CyclomaticComplexityreserve has complexity 12 (threshold 10)UsefulThe method validates, counts, applies eligibility, searches for conflicts and creates the record. It matches the first manual smell. Act on the part that has a pressure: wording (Stage A) and eligibility (Stage B). Leave the sequence of checks, and say so
PMD, DataClassReservation is a suspected data classLow-value hereIt is an immutable record of an accepted booking, so data without behaviour is its design. Keep it, and note that the behaviour using its fields (the wording) is what moved in Stage A
Manual readingnew ReservationRules() inside the coordinatorMissed by the toolThe coordinator fixes its own policy. Act only as far as Stage B needs; the full dependency choice is Chapter 6
Manual reading“Borrower limit or duration” is one string for two reasonsMissed by the toolA caller must compare strings. Record it as a limitation; no pressure in this chapter
Manual readingTwo similar loops over reservationsNot reported by PMD at its default thresholdsLeave them, as in Section 7.1a

Rule sets compared on the same five files. The result is the reason the lab uses a small set.

PMD rule setFindings on beforeWhat they were
Lab set (eight design rules)2The two above
rulesets/java/quickstart.xml5All NoPackage: the book uses the unnamed package until Chapter 10
category/java/codestyle.xml34MethodArgumentCouldBeFinal, LocalVariableCouldBeFinal, NoPackage, OnlyOneReturn and similar style rules
category/java/bestpractices.xml4SystemPrintln in the demonstration class

After each stage. PMD with the lab rule set reported the same two findings on refactored and after: Reservation as a data class, and reserve at complexity 12 (in after the method is the five-argument overload, at line 17). The refactorings changed who decides wording and eligibility, not the number of branches in the sequence of checks, so a student who expects the finding to disappear should be asked what the tool measures.

The extraction experiment (optional extension). In a scratch copy of after, reserve was split by extracting activeReservationsOf(borrowerId) and isUnavailable(itemId, requested) as private methods. The gate still passed (all five acceptance groups and 44 checks), and PMD reported only the data-class finding: the complexity finding disappeared. The class still validates, counts, checks eligibility, searches and creates, so every responsibility is where it was. The tool reports the number of branches in one method, and Extract Method lowers it without moving a responsibility. Use this to show why a number is not a verdict.

7.2 Suggested Stage A rationale

Confirmation wording can change independently of reservation eligibility and conflicts. I moved the existing expression into a concrete formatter that receives the accepted immutable record. The coordinator still stores the record before formatting, returns the same text, and rejects requests as before. The original checks pass without edited expectations. This adds one collaborator and leaves direct construction in place.

This rationale says what changed, why, which behaviour was preserved and what cost remains. It is stronger than “SRP means every method needs a class”. It also avoids claiming that the formatter delivers notifications.

7.3 Suggested Stage B rationale

Student and staff requests now have two documented count/duration policies. A small BorrowingPolicy contract lets the reservation workflow use either without duplicating overlap or storage logic. The existing overload explicitly keeps student behaviour. The policy selector still lives in the coordinator, so adding another category requires an edit there. Category comes from a trusted example and is not verified identity.

The new staff success cases mean this stage is a feature change. The fact that some restructuring occurs while adding it does not make the complete stage behaviour-preserving. Keep the requirements and checks for the feature visible in the change record.

7.4 Alternatives that deserve consideration

Keep the formatter as a private method. This is defensible when wording is small, stable and changes with the request workflow. It avoids another object and file. The chapter chooses extraction because independent wording change is now an explicit pressure. A student who keeps the method should explain how the stated pressure will be managed, not merely reject classes on aesthetic grounds.

Use a limits object. Since the current policies differ only in constants, a validated object with maximum-active and maximum-days values can express both. Validate configuration at construction and retain the common handling of negative counts and nonpositive durations. This approach avoids separate classes for each numeric combination. It is less direct if future policies require qualitatively different decisions; that future must remain a reasoned possibility, not a fabricated requirement.

Keep a local conditional. A small conditional can be clearer than an abstraction in a very small program. Its cost grows if the same category decision is copied across validation, messaging and storage. The chapter’s interface avoids duplicating the decision operation while leaving selection explicit.

Subclass the coordinator. This is a poor fit for the chosen variation. It risks copying or overriding the whole request workflow to change one rule. The current coordinator is also final. Removing that modifier solely to gain access to a subclassing technique does not establish that the inheritance contract is sound.

7.5 Contract comparison

ContractLegitimate inputs/outcomesClient may not assume
Common BorrowingPolicyEvery integer pair can be evaluated; invalid numeric inputs return false; decision is deterministic and free of the forbidden side effectsEvery policy has student limits or proves availability
Student policyCounts 0–2 and days 1–7 accepted; other pairs rejectedFourteenth-day staff behaviour
Staff policyCounts 0–4 and days 1–14 accepted; other pairs rejectedAn authoritative staff identity check
Original reserve overloadExisting student eligibility and request workflow preservedSelecting a category based on identifier text
Explicit-category overloadUses the supplied non-null category and all existing records for that borrowerPersisted category or permission to choose staff status

The shared policy type permits different decision results because concrete limits are documented per implementation. By contrast, a ReservationRules client specifically relying on the student predicate has a stronger expectation. The staff class should not claim to be that exact student-policy type.

A broader interface must still promise enough to be useful. If it permits arbitrary exceptions, hidden mutation, random decisions and unbounded external operations, it stops supporting the coordinator’s simple reasoning. Broadening a contract to excuse a defective implementation can make existing clients incorrect.

8. AI activity: model review

The example in the student chapter is author-written fictional AI output. These verdicts illustrate a review method; they are not a record of a real model or reviewer.

Proposed changeVerdictConcrete reason
Give every class an interfaceNot justified as statedNo second role or client need is identified for immutable record and interval values; it adds naming and navigation
Add coordinator factory and abstract baseDeferTwo rules do not require a factory hierarchy; direct policy selection remains inspectable
Replace rejection strings with exceptionsSeparate feature/API proposalExisting callers and acceptance examples observe the current rejection results
Make staff rules extend student rulesReject for this source and contractReservationRules is final; removing final would still leave staff approval incompatible with the exact student predicate
Remove all conditionals so no file ever changesReject the promiseAn implementation must still be selected somewhere; moving or hiding that decision is not eliminating it
Shorten the extracted confirmation to “Reserved”Reject during Stage AThe exact output changes, so the stage is no longer the promised extraction
Green build proves SOLIDRejectCompilation and checks examine limited behaviour; responsibility and extension claims still need reasoning

A minimal corrected plan is: run the baseline; extract the unchanged formatter; verify Stage A; state the staff rule; introduce the small policy contract and explicit category path; preserve the old default; run old and new checks; inspect remaining direct dependencies. No real directory or notification integration is required.

Independent evidence includes the final declaration, exact confirmation assertions, rejection checks, the selector’s source and the source of the category argument. A confident explanation from an AI service does not override those facts.

An acceptable review record contains the candidate claim, accepted/rejected/deferred verdict, reason and evidence. Do not reject the whole output merely because it contains many classes. Identify which boundary lacks a purpose or which behaviour changes. Some larger designs are justified by larger requirements; those requirements are absent here.

9. Review-question solutions

9.1 Concept questions

1. Method count and SRP. LoanPeriod has construction, accessors and overlap behaviour supporting one interval abstraction. A single process method could contain borrowing rules, persistence and message formatting. Count does not identify independent change pressures. Use the relevant decisions and the reasons they change.

2. Protected variation and remaining edits. The reservation algorithm delegates count/duration decisions, so changing staff limits need not alter overlap detection or storage. The category selector and directly constructed policy fields remain in the coordinator. Adding another category requires updating that selection arrangement; the entire coordinator is not closed against that change.

3. Different booleans with one interface. The common contract defines the kind of question, valid input handling, determinism and side-effect restrictions. It allows implementation-specific documented limits. Student and staff results for fourteen days differ within that contract. A client that assumes seven days for every implementation has assumed more than the interface promises.

4. Refactoring versus feature. Stage A changes where a fixed result is produced while preserving the existing public operation’s outputs, state effects and failure behaviour. Stage B introduces successful requests that were not previously available through the old policy. The preserved student path is compatibility evidence within a broader feature addition.

9.2 Applied diagnosis

5. Throwing for negative count and incrementing a counter. Negative counts are accepted inputs with a specified false result. Throwing instead changes an observable outcome and can break a client that simply branches on the boolean. Pass (-1, 1) through a BorrowingPolicy reference and assert false; an unexpected exception exposes the defect.

The counter conflicts with this chapter’s explicit no-counter-change contract. Repeating the same inputs may reveal a changed result if the implementation uses the counter, but equal results alone do not prove that a hidden counter was not incremented. Inspect the implementation for mutable state, or observe an exposed effect where available. Do not claim that a finite set of return-value checks can establish absence of every internal mutation.

If counting evaluations becomes a genuine operational requirement, revisit the contract and where that concern belongs. Adding it silently inside the policy changes the stated design promise. The answer should distinguish the independently observable exception defect from the evidence needed for the internal-state claim.

6. Silently ignoring negative values. The base history promises to append any integer once and preserve earlier values. After add(5); add(-2);, a result of [5] weakens the append postcondition even though no exception occurred. Use the valid base client to compare the complete resulting sequence. Either retain broad acceptance in the subtype or expose a separate narrower type whose contract explicitly restricts input. Changing the base contract would require reviewing existing callers and is not a transparent repair.

10. Practical activity: suggested solution and feedback

A complete solution includes both stages and explains their different purposes. The before version runs. Stage A changes only the presentation boundary and retains the original expected results. Stage B states its new policy, preserves the original overload and checks the new cases. The learner can explain the invalid subtype using a valid client call sequence.

A useful review follows the operation in order: validate identifiers and date; count all records for the borrower; apply the selected policy; create the period; check same-item overlap; store exactly one record; format the accepted record. Ask whether a structural edit changed that order or duplicated any rule.

The review should specifically inspect category trust. A demo may explicitly supply STAFF, but a real user selecting a dropdown would not establish authority. The correct chapter-level response is an honest scope note and a future identity-boundary question, not inventing an authentication subsystem for this exercise.

Suggested evidence table:

EvidenceWhat to record
Starting pointActual revision/version and passing original gate
Stage ARelevant diff; original checks with unchanged expectations; observed result
Stage BNew rule table; policy contract; compatibility and staff-boundary results
LSP exampleValid base call sequence; subtype outcome; precise violated promise
ReviewActual reviewer or clearly labelled self-review; findings and corrections
Design rationaleReason for each boundary; alternatives considered; remaining selector coupling
Hosted CI, if usedActual tested revision and run result, not merely the presence of YAML

Do not invent a peer review to complete this table. A supplied model rationale is not evidence that someone else inspected a student’s work. A local passing run is also not proof that the hosted workflow executed.

Give feedback on whether the chosen boundary isolates the stated change, whether the contract is useful, and whether the evidence supports compatibility. Do not grade by number of interfaces, methods or commits. Apply the module leader’s approved formative and assessment arrangements.

10.1 Optional numeric-configuration experiment

An alternative could use one concrete type with immutable maximum-active and maximum-days configuration. Its constructor must reject invalid configuration rather than let a negative maximum silently define an unusable policy. Its operation should still return false for invalid request inputs, as required by the shared policy contract. Constructor validation and request-input behaviour are different contracts.

Compare these outcomes:

Future changeSeparate policy classesConfigured numeric limits
Change staff days from 14 to another positive limitEdit staff predicateEdit staff configuration
Add a third pair of simple limitsAdd another policy or share implementationSupply another configuration
Introduce a qualitatively different eligibility ruleNew implementation may express it directlyMay need a broader design beyond two numbers
Trace the current rule in reviewRead the concrete predicateRead implementation plus selected configuration

No approach wins merely by having fewer files. A short report should say which anticipated changes matter and how the alternative handles them. Keep the alternative in a separate experiment so the running version remains unambiguous.

11. Transfer challenge: one possible answer

The coursework feedback tool has at least three pressures: the allowed score input domain, the policy that maps a valid score to a grade, and how the result is presented. Formatting can change independently of the grading decision. Extracting the existing message without changing its text is a structural step; adding a new grading policy is a feature.

For a small original teaching example, define GradePolicy.grade(int score) to accept scores 0 through 100 and return either PASS or REVIEW; other scores raise IllegalArgumentException. The policy is deterministic, performs no I/O and changes no stored records. These are invented exercise rules, not institutional assessment policy.

ScoreIllustrative policy A: pass at 50Illustrative policy B: pass at 60
49REVIEWREVIEW
50PASSREVIEW
59PASSREVIEW
60PASSPASS
100PASSPASS
-1 or 101IllegalArgumentExceptionIllegalArgumentException

The shared interface permits documented threshold variation. A client must not assume that every policy passes 50. The formatter receives the score and computed grade, or a coherent result object, without recomputing the grade. Selection stays explicit in the application entry point or a small selector; a factory framework is unnecessary for the exercise.

A subtype counterexample would implement the broad score contract but throw for a valid score of zero because it treats zero as “not submitted”. A base client entitled to grade zero would break. Another would return null even though the contract promises one of the two grade values. Both require contract-based reasoning, not only signature inspection.

Repair the first by preserving zero as a valid score and representing submission status separately if that requirement is real. Alternatively, expose a different operation with a narrower documented input domain, and do not advertise it as a substitute for the original policy. Repair the second by providing the promised grade or explicitly redesigning the result contract with its callers.

Protect the existing policy and message with unchanged examples during extraction. For the feature, add boundary examples around both thresholds and check invalid scores. Review that the formatter does not grade again and that the selected policy matches the intended exercise mode. As in the equipment example, finite checks support the design but do not prove every larger workflow correct.

12. Teaching notes

12.1 Do the new checks detect faults?

Fourteen deliberate faults were introduced one at a time into a copy of the after version, and the full gate was run on each.

FaultResult
Staff count limit < 5 changed to <= 5Caught: staff sixth rejected
Staff duration <= 14 changed to < 14Caught: staff upper accepted boundaries
Staff duration <= 14 changed to <= 15Caught: staff fifteen days rejected
Staff negative-count guard removedCaught: negative count returns false
Student count limit < 3 changed to < 5Caught: fourth rejected
Student duration <= 7 changed to <= 14Caught: eight-day duration rejected
Selector arms swapped (STUDENT uses the staff policy)Caught: fourth rejected
Old four-argument overload delegates with STAFFCaught: fourth rejected
Confirmation text changed to Reserved:Caught: exact first confirmation
Confirmation drops the end dateCaught: exact first confirmation
Storing the reservation skippedCaught: one success stored
Count restricted to staff requests onlyCaught: fourth rejected
Overlap check ignores the itemCaught: different item and seven-day limit allowed
Explicit Objects.requireNonNull(category, ...) removedNot caught, and not a gap: switch (category) on null still throws NullPointerException before any state changes, so the documented behaviour is identical. Only the order against identifier validation differs, which the contract does not specify

Thirteen of fourteen faults were caught. The survivor is a useful teaching point for Chapter 7: a surviving fault can mean the fault is behaviourally equivalent under the documented contract, not that the checks are weak. Adding a check for the order of validation would pin down something the contract deliberately leaves open.

The plain Java check runners throw AssertionError explicitly; they do not depend on Java’s optional assert activation flag. The application gate propagates a runner failure through its nonzero status. Its last line is reached only after all three application check programs pass.

12.2 Reuse of the Chapter 4 workflow

When the after version becomes the root of a team’s normal application repository, retain the Chapter 4 .github/workflows/java-check.yml. It still runs bash scripts/check.sh; the updated script now includes Chapter05Checks. No pipeline rewrite is needed for this application change.

If the team stores all three teaching directories in one repository instead, its workflow must explicitly invoke the intended version’s script, such as bash after/scripts/check.sh. Do not assume that a root-level command will find a script inside a subdirectory. Keep hosted results tied to the actual tested path and revision.

The isolated LSP example is intentionally outside the application gate. It can be run as a teaching demonstration with the commands in Section 6. The fact that it contains a deliberately invalid subtype is clearly documented rather than hidden among application classes.

12.3 Teaching emphasis

Ask students to explain a change using its behaviour and maintenance pressure before naming a principle. When they say “SRP”, ask what changes independently. When they say “OCP”, ask which variation is protected and which code still changes. When they say “LSP”, ask for the valid client, its promised result and the subtype’s conflicting outcome.

Do not require a perfect final architecture in this chapter. The remaining selector and direct construction are visible discussion points for Chapter 6. Students should understand why the intermediate design improves one concern while retaining another, rather than believe that the word SOLID means the design is finished.

The staff policy is an illustrative progression toward the later MVP work. It does not implement the complete reservation-and-loan flow, an authoritative borrower directory, or the later assessed requirement change. Keep the project backlog and milestone claims aligned with the actual code.

For stronger students, discuss whether internal instrumentation that leaves all public results unchanged violates a behavioural specification. The answer depends on the promises made. This chapter explicitly prohibits changing a counter in the policy; a different contract could permit invisible instrumentation. Distinguish that design choice from a universal claim about subtyping.

References

Checked 12 October 2026. The source listings, limits, history example and model answers are original teaching examples.

  1. Martin, R. C. (2014), The Single Responsibility Principle.
  2. Martin, R. C. (2014), The Open Closed Principle.
  3. Oracle, Java Language Specification, Java SE 17, Chapter 9: Interfaces.
  4. Liskov, B. H. and Wing, J. M. (1994), A Behavioral Notion of Subtyping, ACM Transactions on Programming Languages and Systems, 16(6), 1811–1841. DOI: 10.1145/197320.197383.
  5. Fowler, M. (2004), Definition of Refactoring.

Chapter 6 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 6 Companion: Code, Explanations and Solutions.

Interfaces, Dependencies and Composition
OO Development and Agile Practices

1. Versions and reconstruction

This file accompanies Chapter 6, Interfaces, Dependencies, and Composition. It supplies a complete running Java version, the starting and deliberately coupled coordinator versions, a small educational container and model solutions.

StagePurposeHow to use it
Chapter 5 baselinePolicies and formatter, no notification outputUse the complete Chapter 5 after version
Coupled teaching versionAdd local notification with direct provider constructionIn a copy of Chapter 5, replace the coordinator with Section 2.2 and add the three indicated sender files
Chapter 6 resultSupply notification, formatting and policy mapping from outsideReconstruct the complete after directory from Sections 3–5

Adding notification is a feature change. The next structural step separates construction from the request workflow. Removing the no-argument coordinator constructor is an explicit source API migration, not transparent compatibility.

1.1 Reconstruct the complete result

Every listing beginning with after/ belongs in one directory named after. Create its intermediate directories and copy only the content of each fence. Do not add the two Section 2 coordinator alternatives to that directory. Their paths beginning with before/ and coupled/ identify different teaching versions.

Section 3 contains every application source file. Section 4 contains all four check runners. Section 5 contains the build script, ignore file and README. Section 6 contains the optional container program, which is excluded from the normal application gate.

The result uses Java 17, Bash and the standard library. It has no external dependencies, Maven configuration or JUnit dependency at this version. The existing unnamed-package layout remains. A framework container is not required.

1.2 Commands and expected results

From inside after:

bash scripts/check.sh
java -cp build/classes ReservationApplication
java -cp build/classes Chapter04Demo
java -cp build/classes Chapter05Demo

The gate should report the original 34 checks, five acceptance groups, 44 Chapter 5 checks and:

All 28 Chapter 6 checks passed.
BUILD AND CHECKS PASSED

The new application prints:

Notice: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Result: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).

The two lines have different roles. The sender prints a local notice; the application’s main method prints the returned reservation result. This does not mean the sender was invoked twice.

The Chapter 4 and Chapter 5 demonstrations retain their earlier output because they now use a recorder for notification. Their construction expressions have changed, but their request sequences and expected reservation results have not. No remote service is contacted by any supplied example.

2. Starting and deliberately coupled coordinator versions

These two listings are alternatives to the completed coordinator. For either one, use the complete Chapter 5 version’s other files and gate. Do not mix the Chapter 6 composition root or migrated check runners into these older-constructor versions.

2.1 Starting coordinator

This is exactly the Chapter 5 coordinator. It formats a return value and does not call a notification sender.

2.1.1 before/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final BorrowingPolicy studentPolicy = new ReservationRules();
    private final BorrowingPolicy staffPolicy = new StaffReservationRules();
    private final ConfirmationFormatter formatter = new ConfirmationFormatter();

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policyFor(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        return formatter.format(reservation);
    }

    private BorrowingPolicy policyFor(BorrowerCategory category) {
        return switch (category) {
            case STUDENT -> studentPolicy;
            case STAFF -> staffPolicy;
        };
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

2.2 Coupled notification feature

In a separate copy of Chapter 5, use this coordinator and add NotificationSender.java, NotificationException.java and ConsoleNotificationSender.java from Section 3. Its original no-argument construction remains. It chooses console output directly, so accepted requests now print notices while the old checking assertions still examine reservation results.

2.2.1 coupled/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final BorrowingPolicy studentPolicy = new ReservationRules();
    private final BorrowingPolicy staffPolicy = new StaffReservationRules();
    private final ConfirmationFormatter formatter = new ConfirmationFormatter();

    private final NotificationSender notifications = new ConsoleNotificationSender(
            System.out, new ConfirmationFormatter());

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policyFor(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    private BorrowingPolicy policyFor(BorrowerCategory category) {
        return switch (category) {
            case STUDENT -> studentPolicy;
            case STAFF -> staffPolicy;
        };
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

The field is typed as NotificationSender, but the initializer names ConsoleNotificationSender, System.out and a concrete formatter. The workflow therefore still selects its own output environment. This newly introduced teaching defect is not a claim about the previous chapter.

The coupled version already uses the explicit retain-and-report rule for expected notification failure. The final structural step preserves that feature policy while changing how collaborators arrive. A recording configuration intentionally changes the output destination; it does not change the acceptance algorithm.

3. Complete application source for after

The first seven files retain Chapter 5’s domain and formatting behaviour. The remaining files establish notification and explicit composition. Paths in this section are the actual files to create.

3.1 after/src/main/java/BorrowerCategory.java

public enum BorrowerCategory {
    STUDENT, STAFF
}

3.2 after/src/main/java/BorrowingPolicy.java

/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

3.3 after/src/main/java/ReservationRules.java

public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

3.4 after/src/main/java/StaffReservationRules.java

/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

3.5 after/src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

3.6 after/src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

3.7 after/src/main/java/ConfirmationFormatter.java

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

3.8 after/src/main/java/NotificationSender.java

/**
 * Accept one non-null confirmed reservation for this implementation's local output.
 * Do not modify the reservation or coordinator. Report expected output failure with
 * NotificationException. Normal return is not a promise of email delivery.
 */
@FunctionalInterface
public interface NotificationSender {
    void send(Reservation reservation);
}

3.9 after/src/main/java/NotificationException.java

public final class NotificationException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public NotificationException(String message) { super(message); }
}

3.10 after/src/main/java/RecordingNotificationSender.java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class RecordingNotificationSender implements NotificationSender {
    private final List<Reservation> received = new ArrayList<>();

    @Override
    public void send(Reservation reservation) {
        received.add(Objects.requireNonNull(reservation, "reservation"));
    }

    public List<Reservation> received() { return List.copyOf(received); }
}

3.11 after/src/main/java/ConsoleNotificationSender.java

import java.io.PrintStream;
import java.util.Objects;

public final class ConsoleNotificationSender implements NotificationSender {
    private final PrintStream output;
    private final ConfirmationFormatter formatter;

    public ConsoleNotificationSender(PrintStream output, ConfirmationFormatter formatter) {
        this.output = Objects.requireNonNull(output, "output");
        this.formatter = Objects.requireNonNull(formatter, "formatter");
    }

    @Override
    public void send(Reservation reservation) {
        output.println("Notice: " + formatter.format(reservation));
        if (output.checkError()) {
            throw new NotificationException("Console output failed.");
        }
    }
}

3.12 after/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

3.13 after/src/main/java/ReservationApplication.java

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                new ConfirmationFormatter(), sender);
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

3.14 after/src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.15 after/src/main/java/Chapter05Demo.java

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.16 Reading the object graph

ReservationApplication is the assembly boundary. Its create method supplies a map containing student and staff policies, a concrete formatter and the sender selected by its caller. Its main method chooses a console sender and stream. The coordinator knows the sender contract and policy map, not those provider choices.

The coordinator’s constructor rejects missing dependencies and incomplete category mappings. Map.copyOf stops later changes to the caller’s map from changing this mapping. It does not deep-copy the policy instances; their existing stateless contract still matters.

The original reserve overload continues to delegate with STUDENT. The explicit-category overload still trusts its supplied category, and all records for the borrower contribute to the active count. No identity verification has been added.

After acceptance the coordinator stores the record, formats its return value and makes one sender call. Expected notification failure adds the explicit suffix while retaining the record. There is no retry, rollback or queued work. An unexpected runtime failure is not swallowed; it can propagate after storage. Do not describe the operation as atomic across output and state.

RecordingNotificationSender holds immutable reservation objects in its own local list and exposes an unmodifiable snapshot. ConsoleNotificationSender writes to the supplied PrintStream and checks its error flag. It does not close the stream because ownership remains with the caller. Checking the stream can detect a local error, but cannot promise that no characters were written before failure.

4. Complete checking programs

The first three runners have exactly the previous assertions and request sequences. Their only source changes replace new ReservationCoordinator() with ReservationApplication.create(new RecordingNotificationSender()). That setup migration is necessary because the no-argument constructor no longer exists.

The fourth runner exercises notification and assembly behaviour. The hand-written failing sender is a controlled collaborator for this exercise; the full test-double taxonomy belongs in Chapter 7.

4.1 after/src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = ReservationApplication.create(new RecordingNotificationSender());
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.2 after/src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = ReservationApplication.create(new RecordingNotificationSender());
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

4.3 after/src/test/java/Chapter05Checks.java

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = ReservationApplication.create(new RecordingNotificationSender());
        ReservationCoordinator explicitStudent = ReservationApplication.create(new RecordingNotificationSender());
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.4 after/src/test/java/Chapter06Checks.java

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public final class Chapter06Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator c = ReservationApplication.create(inbox);
        String first = c.reserve("B1", "CAM1", day, 2);
        check(first.equals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "accepted return unchanged");
        check(inbox.received().size() == 1, "one notification per accepted request");
        check(inbox.received().get(0) == c.reservations().get(0), "accepted record supplied");
        List<Reservation> oldInbox = inbox.received();
        expect(UnsupportedOperationException.class, oldInbox::clear, "inbox snapshot unmodifiable");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "conflict result preserved");
        check(inbox.received().size() == 1, "conflict sends nothing");
        c.reserve("B1", "MIC1", day, 8);
        check(inbox.received().size() == 1, "duration rejection sends nothing");
        expect(IllegalArgumentException.class, () -> c.reserve("", "CAM1", day, 1), "bad ID");
        check(inbox.received().size() == 1, "invalid argument sends nothing");
        c.reserve("B2", "CAM1", day.plusDays(2), 1);
        check(inbox.received().size() == 2, "adjacent acceptance sends once");
        check(oldInbox.size() == 1, "earlier inbox snapshot stable");

        FailingSender failure = new FailingSender();
        ReservationCoordinator failed = ReservationApplication.create(failure);
        String failureResult = failed.reserve("B1", "CAM1", day, 2);
        check(failureResult.equals(first + " Notification failed; reservation retained."),
                "explicit partial-success result");
        check(failed.reservations().size() == 1, "notification failure retains accepted record");
        check(failure.calls == 1, "one attempt and no automatic retry");
        check(failed.reserve("B1", "CAM1", day, 2).equals("Rejected: item unavailable."),
                "retrying reservation is still a conflict");
        check(failure.calls == 1, "conflicting repeat does not attempt notification");

        ReservationCoordinator unexpected = ReservationApplication.create(new BrokenSender());
        expect(IllegalStateException.class, () -> unexpected.reserve("B1", "CAM1", day, 2),
                "unexpected sender failure is not swallowed");
        check(unexpected.reservations().size() == 1, "record is stored before the unexpected failure");

        Map<BorrowerCategory, BorrowingPolicy> configured = policies();
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                null, new ConfirmationFormatter(), inbox), "null policy map");
        expect(IllegalArgumentException.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules()),
                new ConfirmationFormatter(), inbox), "missing staff policy");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, null, inbox), "null formatter");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, new ConfirmationFormatter(), null), "null sender");
        Map<BorrowerCategory, BorrowingPolicy> mutable = new EnumMap<>(configured);
        ReservationCoordinator copied = new ReservationCoordinator(mutable,
                new ConfirmationFormatter(), new RecordingNotificationSender());
        mutable.put(BorrowerCategory.STUDENT, (count, days) -> false);
        check(copied.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "caller map mutation cannot rewire coordinator");
        Map<BorrowerCategory, BorrowingPolicy> restrictive = new EnumMap<>(configured);
        restrictive.put(BorrowerCategory.STUDENT, (count, days) -> false);
        RecordingNotificationSender restrictedInbox = new RecordingNotificationSender();
        ReservationCoordinator restricted = new ReservationCoordinator(restrictive,
                new ConfirmationFormatter(), restrictedInbox);
        check(restricted.reserve("B1", "CAM1", day, 1).equals(
                "Rejected: borrower limit or duration."), "injected policy is used");
        check(restrictedInbox.received().isEmpty(), "injected rejection sends nothing");

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        ConsoleNotificationSender console = new ConsoleNotificationSender(
                new PrintStream(bytes, true, StandardCharsets.UTF_8), new ConfirmationFormatter());
        console.send(c.reservations().get(0));
        check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: " + first + System.lineSeparator()),
                "console implementation uses supplied stream");
        PrintStream brokenOutput = new PrintStream(new OutputStream() {
            @Override public void write(int value) throws IOException {
                throw new IOException("Expected output failure");
            }
        });
        ConsoleNotificationSender brokenConsole = new ConsoleNotificationSender(
                brokenOutput, new ConfirmationFormatter());
        expect(NotificationException.class, () -> brokenConsole.send(c.reservations().get(0)),
                "console reports expected output failure");
        expect(NullPointerException.class, () -> inbox.send(null), "null notice rejected");
        System.out.println("All " + checks + " Chapter 6 checks passed.");
    }

    private static Map<BorrowerCategory, BorrowingPolicy> policies() {
        return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                      BorrowerCategory.STAFF, new StaffReservationRules());
    }

    private static final class FailingSender implements NotificationSender {
        private int calls;
        @Override public void send(Reservation reservation) {
            calls++;
            throw new NotificationException("Expected teaching failure");
        }
    }

    // A programming defect, not an expected output failure: it must not be swallowed.
    private static final class BrokenSender implements NotificationSender {
        @Override public void send(Reservation reservation) {
            throw new IllegalStateException("Unexpected programming failure");
        }
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action, String label) {
        try { action.run(); }
        catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.5 What the 28 new checks establish

The runner checks the accepted result, one recorded notification, the exact accepted record supplied to it, recorder snapshot properties and no notification on conflict, duration rejection or invalid identifiers. It then observes a controlled expected failure after storage, the explicit suffix, record retention and absence of automatic retry. Repeating the conflicting reservation does not trigger another sender call.

Two further checks cover the claim that unexpected programming failures are not swallowed. A sender that throws IllegalStateException makes the reservation call propagate that exception, and the record is already stored when it does. Without them, replacing catch (NotificationException ...) with catch (RuntimeException ...) would pass every check.

Constructor checks exercise null dependencies and missing category configuration. A mutable input map is changed after construction to show that the coordinator’s mapping is insulated from that change. A deliberately restrictive injected student policy shows that the supplied policy is actually used. This is a legal broader-interface policy for the test, not the production student mapping.

The console output is captured in a local byte stream, so it can be compared without contacting any external service. A deliberately broken stream demonstrates translation into NotificationException. Java PrintStream normally stores I/O errors internally; checking only for a thrown IOException around println would miss that mechanism.

The checks do not prove thread safety, durable output or remote delivery. The old assertions preserve evidence for existing reservation rules; the new observations cover the new output feature. Passing counts are evidence about these executed cases, not a measure of design quality.

5. Build and repository files

5.1 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."
mkdir -p build/classes
# Remove only generated files from this starter's output directory.
find build/classes -type f -name '*.class' -delete

java -version
"${CHAPTER_JAVAC:-javac}" --release 17 -encoding UTF-8 -d build/classes \
  src/main/java/*.java src/test/java/*.java
java -cp build/classes Chapter02Checks
java -cp build/classes Chapter03AcceptanceExamples
java -cp build/classes Chapter05Checks
java -cp build/classes Chapter06Checks
echo "BUILD AND CHECKS PASSED"

5.2 after/.gitignore

/build/
*.class

5.2a after/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps shell scripts in LF form when Git on Windows converts line endings. Without it, a CRLF copy of scripts/check.sh fails under WSL or Linux bash.

5.3 after/README.md

# Campus Equipment Lending: Chapter 6

Use a Java 17 JDK and Bash. From this directory:

```bash
bash scripts/check.sh
java -cp build/classes ReservationApplication
```

The gate runs 34 Chapter 2 checks, five Chapter 3 acceptance groups,
44 Chapter 5 checks and 28 Chapter 6 checks. It must return nonzero on failure.
The application prints one local console notice and its reservation result.
It sends no email and makes no network calls.

The no-argument ReservationCoordinator constructor has been removed.
Use ReservationApplication.create(sender), or supply the policy map,
formatter and sender directly. Existing reserve overloads keep their
student/default and explicit-category meanings.

Expected notification failure retains the accepted reservation and appends
" Notification failed; reservation retained." to the confirmation.
No retry queue, remote delivery guarantee or transaction is implemented.
All records remain active, in memory and sequentially accessed.
Category comes from a trusted fixture, not an authoritative directory.
Availability enquiry, cancellation and loan returns remain unimplemented.

Optional educational container, after a successful gate:

```bash
javac --release 17 -cp build/classes -d build/classes examples/container/ContainerWalkthrough.java
java -cp build/classes ContainerWalkthrough
```

The next gate clears generated class files; compile the optional example
again if needed. The container is for teaching, not production use.

The Chapter 4 hosted workflow can still call bash scripts/check.sh when
this checkpoint is the repository root. A local pass is not a hosted run.

The Chapter 4 hosted workflow can keep invoking bash scripts/check.sh when the completed version is the repository root. If the team stores after as a subdirectory, change the invoked path deliberately. The workflow template’s presence is not evidence of hosted execution.

The gate deletes generated class files, compiles all direct .java children in the application/check source directories and runs four checking programs. Its final success marker is only reached if each command succeeds. Optional examples are compiled separately because they are teaching programmes outside the running application.

6. Educational container walkthrough

This is a small isolated illustration, not a recommended framework. It uses typed provider functions, caches one instance per registered type, and identifies missing bindings and cycles. It does not implement concurrent access, scopes, resource cleanup, reflection or annotations. Only the configuration code sees the container; the coordinator still receives ordinary constructor arguments.

6.1 after/examples/container/ContainerWalkthrough.java

import java.time.LocalDate;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;

public final class ContainerWalkthrough {
    // Educational, single-threaded container: one lazy instance per registered type.
    // No reflection, annotations, scopes, resource cleanup or production guarantees.
    static final class DemoContainer {
        private final Map<Class<?>, Supplier<?>> providers = new HashMap<>();
        private final Map<Class<?>, Object> instances = new HashMap<>();
        private final Set<Class<?>> constructing = new HashSet<>();

        public <T> void bind(Class<T> type, Supplier<? extends T> provider) {
            Objects.requireNonNull(type, "type");
            Objects.requireNonNull(provider, "provider");
            if (providers.containsKey(type)) {
                throw new IllegalArgumentException("Duplicate binding: " + type.getSimpleName());
            }
            providers.put(type, provider);
        }

        public <T> T get(Class<T> type) {
            if (instances.containsKey(type)) return type.cast(instances.get(type));
            Supplier<?> provider = providers.get(type);
            if (provider == null) throw new IllegalArgumentException("Missing binding: " + type.getSimpleName());
            if (!constructing.add(type)) throw new IllegalStateException("Dependency cycle: " + type.getSimpleName());
            try {
                T instance = type.cast(Objects.requireNonNull(provider.get(), "provider result"));
                instances.put(type, instance);
                return instance;
            } finally {
                constructing.remove(type);
            }
        }
    }

    public static void main(String[] args) {
        DemoContainer container = new DemoContainer();
        container.bind(ConfirmationFormatter.class, ConfirmationFormatter::new);
        container.bind(NotificationSender.class, RecordingNotificationSender::new);
        container.bind(ReservationCoordinator.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                container.get(ConfirmationFormatter.class),
                container.get(NotificationSender.class)));

        ReservationCoordinator first = container.get(ReservationCoordinator.class);
        ReservationCoordinator again = container.get(ReservationCoordinator.class);
        if (first != again) throw new AssertionError("Expected one coordinator instance.");
        System.out.println(first.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2));
        if (again.reservations().size() != 1) throw new AssertionError("Expected shared state.");
        System.out.println("Repeated resolution shares the same coordinator and one reservation.");

        DemoContainer missing = new DemoContainer();
        try {
            missing.get(NotificationSender.class);
            throw new AssertionError("Expected missing-binding failure.");
        } catch (IllegalArgumentException expected) {
            System.out.println(expected.getMessage());
        }
        DemoContainer cyclic = new DemoContainer();
        cyclic.bind(NotificationSender.class, () -> cyclic.get(NotificationSender.class));
        try {
            cyclic.get(NotificationSender.class);
            throw new AssertionError("Expected cycle failure.");
        } catch (IllegalStateException expected) {
            System.out.println(expected.getMessage());
        }
    }
}

6.2 Run and trace it

From inside after, first run the normal gate, then:

javac --release 17 -cp build/classes -d build/classes examples/container/ContainerWalkthrough.java
java -cp build/classes ContainerWalkthrough

Expected output:

Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Repeated resolution shares the same coordinator and one reservation.
Missing binding: NotificationSender
Dependency cycle: NotificationSender

Registration stores a provider without running it. The first coordinator resolution runs its provider, resolves its formatter and sender, constructs the coordinator and caches it. The second resolution returns the same coordinator. Both references therefore observe the same reservation list.

A new DemoContainer with no sender registration produces the missing-binding example. A separate container whose sender provider resolves itself produces the cycle example. These expected failures are caught in the demonstration so it can print their meaning. A zero exit status means the walkthrough observed the intended cases, not that missing or cyclic configuration is valid.

The application gate clears generated class files. Recompile the optional example after another gate run. Compiling it into build/classes avoids a platform-specific multi-directory classpath for this small exercise.

6.3 Compare with a framework container

A framework container similarly needs configuration, resolution rules and lifetime decisions. Spring’s official introduction describes its role in creating, configuring and assembling application objects. A framework adds substantial facilities beyond this example; it does not remove the need to decide what is shared and which contracts the application uses.

No Spring dependencies, annotations or framework-run claims are included in this version. In an instructor-led framework lab, map each registration to the manual constructor graph first. Find the actual policy mapping and sender choice, then determine whether the coordinator is reused or recreated. If business methods look up services through the container, discuss that service-location choice explicitly.

7. Model design rationale

7.1 Client needs and contract boundaries

ClientNeeded roleDeliberately excluded capability
Reservation coordinatorNotify an accepted reservation through configured outputProvider administration, output history inspection, identity management
Demonstration/check codeInspect recorded noticesNo need to add that operation to the coordinator’s role
Application entry pointSelect implementations and supply configurationNo reservation acceptance decisions duplicated here
Console senderWrite to its supplied stream and format a noticeNo borrowing-policy selection or reservation storage

NotificationSender serves an actual variation: console output and in-memory recording. Both implementations accept the same domain record under the same expected-failure convention. The recorder may expose additional operations on its concrete type without burdening the coordinator’s interface.

A proposed administration interface should only be created when an administration client exists. The exercise asks students to identify excluded operations, not to implement every hypothetical role they can imagine.

7.2 DIP, injection and composition

DIP: the coordinator depends on a small application role rather than console configuration or a provider SDK. Output implementations adapt to that role. The interface’s ownership and vocabulary matter, not merely its declaration.

Constructor injection: the coordinator receives the role implementation and other collaborators from its caller. This is ordinary Java object construction. Passing a concrete console sender through a concrete-typed parameter would still be injection, but would provide less provider independence.

Composition root: ReservationApplication selects the implementation and builds the object graph. It is allowed to reference concrete classes. Its factory does not fetch services on behalf of the coordinator during reservation processing; it supplies them before use.

Container: the optional walkthrough automates registered construction and reuse. It is kept outside the business object. The coordinator has no DemoContainer field or lookup and remains usable with manual assembly.

7.3 Assembly and runtime collaboration are different views

This object-assembly view shows the actual objects selected in the console demonstration. It differs from the source-dependency diagram in the student chapter.

flowchart TD
    A["Application entry point"] -. creates .-> C["Coordinator instance"]
    A -. creates .-> P["Student and staff policy map"]
    A -. creates .-> N["Console sender instance"]
    A -. creates .-> F["Confirmation formatter"]
    P --> C
    N --> C
    F --> C

Dotted arrows labelled “creates” start at the application entry point. Solid arrows into the coordinator mean collaborators supplied at construction. The sender also receives its own formatter and stream in the actual code; those details are omitted from this focused view to keep the central assembly relationship readable. The code supplies all of them explicitly.

A runtime trace has a different order: the coordinator evaluates eligibility and conflicts, stores a record, formats its result and invokes the already supplied sender. Do not infer event order from a dependency or construction diagram.

7.4 Why the constructor changed

Removing the no-argument constructor prevents the coordinator from hiding default provider choices. The price is source migration for callers. Existing setup expressions now use the application factory with a recorder, while the old reservation assertions remain unchanged.

An alternative compatibility constructor could delegate to a fixed default graph. That would preserve source compatibility but retain a default assembly decision inside or reachable from the coordinator. It may be justified in a published API with real callers; this teaching version has known source callers, so the migration is explicit and manageable.

Do not call the entire chapter a behaviour-preserving refactoring. Notification is new observable behaviour, expected notification failure has a new result, and constructor compatibility changes. The subsequent separation of construction preserves the agreed feature semantics under equivalent output configuration.

7.5 Failure policy and its limits

The chosen rule is: an accepted reservation remains accepted if the configured sender reports an expected output failure. The returned text distinguishes that situation. A failed sender does not convert acceptance into rejection, consume another identifier, trigger an automatic retry or create a background task.

This policy is small and observable. It is not reliable messaging. A future requirement for durable delivery might need persistent pending work, retry identity and an explicit result model. Those additions should follow an actual requirement and belong in later integration work, not be smuggled into a lesson on constructor injection.

Unexpected programming failures can still propagate after storage. The coordinator does not catch every runtime exception or erase evidence of bugs. The supplied formatter is deterministic and receives a valid immutable record, but the application is not a general transaction manager for arbitrary collaborators.

8. AI activity: model critique

The example is author-written fictional AI output. A useful response evaluates concrete dependencies and observable behaviour instead of judging the number of interface declarations.

Fixture claim or changeVerdictReason
Type the field as NotificationSenderUseful but insufficient aloneIt expresses a role, but concrete construction can remain in the coordinator
Read provider settings inside the coordinatorReject for this boundaryEnvironmental configuration still changes the reservation workflow’s source
Construct the console sender inside the coordinatorReject for the final designThe caller cannot select a recorder without editing the client
Add configure/export/delete operations to the sender roleReject without a client needThey couple this client to unrelated capabilities
Catch every runtime exception and return ordinary confirmationRejectIt hides programming defects and makes expected output failure indistinguishable from ordinary success
Inject a global container for future lookupsReject for this exampleReal required collaborators become hidden behind a registry dependency
Declare DIP and DI complete because an interface existsRejectContract ownership, provider knowledge and actual assembly must be inspected

A corrected answer supplies the three explicit constructor parameters, the application-owned sender contract, two implementations and external assembly. It preserves the stated notification timing and failure outcome. It also names the constructor migration and the still-limited in-memory model.

Independent verification includes inspecting concrete constructor calls, searching the coordinator for provider and container references, running with the recorder, and observing the failure case. A second model agreeing with the first is not a substitute for those observations.

9. Review-question solutions

9.1 Concept questions

1. Why omit received()? The coordinator’s job is to submit an accepted record, not inspect output history. The recorder’s concrete inspection API serves a different client, such as the check code. Adding it to the sender role would force a console implementation to invent a history it does not otherwise need.

2. Injection without the intended DIP boundary. A constructor taking ConsoleNotificationSender receives its collaborator from outside, so it uses DI. Its source still depends on that concrete provider. A contract copied directly from a provider SDK can also retain provider coupling despite having an interface name. Define the application role and inject a conforming implementation.

3. Knowledge in the root. The root knows which category maps to each concrete policy, which sender implementation is selected, which stream it uses and how formatter instances are constructed. The coordinator knows the collaborators’ roles and when to invoke them, not how their environment was chosen.

4. Sharing changes state lifetime. Two resolutions of one cached coordinator observe the same in-memory reservations. Two fresh coordinators have separate empty lists and can each accept what appears to be the first reservation. The method source can be identical while the assembled object graph changes the system’s behaviour. Neither caching nor fresh construction provides concurrency protection by itself.

9.2 Applied diagnosis

5. Delete after notification failure. The output may already have occurred, so deleting the reservation cannot undo it. The user could receive a notice for a record that no longer exists. Returning rejection also hides the earlier acceptance and may encourage another request. This chapter instead retains the record and reports the output failure explicitly; it does not imply the notice was never partially produced. Another policy requires an explicit consistency design, not a catch block pretending to reverse external effects.

6. What copying the map protects. The unmodifiable copy fixes associations between enum categories and policy object references. Changes to the original map cannot replace those references. The copy is shallow: an externally held policy object could still change internally if its implementation permitted it. The supplied stateless policy contract and implementations provide the additional stability. A collection wrapper cannot enforce arbitrary collaborator behaviour.

10. Practical activity: model reasoning and feedback

A satisfactory result identifies a genuine client role, supplies it from outside and explains failure semantics. It is not enough to replace a field type while leaving provider construction or settings lookup in a business method.

The work should keep three observations separate: existing reservation rules still behave as expected; the new notification feature has the stated effects; and collaborator assembly has moved to a visible boundary. Old tests alone address only the first. The 28 new checks and source inspection address selected risks in the other two.

For a peer walkthrough, ask the reviewer to substitute the recorder without editing the coordinator and explain where the notification goes. Next, ask them to identify the state after the controlled sender failure. Finally, ask which object owns the sender’s output stream. Retain actual findings; these prompts are not evidence that a review occurred.

Review concernEvidence worth inspecting
Role is client-specificCoordinator uses only the sender operation and application-domain record
Provider choice is externalConcrete output construction appears in assembly code, not the coordinator
Valid configurationNull and missing-policy construction failures occur before request processing
Mapping is stableEditing the caller’s original map does not rewire an existing coordinator
Existing behaviourMigrated setup with unchanged reservation assertions passes
Feature effectsAccepted record supplied once; rejection causes no send; expected failure retains state
LifetimeRepeated container resolution shares the same coordinator in the demonstration
Claims are boundedNo claim of email delivery, atomic messaging, thread safety or a hosted run

The optional resolver alternative is reasonable if resolving policy becomes a genuine client role (for example, if selection has its own stable contract beyond a fixed enum map. for the current two-entry mapping), another interface may add indirection without a new benefit. Either argument should be tied to the actual changes expected.

No numerical rubric is introduced here. Use the module leader’s approved criteria. The chapter’s completion conditions are learning checks, not new assessment weights or deadlines.

11. Transfer challenge: one possible solution

For the document converter, define a CompletionNotifier role that accepts an immutable successful-conversion result containing synthetic input/output names and the information needed for the notice. It should not validate conversion requests, perform conversion or administer user accounts.

The conversion workflow decides when conversion has succeeded. A desktop notifier can display the result, while an in-memory notifier records it. The application root selects one and supplies it through the converter’s constructor. The root also owns environment-specific objects such as the window or output destination.

A possible failure rule is: retain the successfully converted file and return a result distinguishing conversion success from notification failure. Do not delete the output simply because display failed, and do not claim background retry without implementing it. Whether that rule fits a real product must be agreed with its stakeholders.

Acceptance exampleConversion stateNotification state
Valid synthetic input converts successfullyOne expected output producedOne completion result submitted
Conversion itself fails before output completionNo successful completion reportedNo completion notice submitted
Conversion succeeds but notifier reports expected failureSuccessful output retainedFailure reported separately; no automatic retry implied

A source dependency diagram can show the conversion workflow depending on its application-defined notifier role and both concrete outputs implementing it. Assembly code knows the desktop or recording implementation. No provider-specific window handle should leak into the conversion operation’s core contract.

The lesson transfers because it separates client need, provider detail, assembly and partial success. It does not require copying the equipment class names, using a map of policies, or choosing the same textual result representation.

12. Quiz 1 and teaching notes

Quiz 1’s stated coverage remains Weeks 1–4, corresponding to Chapters 2–5. Refer students to responsibilities and encapsulation, Agile artefacts and criteria, credible iteration planning and CI evidence, and SRP/OCP/LSP reasoning. Do not infer that holding the quiz in Week 5 adds ISP, DIP or containers to its assessed scope. Follow the module leader’s confirmed guidance.

For this week’s lab, begin with manual assembly. Ask learners to trace references before introducing the optional container. The container’s provider registrations should be recognisable as another representation of the same constructor graph. If a framework is used in class, identify its scopes and ownership rules rather than treating annotations as explanations.

Keep the hand-written recorder and failing sender vocabulary modest. They preview controlled collaborators, but Chapter 7 owns the full comparison of test-double roles and the introduction of a mocking framework. This chapter’s checks are supplied evidence, not a claim that a complete TDD cycle has been demonstrated.

The source remains a sequential in-memory version. Authoritative category lookup, loans and returns, cancellation and availability enquiry remain visible future work. Notification here is a local output port; later chapters can extend integration behaviour when there is a reason.

13. Fault experiment and container notes

13.1 Do the checks detect faults?

Fourteen faults were introduced one at a time into a copy of after, and the full gate was run on each. All fourteen are caught.

FaultResult
Notification sent before the record is storedCaught: notification failure retains accepted record
catch (NotificationException) widened to catch (RuntimeException)Caught: unexpected sender failure is not swallowed
Failure suffix text changedCaught: explicit partial-success result
One retry added after a failed sendCaught: one attempt and no automatic retry
Sender called twice on successCaught: one notification per accepted request
Map.copyOf removedCaught: caller map mutation cannot rewire coordinator
Missing-category check removedCaught: missing staff policy
Null sender check removedCaught: null sender
Null formatter check removedCaught: null formatter
Policy lookup always uses the student policyCaught: staff confirmation with new limit
Console sender’s checkError() test removedCaught: console reports expected output failure
Console prefix Notice: changedCaught: console implementation uses supplied stream
Recorder returns its mutable listCaught: inbox snapshot unmodifiable
Factory ignores the supplied senderCaught: one notification per accepted request

13.2 Container lab

The student chapter’s required container lab (practical step 7) uses the educational container DemoContainer, listed in Section 6.1 of this file and runnable with plain javac. The module descriptor mentions a real library container; the book records that as a known difference and does not pin one. A team that wants to try a library container may do so on its own, with the dependency management that the plain-javac gate does not provide.

The supplied application gate stops on a nonzero check result. The container’s expected errors are caught inside its demonstration to verify those teaching cases; this is distinct from suppressing unexpected application test failures.

References

The companion uses the sources listed in the student chapter. Source checked date: 12 October 2026. The implementations, acceptance examples and model answers are original teaching material.

  1. Martin, R. C. (2000), Design Principles and Design Patterns.
  2. Martin, R. C. (2016), A Little Architecture.
  3. Fowler, M. (2004), Inversion of Control Containers and the Dependency Injection Pattern.
  4. Fowler, M. (2005), Inversion of Control.
  5. Oracle, Java SE 17 Map.
  6. Oracle, Java SE 17 PrintStream.
  7. Spring, Introduction to the Spring IoC Container and Beans.

Chapter 7 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 7 Companion: Code, Explanations and Solutions.

Testing as a Design Practice
OO Development and Agile Practices

1. Read this before reconstructing the examples

This file accompanies Chapter 7, Testing as a Design Practice. It contains the complete running project, JUnit tests, a Maven build, two deliberately staged coordinator alternatives, model solutions and teaching notes.

New behaviour: US-02 reservation availability enquiry and a bounded in-memory checkout/return flow. Retained behaviour: student/staff rules, reservation results, rejection state effects and Chapter 6’s local notification contract. Explicit limitation: a returned loan does not cancel its reservation, release its reserved interval or remove it from the inherited reservation count.

The TDD record covers the first availability test, its green implementation and extraction of a shared conflict helper. Other query tests, the loan increment and Mockito examples extend the verified result. They are not all claimed to have been developed through recorded test-first cycles.

1.1 Versions and commands

ToolTested selectionRole
JavaJava 17 language/API targetApplication and test execution
Maven3.9.11Build and dependency resolution
Compiler Plugin3.14.1Java compilation
Surefire Plugin3.5.4JUnit Platform execution
JUnit Jupiter5.13.4Assertions, test discovery and parameterised cases
Mockito5.20.0Interface mock, stubbing and call verification

These are fixed chapter choices, not a newest-release recommendation. Install a normal Java 17 JDK and Maven 3.9.11. Confirm java -version and mvn -version. The first build needs access to the configured Maven repository. A dependency-resolution failure is an environment failure, not a TDD assertion failure.

1.2 Reconstruct the running project

Create a directory named after. Save every listing whose path begins with after/ in the indicated location, preserving case and creating intermediate directories. Copy only code-fence contents. Sections 2–4 contain all application, test and configuration files.

The code still uses Java’s unnamed package. Maven recognises the source roles through src/main/java and src/test/java; named packages are a later structural step. The test-resource file must be placed under src/test/resources, not beside a Java source file.

From inside after:

mvn --batch-mode verify
java -cp target/classes Chapter07Demo

The Bash wrapper remains available:

bash scripts/check.sh

This replaces the earlier direct javac gate. New test sources import JUnit and Mockito, so compiling the entire test directory without their dependencies is no longer the intended workflow. Maven supplies the test classpath; these libraries are not runtime dependencies of the application demonstration.

The observed full build reports:

Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Four of those cases bridge to the older runners. Their internal counts remain distinct: 34 Chapter 2 checks, five Chapter 3 example groups, 44 Chapter 5 checks and 28 Chapter 6 checks. A JUnit invocation is not necessarily one assertion.

The MVP demonstration prints:

Available before reservation: true
Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Available after reservation: false
Loan active after checkout: true
Loan active after return: false
Accepted-reservation notices: 1

The demo uses a fixed clock and one coordinator/loan-service pair. It records one reservation notice; checkout and return do not send new notices. There is no remote delivery, database or hosted execution involved.

2. Complete application source

The existing value objects, policy implementations, notification collaborators and application factory retain their Chapter 6 definitions. The coordinator adds the query and shares conflict detection. Loan, LoanService and Chapter07Demo supply the bounded lending flow.

2.1 after/src/main/java/BorrowerCategory.java

public enum BorrowerCategory {
    STUDENT, STAFF
}

2.2 after/src/main/java/BorrowingPolicy.java

/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

2.3 after/src/main/java/ReservationRules.java

public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

2.4 after/src/main/java/StaffReservationRules.java

/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

2.5 after/src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

2.6 after/src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

2.7 after/src/main/java/ConfirmationFormatter.java

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

2.8 after/src/main/java/NotificationSender.java

/**
 * Accept one non-null confirmed reservation for this implementation's local output.
 * Do not modify the reservation or coordinator. Report expected output failure with
 * NotificationException. Normal return is not a promise of email delivery.
 */
@FunctionalInterface
public interface NotificationSender {
    void send(Reservation reservation);
}

2.9 after/src/main/java/NotificationException.java

public final class NotificationException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public NotificationException(String message) { super(message); }
}

2.10 after/src/main/java/RecordingNotificationSender.java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class RecordingNotificationSender implements NotificationSender {
    private final List<Reservation> received = new ArrayList<>();

    @Override
    public void send(Reservation reservation) {
        received.add(Objects.requireNonNull(reservation, "reservation"));
    }

    public List<Reservation> received() { return List.copyOf(received); }
}

2.11 after/src/main/java/ConsoleNotificationSender.java

import java.io.PrintStream;
import java.util.Objects;

public final class ConsoleNotificationSender implements NotificationSender {
    private final PrintStream output;
    private final ConfirmationFormatter formatter;

    public ConsoleNotificationSender(PrintStream output, ConfirmationFormatter formatter) {
        this.output = Objects.requireNonNull(output, "output");
        this.formatter = Objects.requireNonNull(formatter, "formatter");
    }

    @Override
    public void send(Reservation reservation) {
        output.println("Notice: " + formatter.format(reservation));
        if (output.checkError()) {
            throw new NotificationException("Console output failed.");
        }
    }
}

2.12 after/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        if (hasConflict(itemId, requested)) {
            return "Rejected: item unavailable.";
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        LoanPeriod requested = new LoanPeriod(start, endExclusive);
        return !hasConflict(itemId, requested);
    }

    private boolean hasConflict(String itemId, LoanPeriod requested) {
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId) && existing.period().overlaps(requested)) {
                return true;
            }
        }
        return false;
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

2.13 after/src/main/java/ReservationApplication.java

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                new ConfirmationFormatter(), sender);
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

2.14 after/src/main/java/Loan.java

import java.time.LocalDate;
import java.util.Objects;

/** Immutable loan state. A null returnedOn means this loan is active. */
public record Loan(String reservationId, String borrowerId, String itemId,
                   LocalDate borrowedOn, LocalDate returnedOn) {
    public Loan {
        if (reservationId == null || reservationId.isBlank()
                || borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Loan identifiers must not be blank.");
        }
        Objects.requireNonNull(borrowedOn, "borrowedOn");
        if (returnedOn != null && returnedOn.isBefore(borrowedOn)) {
            throw new IllegalArgumentException("Return cannot precede checkout.");
        }
    }
    public boolean active() { return returnedOn == null; }
}

2.15 after/src/main/java/LoanService.java

import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** One service per coordinator/session; sequential, in-memory teaching flow. */
public final class LoanService {
    private final ReservationCoordinator reservations;
    private final Clock clock;
    private final Map<String, Loan> loans = new LinkedHashMap<>();

    public LoanService(ReservationCoordinator reservations, Clock clock) {
        this.reservations = Objects.requireNonNull(reservations, "reservations");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public Loan checkOut(String reservationId) {
        Reservation reservation = reservations.reservations().stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown reservation."));
        if (loans.containsKey(reservationId)) {
            throw new IllegalStateException("Reservation already used for a loan.");
        }
        LocalDate today = LocalDate.now(clock);
        if (today.isBefore(reservation.period().start())
                || !today.isBefore(reservation.period().endExclusive())) {
            throw new IllegalStateException("Checkout must be within the reservation period.");
        }
        boolean itemAlreadyOut = loans.values().stream()
                .anyMatch(loan -> loan.active() && loan.itemId().equals(reservation.itemId()));
        if (itemAlreadyOut) throw new IllegalStateException("Item is already on loan.");
        Loan loan = new Loan(reservation.id(), reservation.borrowerId(),
                reservation.itemId(), today, null);
        loans.put(reservationId, loan);
        return loan;
    }

    public Loan returnLoan(String reservationId) {
        Loan current = loans.get(reservationId);
        if (current == null || !current.active()) {
            throw new IllegalStateException("No active loan for this reservation.");
        }
        Loan returned = new Loan(current.reservationId(), current.borrowerId(),
                current.itemId(), current.borrowedOn(), LocalDate.now(clock));
        loans.put(reservationId, returned);
        return returned;
    }

    public List<Loan> loans() { return List.copyOf(loans.values()); }
}

2.16 after/src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

2.17 after/src/main/java/Chapter05Demo.java

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

2.18 after/src/main/java/Chapter07Demo.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

public final class Chapter07Demo {
    public static void main(String[] args) {
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator reservations = ReservationApplication.create(inbox);
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println("Available before reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        System.out.println(reservations.reserve("B1", "CAM1", day, 2));
        System.out.println("Available after reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        LoanService loans = new LoanService(reservations,
                Clock.fixed(Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        System.out.println("Loan active after checkout: " + loans.checkOut("R1").active());
        System.out.println("Loan active after return: " + loans.returnLoan("R1").active());
        System.out.println("Accepted-reservation notices: " + inbox.received().size());
    }
}

2.19 Reading the new behaviour

isAvailable validates the item and constructs a valid half-open period before inspecting conflicts. It has no borrower argument, no count limit and no call to reserve. The shared helper is private; tests observe public behaviour rather than require a particular helper name or invocation count.

LoanService looks up confirmed reservations from its associated coordinator. Checkout requires a date inside the reserved period, rejects an already-used reservation and prevents two active loans for the same item within this service. Return requires an active loan. A backward date cannot create a return before checkout because the new immutable Loan value validates that invariant before the map is updated.

The Loan record’s returnedOn component is null only while active. A return creates a replacement record; earlier snapshots retain the previous value. Identifiers and checkout dates are validated. The record syntax supplies component accessors, value equality and a data-oriented representation; it does not provide validation automatically.

The service has its own loan map. Construct one service per coordinator/session. Two independent services would have independent loan state, and none of this code is thread-safe. Reservation counts and availability are unchanged by returning a loan: this deliberately preserves the earlier reservation contract while exposing the unresolved completion policy.

3. Complete tests

The four legacy runners below are unchanged from Chapter 6. The JUnit bridge invokes them explicitly. New test classes then examine the query, policy boundaries, loan state and notification interactions.

3.1 after/src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = ReservationApplication.create(new RecordingNotificationSender());
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

3.2 after/src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = ReservationApplication.create(new RecordingNotificationSender());
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

3.3 after/src/test/java/Chapter05Checks.java

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = ReservationApplication.create(new RecordingNotificationSender());
        ReservationCoordinator explicitStudent = ReservationApplication.create(new RecordingNotificationSender());
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

3.4 after/src/test/java/Chapter06Checks.java

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public final class Chapter06Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator c = ReservationApplication.create(inbox);
        String first = c.reserve("B1", "CAM1", day, 2);
        check(first.equals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "accepted return unchanged");
        check(inbox.received().size() == 1, "one notification per accepted request");
        check(inbox.received().get(0) == c.reservations().get(0), "accepted record supplied");
        List<Reservation> oldInbox = inbox.received();
        expect(UnsupportedOperationException.class, oldInbox::clear, "inbox snapshot unmodifiable");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "conflict result preserved");
        check(inbox.received().size() == 1, "conflict sends nothing");
        c.reserve("B1", "MIC1", day, 8);
        check(inbox.received().size() == 1, "duration rejection sends nothing");
        expect(IllegalArgumentException.class, () -> c.reserve("", "CAM1", day, 1), "bad ID");
        check(inbox.received().size() == 1, "invalid argument sends nothing");
        c.reserve("B2", "CAM1", day.plusDays(2), 1);
        check(inbox.received().size() == 2, "adjacent acceptance sends once");
        check(oldInbox.size() == 1, "earlier inbox snapshot stable");

        FailingSender failure = new FailingSender();
        ReservationCoordinator failed = ReservationApplication.create(failure);
        String failureResult = failed.reserve("B1", "CAM1", day, 2);
        check(failureResult.equals(first + " Notification failed; reservation retained."),
                "explicit partial-success result");
        check(failed.reservations().size() == 1, "notification failure retains accepted record");
        check(failure.calls == 1, "one attempt and no automatic retry");
        check(failed.reserve("B1", "CAM1", day, 2).equals("Rejected: item unavailable."),
                "retrying reservation is still a conflict");
        check(failure.calls == 1, "conflicting repeat does not attempt notification");

        ReservationCoordinator unexpected = ReservationApplication.create(new BrokenSender());
        expect(IllegalStateException.class, () -> unexpected.reserve("B1", "CAM1", day, 2),
                "unexpected sender failure is not swallowed");
        check(unexpected.reservations().size() == 1, "record is stored before the unexpected failure");

        Map<BorrowerCategory, BorrowingPolicy> configured = policies();
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                null, new ConfirmationFormatter(), inbox), "null policy map");
        expect(IllegalArgumentException.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules()),
                new ConfirmationFormatter(), inbox), "missing staff policy");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, null, inbox), "null formatter");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, new ConfirmationFormatter(), null), "null sender");
        Map<BorrowerCategory, BorrowingPolicy> mutable = new EnumMap<>(configured);
        ReservationCoordinator copied = new ReservationCoordinator(mutable,
                new ConfirmationFormatter(), new RecordingNotificationSender());
        mutable.put(BorrowerCategory.STUDENT, (count, days) -> false);
        check(copied.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "caller map mutation cannot rewire coordinator");
        Map<BorrowerCategory, BorrowingPolicy> restrictive = new EnumMap<>(configured);
        restrictive.put(BorrowerCategory.STUDENT, (count, days) -> false);
        RecordingNotificationSender restrictedInbox = new RecordingNotificationSender();
        ReservationCoordinator restricted = new ReservationCoordinator(restrictive,
                new ConfirmationFormatter(), restrictedInbox);
        check(restricted.reserve("B1", "CAM1", day, 1).equals(
                "Rejected: borrower limit or duration."), "injected policy is used");
        check(restrictedInbox.received().isEmpty(), "injected rejection sends nothing");

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        ConsoleNotificationSender console = new ConsoleNotificationSender(
                new PrintStream(bytes, true, StandardCharsets.UTF_8), new ConfirmationFormatter());
        console.send(c.reservations().get(0));
        check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: " + first + System.lineSeparator()),
                "console implementation uses supplied stream");
        PrintStream brokenOutput = new PrintStream(new OutputStream() {
            @Override public void write(int value) throws IOException {
                throw new IOException("Expected output failure");
            }
        });
        ConsoleNotificationSender brokenConsole = new ConsoleNotificationSender(
                brokenOutput, new ConfirmationFormatter());
        expect(NotificationException.class, () -> brokenConsole.send(c.reservations().get(0)),
                "console reports expected output failure");
        expect(NullPointerException.class, () -> inbox.send(null), "null notice rejected");
        System.out.println("All " + checks + " Chapter 6 checks passed.");
    }

    private static Map<BorrowerCategory, BorrowingPolicy> policies() {
        return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                      BorrowerCategory.STAFF, new StaffReservationRules());
    }

    private static final class FailingSender implements NotificationSender {
        private int calls;
        @Override public void send(Reservation reservation) {
            calls++;
            throw new NotificationException("Expected teaching failure");
        }
    }

    // A programming defect, not an expected output failure: it must not be swallowed.
    private static final class BrokenSender implements NotificationSender {
        @Override public void send(Reservation reservation) {
            throw new IllegalStateException("Unexpected programming failure");
        }
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action, String label) {
        try { action.run(); }
        catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

3.5 after/src/test/java/LegacyRegressionTest.java

import org.junit.jupiter.api.Test;

class LegacyRegressionTest {
    @Test void chapter02() { Chapter02Checks.main(new String[0]); }
    @Test void chapter03() { Chapter03AcceptanceExamples.main(new String[0]); }
    @Test void chapter05() { Chapter05Checks.main(new String[0]); }
    @Test void chapter06() { Chapter06Checks.main(new String[0]); }
}

3.6 after/src/test/java/AvailabilityTddTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTddTest {
    @Test
    void overlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying() {
        // Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator coordinator = ReservationApplication.create(inbox);
        LocalDate start = LocalDate.of(2026, 10, 20);
        coordinator.reserve("B1", "CAM1", start, 2);
        List<Reservation> recordsBefore = coordinator.reservations();
        List<Reservation> noticesBefore = inbox.received();

        // Act: inspect the overlapping interval without requesting a reservation.
        boolean available = coordinator.isAvailable("CAM1", start.plusDays(1), start.plusDays(3));

        // Assert: the answer and both kinds of state matter.
        assertAll(
                () -> assertFalse(available, "An overlapping item must be unavailable"),
                () -> assertEquals(recordsBefore, coordinator.reservations()),
                () -> assertEquals(noticesBefore, inbox.received()));
    }
}

3.7 after/src/test/java/AvailabilityTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);
    private final RecordingNotificationSender inbox = new RecordingNotificationSender();
    private final ReservationCoordinator c = ReservationApplication.create(inbox);

    @Test void emptyScheduleIsAvailableWithoutAnySideEffect() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(2)));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void adjacentPeriodAndDifferentItemAreAvailable() {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertTrue(c.isAvailable("CAM1", day.plusDays(2), day.plusDays(3)));
        assertTrue(c.isAvailable("MIC1", day, day.plusDays(2)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @ParameterizedTest @ValueSource(ints = {0, -1})
    void invalidPeriodFailsWithoutChangingState(int endOffset) {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertThrows(IllegalArgumentException.class,
                () -> c.isAvailable("CAM1", day, day.plusDays(endOffset)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @Test void malformedIdentifiersAndNullDatesAreRejected() {
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(" ", day, day.plusDays(1)));
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(null, day, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", null, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", day, null));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void queryDoesNotAllocateAnIdentifier() {
        c.isAvailable("CAM1", day, day.plusDays(1));
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-21).",
                c.reserve("B1", "CAM1", day, 1));
    }

    @Test void availabilityDoesNotMeanBorrowerEligibility() {
        for (String item : List.of("CAM1", "MIC1", "TRI1")) c.reserve("B1", item, day, 1);
        assertTrue(c.isAvailable("CAM2", day, day.plusDays(1)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM2", day, 1));
        assertEquals(3, inbox.received().size());
    }

    @Test void queryCanSpanMoreDaysThanAStudentMayReserve() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(30)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 30));
    }
}

3.8 after/src/test/java/BorrowingPolicyTest.java

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BorrowingPolicyTest {
    @ParameterizedTest(name = "{0}: count={1}, days={2} -> {3}")
    @CsvSource({
        "STUDENT, 0, 1, true", "STUDENT, 2, 7, true",
        "STUDENT, 3, 1, false", "STUDENT, 0, 8, false",
        "STAFF, 4, 14, true", "STAFF, 5, 1, false",
        "STAFF, 0, 15, false", "STUDENT, -1, 1, false",
        "STAFF, 0, 0, false", "STAFF, 0, -1, false"
    })
    void documentedBoundaries(BorrowerCategory category, int count, int days, boolean expected) {
        BorrowingPolicy policy = category == BorrowerCategory.STUDENT
                ? new ReservationRules() : new StaffReservationRules();
        assertEquals(expected, policy.mayRequest(count, days));
    }
}

3.9 after/src/test/java/LoanServiceTest.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class LoanServiceTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);

    private ReservationCoordinator reserved() {
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        c.reserve("B1", "CAM1", start, 2);
        return c;
    }

    @Test void checkoutAndReturnUseTheSuppliedClock() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        Loan active = service.checkOut("R1");
        List<Loan> snapshot = service.loans();
        assertThrows(UnsupportedOperationException.class, snapshot::clear);
        assertEquals(start, active.borrowedOn());
        assertTrue(active.active());
        clock.set(Instant.parse("2026-10-21T09:00:00Z"));
        Loan returned = service.returnLoan("R1");
        assertAll(() -> assertFalse(returned.active()),
                () -> assertEquals(start.plusDays(1), returned.returnedOn()),
                () -> assertTrue(snapshot.get(0).active(), "Old immutable snapshot stays active"),
                () -> assertEquals(List.of(returned), service.loans()));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertEquals(List.of(returned), service.loans());
    }

    @ParameterizedTest
    @ValueSource(strings = {"2026-10-19T12:00:00Z", "2026-10-22T00:00:00Z"})
    void checkoutOutsideHalfOpenPeriodIsRejected(String instant) {
        LoanService service = new LoanService(reserved(), Clock.fixed(Instant.parse(instant), ZoneOffset.UTC));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void unknownReservationAndInactiveReturnChangeNothing() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        assertThrows(IllegalArgumentException.class, () -> service.checkOut("R99"));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void aReservationCannotBeCheckedOutTwiceEvenAfterReturn() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        service.checkOut("R1");
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        service.returnLoan("R1");
        List<Loan> before = service.loans();
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertEquals(before, service.loans());
    }

    @Test void unreturnedItemBlocksTheNextAdjacentReservationUntilReturned() {
        ReservationCoordinator c = reserved();
        c.reserve("B2", "CAM1", start.plusDays(2), 1);
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(c, clock);
        service.checkOut("R1");
        clock.set(Instant.parse("2026-10-22T09:00:00Z"));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R2"));
        assertEquals(1, service.loans().size());
        service.returnLoan("R1");
        assertTrue(service.checkOut("R2").active());
    }

    @Test void clockZoneDeterminesTheCheckoutDate() {
        Clock local = Clock.fixed(Instant.parse("2026-10-19T22:30:00Z"), ZoneId.of("Asia/Baghdad"));
        LoanService service = new LoanService(reserved(), local);
        assertEquals(start, service.checkOut("R1").borrowedOn());
    }

    @Test void backwardClockCannotRecordReturnBeforeCheckout() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        service.checkOut("R1");
        List<Loan> before = service.loans();
        clock.set(Instant.parse("2026-10-19T09:00:00Z"));
        assertThrows(IllegalArgumentException.class, () -> service.returnLoan("R1"));
        assertEquals(before, service.loans());
    }

    @Test void returningLoanDoesNotRewriteHistoricalReservationsOrTheirCount() {
        ReservationCoordinator c = reserved();
        LoanService service = new LoanService(c, Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        List<Reservation> before = c.reservations();
        service.checkOut("R1");
        service.returnLoan("R1");
        assertEquals(before, c.reservations());
        assertFalse(c.isAvailable("CAM1", start, start.plusDays(1)));
    }

    static final class AdjustableClock extends Clock {
        private final AtomicReference<Instant> now;
        private final ZoneId zone;
        AdjustableClock(Instant instant) { this(new AtomicReference<>(instant), ZoneOffset.UTC); }
        private AdjustableClock(AtomicReference<Instant> now, ZoneId zone) {
            this.now = now; this.zone = zone;
        }
        void set(Instant instant) { now.set(instant); }
        @Override public ZoneId getZone() { return zone; }
        @Override public Clock withZone(ZoneId zone) { return new AdjustableClock(now, zone); }
        @Override public Instant instant() { return now.get(); }
    }
}

3.10 after/src/test/java/NotificationMockitoTest.java

import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class NotificationMockitoTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);

    @Test void acceptedReservationIsSentOnceWithItsDomainDetails() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        c.reserve("B1", "CAM1", day, 2);
        ArgumentCaptor<Reservation> captured = ArgumentCaptor.forClass(Reservation.class);
        verify(sender).send(captured.capture());
        Reservation sent = captured.getValue();
        assertAll(() -> assertEquals("R1", sent.id()),
                () -> assertEquals("B1", sent.borrowerId()),
                () -> assertEquals("CAM1", sent.itemId()),
                () -> assertEquals(day, sent.period().start()),
                () -> assertEquals(day.plusDays(2), sent.period().endExclusive()));
    }

    @Test void rejectedDurationDoesNotNotify() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 8));
        assertTrue(c.reservations().isEmpty());
        verifyNoInteractions(sender);
    }

    @Test void expectedNotificationFailureRetainsReservationWithoutRetry() {
        NotificationSender sender = mock(NotificationSender.class);
        doThrow(new NotificationException("Expected test failure")).when(sender).send(any(Reservation.class));
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."
                + " Notification failed; reservation retained.", c.reserve("B1", "CAM1", day, 2));
        assertEquals(1, c.reservations().size());
        verify(sender, times(1)).send(any(Reservation.class));
    }
}

3.11 How to read the suite

AvailabilityTddTest is the first test used in the recorded cycle. Its expected false result distinguishes the initially optimistic query from the required overlap rule. The other availability cases distinguish empty state, adjacency, another item, invalid input, identifier preservation and the difference between availability and eligibility.

BorrowingPolicyTest has ten independently stated table rows. Parameterised invocation names show category and inputs when one row fails. The expected booleans come from the published student/staff rule table, not from calling another copy of the predicate.

LoanServiceTest has nine invocations, including two dates in the out-of-window parameterised case. Fixed clocks make simple scenarios deterministic. Its small adjustable clock is used only when one service must retain state as the test advances time. An atomic reference backs its instant, and views created with a different zone share that controlled instant. This does not make the loan service thread-safe.

NotificationMockitoTest follows the already-understood hand-written recorder/failing-sender examples. It mocks only the output role. The real coordinator performs eligibility, storage and formatting. Captured arguments are checked against stated domain details, and expected failure is examined through the returned text and retained state.

Mockito’s verify(sender) checks one invocation by default; times(1) makes that intent explicit in the failure scenario. verifyNoInteractions is appropriate where the selected rule forbids notification altogether. These tests do not assert private loops or helper-call counts.

The legacy bridge is intentionally coarse. Each wrapper method can execute many assertions, and an early failure can stop the rest of that runner. It preserves existing evidence during migration; future conversion into smaller JUnit methods can improve diagnosis without changing expectations.

4. Build and execution files

4.1 after/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>equipment-lending</artifactId>
  <version>0.7.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

4.2 after/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

mock-maker-proxy

4.3 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
"${CHAPTER_MVN:-mvn}" --batch-mode verify

4.4 after/.gitignore

/target/
/build/
*.class

4.4a after/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps scripts/check.sh in LF form when Git on Windows converts line endings. Without it, a CRLF copy of the script fails under WSL or Linux bash.

4.5 after/README.md

# Campus Equipment Lending: Chapter 7

Use a Java 17 JDK and Maven 3.9.11. The POM pins JUnit Jupiter 5.13.4,
Mockito 5.20.0, Compiler Plugin 3.14.1 and Surefire 3.5.4.
The first build needs access to Maven Central; offline mode needs a warm cache.

```bash
mvn --batch-mode verify
java -cp target/classes Chapter07Demo
```

The Bash wrapper scripts/check.sh runs the same verify command. JUnit cases
are in *Test.java classes. LegacyRegressionTest explicitly invokes the four
older main-based check runners. They are not discovered as tests by themselves.
Surefire's XML/text reports are under target/surefire-reports.

The observed complete suite has 35 JUnit invocations, including four legacy
wrappers. Legacy output separately reports 34 checks, five acceptance groups,
44 Chapter 5 checks and 28 Chapter 6 checks. Do not add these as equivalent units.

The query inspects reservation conflicts and changes no records or notices.
It does not check a borrower's eligibility, real inventory or outstanding loans.
The loan service supports one checkout per confirmed reservation, within its
period, and return of an active loan. Use one coordinator and one loan service
per sequential session. The clock is explicit. No remote service is contacted.

Returning a loan does not cancel or remove its reservation. All stored
reservations still count toward the inherited borrowing limit, and their
periods still block reservation enquiries. Persistence, cancellation,
authoritative identity, concurrent access and retry delivery remain outside scope.

Mockito is configured with its interface-only proxy mock maker, selected by the
resource under src/test/resources/mockito-extensions. The examples mock
NotificationSender, not final classes, clocks or domain values, and the mocks are
Java dynamic proxies. On newer JDKs Mockito may still attach its ByteBuddy agent
when it starts, and the JVM then prints a "Java agent has been loaded dynamically"
warning; the tests still pass. To silence it, pass -XX:+EnableDynamicAgentLoading
to the test JVM, for example with -DargLine=-XX:+EnableDynamicAgentLoading.

For the TDD reconstruction, use the red/green listings from the Chapter 7 code
file as src/main/java/ReservationCoordinator.java in a separate working copy.
Run mvn --batch-mode -Dtest=AvailabilityTddTest test, then restore the final
coordinator and run the complete gate. Never merge deliberate red/mutant code.

A local build does not establish a hosted CI run. The Chapter 4 workflow can
still invoke scripts/check.sh on a runner with the documented Maven/JDK setup.
Review and record the actual installed Maven version and executed revision.

4.6 Configuration decisions

Both test libraries have test scope. Surefire discovers the *Test classes and uses JUnit Platform. failIfNoTests prevents an entirely empty test discovery from reporting success; it does not guarantee that every intended test was included. Check the report and class list.

The mock-maker-proxy resource chooses Mockito’s interface-only mock maker. That suits NotificationSender: in a probe run, the mock’s class was a JDK dynamic proxy (jdk.proxy2.$Proxy43), not a generated subclass. Do not try to mock final domain classes or Clock with this configuration; the tests use real values and explicit clock implementations instead.

The resource does not stop Mockito from loading its ByteBuddy agent. On a recent JDK the log shows WARNING: A Java agent has been loaded dynamically, with and without the resource, and the tests pass. Passing -XX:+EnableDynamicAgentLoading to the test JVM (Surefire’s argLine) removes the warning. The JVM states that dynamic agent loading will be disallowed by default in a future release, so check the Mockito documentation for the setup recommended for the JDK you use. The behaviour can differ between JDK and Mockito versions.

Maven stores compiled application classes in target/classes, tests in target/test-classes and Surefire reports in target/surefire-reports. The JAR is not configured as a self-launching executable, so use the documented java -cp target/classes Chapter07Demo command rather than assume java -jar will work.

The Chapter 4 workflow can still run bash scripts/check.sh on a runner with the documented tools. Confirm the actual Maven version in the hosted environment. This chapter verifies local execution, not the configuration or results of a team’s remote repository.

5. Reproducing the recorded TDD cycle

Use a disposable copy of the complete project. The following two files are alternative coordinator snapshots, not additional classes to compile. Copy a snapshot’s contents into src/main/java/ReservationCoordinator.java when instructed. Keep the original final file available for restoration. Do not save .red.java or .green.java alternatives inside the active Maven source directory.

The original Chapter 6 coordinator had no query. The red snapshot below adds the signature and input checks with an intentionally incomplete true result. It is a compiling teaching starting point for the behavioural test.

5.1 stages/ReservationCoordinator.red.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        new LoanPeriod(start, endExclusive);
        return true; // INTENTIONALLY INCOMPLETE: red-stage teaching implementation.
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

After selecting the red source, run:

mvn --batch-mode -Dtest=AvailabilityTddTest test

Observed: one test, one failure, zero errors, and the message An overlapping item must be unavailable ==> expected: <false> but was: <true>. The build exits nonzero. This is the intended missing-behaviour failure. A dependency or compilation failure must be resolved before claiming that observation.

The next source adds the direct conflict scan. The assertions are unchanged.

5.2 stages/ReservationCoordinator.green.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId)
                    && existing.period().overlaps(requested)) {
                return "Rejected: item unavailable.";
            }
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        LoanPeriod requested = new LoanPeriod(start, endExclusive);
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId) && existing.period().overlaps(requested)) {
                return false;
            }
        }
        return true;
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

Run the same selected test. Observed: one test, zero failures, zero errors and a successful build. At this point the query’s scan duplicates the reservation scan.

5.3 Refactor using the final coordinator

Replace the coordinator with the complete final listing in Section 2.12. It introduces hasConflict and calls it from reservation and enquiry. Then run:

mvn --batch-mode -Dtest=AvailabilityTddTest,LegacyRegressionTest test
mvn --batch-mode verify

The first author run after refactoring passed five JUnit cases: the new overlap test and four legacy bridges. The full completed suite, after supplementary examples and the loan-flow work were added, passed 35 invocations. These are different stages and report counts.

The refactoring preserves reservation validation and policy order. It does not implement enquiry by reserving and undoing. It also does not apply borrower eligibility limits to the read-only query.

5.4 What this sequence establishes

The recorded sequence demonstrates one real red–green–refactor cycle. The red failure was inspected before applying the green implementation, and the subsequent extraction ran the new test plus regression bridge. Source snapshots make it reproducible; they are not a claim about a team’s Git history or independent review.

Only one test passing at green is insufficient coverage for the whole query contract. The final suite adds the complementary cases. Do not describe all supplementary development as test-first merely because the final files contain tests.

6. Targeted wrong-boundary experiment

Make a separate copy of the completed project. In that copy only, change the second comparison in LoanPeriod.overlaps from:

other.start.isBefore(endExclusive)

to the intentionally wrong inclusive comparison:

!other.start.isAfter(endExclusive)

This admits equality at the existing period’s end and incorrectly makes the selected adjacent interval overlap. Run:

mvn --batch-mode -Dtest=AvailabilityTest#adjacentPeriodAndDifferentItemAreAvailable test

The observed result is one failure, zero errors and nonzero build exit. The assertion expects true but receives false. This is a behavioural failure in the adjacency example, not a missing tool or compilation issue.

Restore the original LoanPeriod and rerun the same selected test. The observed restored run passes. The correct running project is kept separate from the experiment throughout.

This is one manual mutation experiment. It supports the claim that the adjacency test detects this particular defect. It does not measure a mutation score or establish that every possible error would be rejected. Never change the correct expected value to accommodate the mutant.

6.1 A wider fault experiment, and a mutant that survives

Sixteen single faults were introduced one at a time into copies of the completed project, and each copy was run through mvn test. Fifteen were rejected by at least one named test: fourteen are in the table below, and the fifteenth, the loan snapshot, follows it. One was not rejected, and it follows as well.

FaultCaught by
isAvailable always returns trueAvailabilityTddTest.overlappingEnquiry…
isAvailable always returns falseAvailabilityTest empty-schedule, adjacency and eligibility examples
Conflict check ignores the item identifierAvailabilityTest adjacency/different-item example, and the Chapter 2 bridge
Inclusive end boundary in LoanPeriod (Section 6)AvailabilityTest.adjacentPeriodAndDifferentItemAreAvailable, and both older bridges
Checkout allowed on the exclusive end dayLoanServiceTest.checkoutOutsideHalfOpenPeriodIsRejected
Checkout allowed before the startthe same test
One reservation checked out twiceaReservationCannotBeCheckedOutTwiceEvenAfterReturn
Item-already-on-loan check removedunreturnedItemBlocksTheNextAdjacentReservationUntilReturned
Return of an inactive loan allowedcheckoutAndReturnUseTheSuppliedClock
Real system clock used instead of the supplied onethree LoanServiceTest methods
Clock zone ignoredclockZoneDeterminesTheCheckoutDate
Return date recorded as the checkout datecheckoutAndReturnUseTheSuppliedClock and the backward-clock test
Loan no longer rejects a return before checkoutbackwardClockCannotRecordReturnBeforeCheckout
Sender called twice on successNotificationMockitoTest, Chapter06Checks and an availability test

Two faults need a closer look.

The loans() snapshot could be modified. Replacing List.copyOf(loans.values()) with a plain ArrayList would pass the other tests, although the chapter’s rule table says loan snapshots are unmodifiable. An assertion in checkoutAndReturnUseTheSuppliedClock rejects that fault.

An enquiry that adds a temporary record and removes it again. This one survives, and it is a good teaching case. With identifiers derived from the list size, a record added and then removed leaves no trace: the records, notices and next identifier all end up exactly as before. In a sequential program, no test can tell this implementation from the correct one. It would matter only if something could observe the list between the add and the remove, such as a concurrent caller. The right response is to reject the design by reading it, as the AI activity does, and to avoid claiming that a test can catch it. The identifier example is still worth keeping, because it protects the promise if identifiers ever come from a separate counter.

7. Acceptance mapping and bounded MVP scope

7.1 Evidence map

Requirement or riskExecutable evidence
AQ-01: empty schedule availableAvailabilityTest.emptyScheduleIsAvailableWithoutAnySideEffect
AQ-02: overlap unavailable, no side effectsAvailabilityTddTest.overlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying
AQ-03: adjacent periods allowedAvailabilityTest.adjacentPeriodAndDifferentItemAreAvailable
AQ-04: different item does not conflictThe same named adjacency/different-item test checks MIC1 separately
AQ-05: equal/reversed interval invalidParameterised AvailabilityTest.invalidPeriodFailsWithoutChangingState
Enquiry does not allocate a reservation IDAvailabilityTest.queryDoesNotAllocateAnIdentifier
Availability differs from eligibilityTwo explicit tests for count limit and a thirty-day enquiry
Existing student/staff boundariesLegacy checks and ten rows in BorrowingPolicyTest
Checkout/return, stable historical snapshot and unmodifiable loans() snapshotLoanServiceTest.checkoutAndReturnUseTheSuppliedClock
Checkout window excludes its endTwo invocations of checkoutOutsideHalfOpenPeriodIsRejected
No second checkout or inactive returnLoan-service duplicate and unknown/inactive scenarios
Active physical loan blocks adjacent booking checkoutunreturnedItemBlocksTheNextAdjacentReservationUntilReturned
Local date depends on clock zoneclockZoneDeterminesTheCheckoutDate
Backward time cannot predate checkoutbackwardClockCannotRecordReturnBeforeCheckout
Existing notification contractChapter 6 bridge and three Mockito examples

A shared test can cover more than one example, but an early failure may prevent later statements from executing. Splitting cases into separate methods can improve independent reporting. The mapping states what is actually present rather than claiming one-to-one identity between acceptance criteria and test methods.

7.2 What the demonstration delivers

The scripted outcome is: inspect availability, reserve one item, observe it becoming unavailable for that interval, check it out during the period and return the active loan. The demo uses a fixed date and a local recording sender. The same code accepts a clock configured by an application root; it does not need to discover today’s date globally.

This is a small in-memory MVP slice. It does not claim to be the whole assessed team project, or a production-ready equipment service. Identity and equipment inputs remain synthetic. Calls are sequential. A service owns its own loan map, and constructing another service does not share that map.

The loan lifecycle and reservation lifecycle are deliberately distinct. Returning a loan does not change the reservation snapshot. The original count rule continues to count every stored reservation, and the original period still blocks reservation enquiry. Before making this realistic across repeated sessions, clarify when a reservation stops contributing to limits and how early returns affect reserved availability.

The query reports reservation-schedule availability. An item with an overdue active loan may appear schedule-available for a later unreserved interval; checkout still rejects it if it is already on loan in this service. A combined physical-availability view is new work, not an undocumented meaning of isAvailable.

8. Model reasoning about test doubles

8.1 Choose a role before choosing a library

The recorder is part of the local teaching application. When a test retains it and inspects calls, it plays a spy-like role. When used as a working simplified stand-in for external delivery, it can be described as a fake. It is not inherently one category in every context merely because its class name contains “Recording”.

The Chapter 6 failing sender is a hand-written stub for an expected error. The Mockito doThrow example supplies the same controlled failure with a library. Neither requires a real notification outage.

A sender passed to a request that must be rejected can act as a dummy if it should never be used. A fail-on-call implementation strengthens the test’s observation of that requirement. If the test examines recorded calls or explicitly verifies their absence, describe that additional observation rather than insist the object has only one possible label.

The policy table uses real policy objects. They are tiny deterministic subjects under test, so mocking them would defeat the purpose. The loan tests use real reservation values and a controlled clock; mocking getters on every value object would add setup without improving the behavioural evidence.

8.2 Why Mockito is limited here

The examples mock a single public output role. They verify meaningful interactions: one attempt for acceptance, no call for rejection and no retry after the documented failure. They do not verify the private sequence of policy-map lookup, list iteration or helper invocation.

The argument captor makes the boundary payload inspectable. Expected R1, B1, CAM1 and the concrete dates come from the scenario. The test does not calculate expected values by running reserve a second time or asking the formatter to generate its own expected output.

The proxy mock-maker configuration only supports interface types. That is sufficient for this lesson and keeps the framework setup explicit. Broader Mockito facilities are not required to demonstrate substitution or interaction checking.

8.3 A preview: doubles for the borrower directory

Section 7.5 of the chapter shows three one-line doubles for the directory contract that Chapter 8 builds. They are not part of this chapter’s Maven build and are not among the listings reconstructed above. They were checked separately: the three lines below were compiled with --release 17 and run against the Chapter 8 types BorrowerDirectory, Borrower, BorrowerCategory and DirectoryException, and they printed STUDENT, false and failure: provider offline.

BorrowerDirectory knownStudent = id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT));
BorrowerDirectory nobody = id -> Optional.empty();
BorrowerDirectory offline = id -> { throw new DirectoryException("provider offline"); };

Classify them as the chapter does: all three are stubs. The first and second return fixed answers. The third throws a predictable failure, so it is a throwing stub, like the failing sender of Chapter 6. A recorder or mock would be needed only if a test wanted to check how often the directory was asked.

9. AI activity: model critique

The example is author-written fictional AI output. A useful review identifies defects and proposes independent evidence.

Fixture claimVerdictIndependent check or correction
Query by reserve-and-removeRejectCalling reserve sends a notice and can be refused by borrower limits, so the unchanged-notices and eligibility examples expose it. Adding and removing a record directly would leave no trace that these sequential tests can see (see Section 6.1); reject it by reading the design, and implement a read-only scan
Inclusive end means adjacency conflictsRejectThe agreed interval is half-open; [20,22) and [22,23) do not overlap
No exception is sufficientRejectAn always-true or always-false method can return normally and be wrong
Generated implementation and tests mutually prove correctnessRejectBoth may share the same mistaken convention; compare with reviewed examples
Coverage proves correctnessRejectExecution does not validate expected values or assert sensitivity to defects
Use real date and wait for tomorrowRejectSupply a fixed or controlled clock and an explicit zone

The wrong-boundary experiment supplies a concrete verification step. An adjacency example with the correct independently chosen expectation fails against the mutant, then passes after restoration. It is unnecessary to ask another AI model whether the mutant is wrong.

For a live AI exercise, retain the actual prompt and evaluated output, the decisions made and the observed tests. If no live service is used, label the example data-based work as such. Do not invent a model transcript or retrofit a fictional TDD history around completed code.

10. Review-question solutions

1. Normal return is insufficient. A valid query must distinguish available from conflicting intervals and preserve state. An implementation that always returns true returns normally but fails AQ-02; one that always returns false fails AQ-01 and AQ-03. Check the result against a concrete example and inspect relevant side effects.

2. Intended red versus missing dependency. The worked red run discovered and executed the named test, reached the assertion and reported false expected but true actual. A dependency-resolution failure never establishes that application behaviour. Resolve setup problems first, then inspect the test failure that corresponds to the missing feature.

3. Recorder and mock roles. When the test examines its received records, the recorder provides observed interaction history and acts as a spy. A Mockito mock can record interactions and verify them too. The classification depends on the test’s purpose and observation; framework-created objects can also provide stubbed results or failures.

4. Instant and zone. Converting one instant into a date can give different dates in different zones. The test must state both when the rule concerns a local date. A fixed instant interpreted through the machine’s default zone could still produce an environment-dependent result. The supplied Baghdad example fixes the intended interpretation.

5. Shared mistaken helper. The test derives its expected answer from the same faulty rule, so the two computations agree without independent validation. Replace it with a booked [2026-10-20, 2026-10-22) interval and an enquiry [2026-10-22, 2026-10-23), explicitly expecting true under the agreed exclusive end. Run the mutant to show that this expectation rejects the inclusive comparison.

6. Static shared service. Earlier tests may have inserted or returned loans that alter later tests’ starting state. Use a fresh coordinator, sender and loan service for each independent scenario. A single multi-step lifecycle test may intentionally share its own service while advancing a controlled clock; that state should not leak into unrelated test methods. Do not rely on execution order to repair accidental coupling.

11. Practical activity, retrospective and transfer solutions

11.1 Suggested evidence packet

DocumentUseful content
Scope noteSelected query and loan rules; synthetic inputs; lifecycle limitations
TDD recordActual red message, same test at green, refactoring diff and regression result
Acceptance mappingNamed tests tied to concrete examples
Double rationaleWhich collaborator is replaced, why, and what observation it enables
Mutation recordIsolated faulty comparison, selected failure and restored pass
Build resultExecuted revision, tool versions and actual report
DemonstrationReproducible steps from empty state through reserve/checkout/return
Review and retrospectiveActual observations, corrective action and a next-iteration experiment

This is preparation guidance, not an invented marking scheme. A copied sample table is not evidence that a team ran the tests or conducted a review. Hosted CI results and peer feedback must be obtained and recorded when they actually occur.

For feedback, ask whether a test can reject a plausible wrong result. Ask what data or clock state makes it repeatable. Ask whether its expected values come from a requirement, and whether a harmless private refactoring would break it. Those questions are more useful than rewarding sheer assertion count.

11.2 Retrospective example, explicitly hypothetical

Hypothetical observation: the team spent a review session diagnosing different dates on two laptops because the first loan test used the default zone. Proposed action: require an explicit clock and zone in future date-sensitive examples, then review the next set of tests for hidden real-time calls. Evidence to collect later: whether those tests run consistently on both environments.

This is a teaching example, not a report that the author’s or learner’s team experienced that event. Replace it with an actual observation in assessed work. A retrospective should record something learned and a testable improvement, not manufacture a success narrative.

11.3 Optional lifecycle extension

Before changing active reservation counts, decide what completion means: checkout, return, expiry or an explicit completion operation. Decide whether an early return frees the booked interval and whether a reservation may be used again. Then identify effects on reserve, isAvailable, borrower limits, snapshots and old regression expectations.

The current implementation deliberately does not answer those questions by deleting records. Deletion would also interact with the size-based identifier scheme. A lifecycle change therefore requires a reviewed requirement and a careful migration, rather than editing one count expression under the name of testing.

11.4 Room-booking transfer

A possible set of reviewed examples is: an empty room schedule is available; an overlapping confirmed booking makes an enquiry unavailable without adding a booking; an enquiry beginning at the previous exclusive end is available if no turnaround rule applies.

For check-in, supply a fixed clock at the stated booking start and expect acceptance. At the exclusive end, expect rejection if that is the agreed policy. Use a controlled notification recorder to observe one check-in notice without a real messaging service. Keep enquiry and check-in as different outcomes.

Clarify cleaning intervals, early arrival, late check-in, no-show expiry and who can authorise entry. Equipment date rules do not automatically settle hour-based room rules. Explicitly represent the booking’s time zone and choose an appropriate time type rather than copying LocalDate merely because the earlier project used whole days.

The transfer demonstrates the reasoning pattern (reviewed examples, independent expectations, controlled time and relevant state), not a requirement to copy the exact Java classes.

11.5 Midterm practice set: supplied files and solutions

The practice set in Section 7.12 of the student chapter mirrors the four components the module descriptor lists for the midterm: UML modelling, smell and SOLID diagnosis, refactoring with an interface and dependency injection, and unit tests with a test double. It is separate from the running project. Do not add these files to after.

Supplied files for Tasks 2–4. Save both in an empty directory. MailClient stands in for a real network client and always fails, so the original class cannot run its main path here.

// MailClient.java
/** Supplied for the practice exercise. A real client would open a network connection. */
public class MailClient {
    private final String host;
    private final int port;

    public MailClient(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void send(String to, String subject, String body) {
        throw new IllegalStateException("No mail server is reachable here: " + host + ":" + port);
    }
}
// OverdueReminder.java  (the class students are given)
import java.time.LocalDate;

public class OverdueReminder {
    public boolean remind(String borrowerEmail, LocalDate dueDate, String itemName) {
        if (LocalDate.now().isAfter(dueDate)) {
            MailClient client = new MailClient("smtp.campus.example", 25);
            client.send(borrowerEmail, "Overdue: " + itemName, "Please return " + itemName + ".");
            System.out.println("Reminder sent to " + borrowerEmail);
            return true;
        }
        return false;
    }
}

Task 1: UML. One model that answers the scenario:

classDiagram
    direction LR
    class Borrower {
        -String name
    }
    class Laptop {
        -String assetTag
        -String model
    }
    class Loan {
        -LocalDate startedOn
        -LocalDate dueOn
        +notify(NoticeChannel channel) void
    }
    class Renewal {
        -LocalDate newDueOn
    }
    class NoticeChannel {
        <<interface>>
        +deliver(String message) void
    }
    class EmailChannel
    Borrower "1" -- "0..*" Loan : borrows
    Laptop "1" -- "0..*" Loan : is lent in
    Loan "1" *-- "0..*" Renewal : has
    Loan ..> NoticeChannel : sends notices through
    NoticeChannel <|.. EmailChannel : implements

What a strong answer shows: a borrower and a laptop are each linked to any number of loans over time, so both ends use 0..* at the loan side; a renewal cannot exist without its loan, so composition; the channel is an interface because the library expects other channels, and Loan depends on the interface and not on EmailChannel. The rule “a borrower has at most one active loan, and a laptop is on at most one active loan” cannot be expressed by multiplicity, because the multiplicity counts all loans over time. It should be written as a note or an invariant beside the diagram. Accept Loan as an association class or a plain class, and accept a Notifier class between Loan and the channel if the dependency and realisation are still correct. Penalise inheritance used for “has”, a solid line for the realisation, and a composition diamond between Borrower and Loan.

Task 2: diagnosis. The class mixes three jobs: deciding whether the item is overdue, building and sending the message, and reporting to the console (single responsibility). It reads the system clock directly (LocalDate.now()), so its result depends on the date the test runs. It constructs MailClient with a hard-coded host and port inside the method, so it depends on a concrete provider and cannot be given another one (dependency inversion), and it cannot be tested without a mail server. The boolean result does not distinguish “not overdue” from “sent”, though the second case throws if sending fails. Strong answers connect each defect to a concrete consequence, such as “the overdue path cannot run in a test; running it here throws IllegalStateException from MailClient”. They do not list principle names alone.

Task 3: refactoring. One acceptable solution introduces a role for sending, injects it and a clock, and keeps the console message in the mail adapter so behaviour is preserved:

// ReminderSender.java
public interface ReminderSender {
    void send(String to, String subject, String body);
}
// MailReminderSender.java
import java.util.Objects;

/** Adapts the supplied MailClient to the application's ReminderSender role. */
public final class MailReminderSender implements ReminderSender {
    private final MailClient client;

    public MailReminderSender(MailClient client) {
        this.client = Objects.requireNonNull(client, "client");
    }

    @Override
    public void send(String to, String subject, String body) {
        client.send(to, subject, body);
        System.out.println("Reminder sent to " + to);
    }
}
// OverdueReminder.java  (refactored)
import java.time.Clock;
import java.time.LocalDate;
import java.util.Objects;

public final class OverdueReminder {
    private final Clock clock;
    private final ReminderSender sender;

    public OverdueReminder(Clock clock, ReminderSender sender) {
        this.clock = Objects.requireNonNull(clock, "clock");
        this.sender = Objects.requireNonNull(sender, "sender");
    }

    /** Sends one reminder when the item is past its due date; returns whether one was sent. */
    public boolean remind(String borrowerEmail, LocalDate dueDate, String itemName) {
        if (!LocalDate.now(clock).isAfter(dueDate)) {
            return false;
        }
        sender.send(borrowerEmail, "Overdue: " + itemName, "Please return " + itemName + ".");
        return true;
    }
}

The composition root, not shown, would call new OverdueReminder(Clock.systemDefaultZone(), new MailReminderSender(new MailClient("smtp.campus.example", 25))). The constructor is a deliberate API change; the observable behaviour of remind is preserved. Moving the console message into the adapter keeps the output without making the decision class responsible for it. Do not give credit for adding an interface to MailClient itself, which the student does not own, or for a factory nobody needs.

Task 4: tests with a double.

// OverdueReminderTest.java
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class OverdueReminderTest {
    private static final LocalDate DUE = LocalDate.of(2026, 10, 20);
    private final SpySender sender = new SpySender();

    private OverdueReminder reminderOn(String instant) {
        return new OverdueReminder(Clock.fixed(Instant.parse(instant), ZoneOffset.UTC), sender);
    }

    @Test void noReminderOnTheDueDateItself() {
        assertFalse(reminderOn("2026-10-20T23:00:00Z").remind("[email protected]", DUE, "CAM1"));
        assertTrue(sender.calls.isEmpty());
    }

    @Test void noReminderBeforeTheDueDate() {
        assertFalse(reminderOn("2026-10-19T12:00:00Z").remind("[email protected]", DUE, "CAM1"));
        assertTrue(sender.calls.isEmpty());
    }

    @Test void exactlyOneReminderTheDayAfterTheDueDate() {
        assertTrue(reminderOn("2026-10-21T00:00:00Z").remind("[email protected]", DUE, "CAM1"));
        assertEquals(List.of("[email protected]|Overdue: CAM1|Please return CAM1."), sender.calls);
    }

    /** A hand-written spy: it records what it was asked to send. */
    private static final class SpySender implements ReminderSender {
        private final List<String> calls = new ArrayList<>();
        @Override public void send(String to, String subject, String body) {
            calls.add(to + "|" + subject + "|" + body);
        }
    }
}

The double is a spy: it records calls so the test can inspect them. The clock is fixed, so no test depends on today’s date. The first two tests state the boundary (the due date itself is not overdue) and a clearly earlier date; the third states the content of the one expected message. Expected values come from the scenario, not from calling the class. Mockito is not needed, and answers that use it are acceptable if the interaction checked is the public send call and the clock is still controlled. Check the module leader’s exam instructions before relying on Mockito in a timed lab.

To run it, copy MailClient, ReminderSender, MailReminderSender and the refactored OverdueReminder into src/main/java, and OverdueReminderTest into src/test/java, of a scratch Maven project that uses this chapter’s pom.xml. The original, unrefactored OverdueReminder must not be in the same source directory.

11.6 Midterm practice: the combined trace prompt (Task 5)

For a rejected reservation, trace argument validation, borrower count, policy decision and conflict checking in order. A rejection occurs before storage and notification. A useful test checks the rejection outcome, unchanged records and no sender interaction. The injected sender lets the test observe that boundary without changing the coordinator’s request logic.

Connect this trace to encapsulation and invariants from Chapter 2, acceptance examples from Chapter 3, evidence and review from Chapter 4, contracts from Chapter 5 and dependency assembly from Chapter 6. Chapter 7 adds a disciplined way to make those claims executable. The supplied midterm scope remains Chapters 2–7; Chapter 8 patterns are not added by this practice prompt.

12. What the build establishes

The 35 JUnit invocations consist of one TDD test, eight additional availability invocations, ten policy table rows, nine loan invocations, three Mockito tests and four legacy wrappers. The count is an execution inventory, not a claim of exhaustive correctness or a quality score.

The Maven report establishes local results for the supplied source and configuration. It does not establish external provider behaviour, persistent storage, concurrent safety, authoritative identity or the completeness of a team’s milestone submission.

References

Sources checked 12 October 2026. The application policies, examples and solutions are original teaching material.

  1. JUnit 5.13.4 User Guide.
  2. Apache Maven, Surefire and JUnit Platform, Maven 3.9.11 release notes and Compiler Plugin.
  3. Fowler, M., Test Driven Development.
  4. Oracle, Java SE 17 Clock.
  5. Fowler, M. (2006), Test Double.
  6. Mockito, 5.20.0 documentation entry and basic API usage documentation. The latter page identifies an older release; the executable version here is explicitly pinned and locally verified.
  7. Oracle, Java 17 Record Classes.

Chapter 8 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 8 Companion: Code, Explanations and Solutions.

Creating and Connecting Objects
OO Development and Agile Practices

1. Scope, starting point and execution

This file accompanies Chapter 8, Creating and Connecting Objects. It contains a complete reconstructable Maven version, explanations of the added code, focused tests, model answers and instructor guidance.

Starting point: Chapter 7’s completed project. Its application classes and tests remain unchanged. Running addition: a directory-backed reservation entry point that translates synthetic provider records before calling the existing coordinator. Separate examples: the patterns package demonstrates Factory Method, Abstract Factory, a fluent Builder and ordered Decorators. Those examples are compiled and tested but are not connected to the lending application.

1.1 Reconstruct the complete version

Create a directory named after. Copy the contents of each labelled file listing to the indicated path, preserving case and creating intermediate directories. Sections 3–5 contain every application, test, build and documentation file. Do not copy Markdown fence markers. The README listing uses four backticks because its content includes ordinary fenced commands.

Alternatively, copy Chapter 7’s completed after directory and add the eight new main-source files and two new test files listed in Section 2. Replace pom.xml and README.md with this version’s versions. Existing sources, test resources and the check script are unchanged. The Maven project version becomes 0.8.0; dependency versions do not change.

ToolConfiguration
JavaJava 17 language/API target
Maven3.9.11 used for verification
JUnit Jupiter5.13.4
Mockito5.20.0; retained Chapter 7 interface-proxy configuration
Compiler Plugin3.14.1; release 17
Surefire Plugin3.5.4; fail if no tests are discovered

With a normal Java 17 JDK and Maven installed, run from after:

mvn --batch-mode verify
java -cp target/classes Chapter08Demo

Run the two new test classes alone when examining the examples:

mvn --batch-mode -Dtest=DirectoryAdapterTest,PatternExamplesTest test

The isolated pattern class has no main method; its JUnit tests are the executable demonstrations. The existing Bash command bash scripts/check.sh still runs the complete Maven gate. The first build requires dependency downloads; later offline execution depends on the required artifacts being cached.

The Chapter 8 demo’s exact output is:

Confirmed: R1 for B2 on CAM1 [2026-10-20, 2026-10-30).
Rejected: borrower limit or duration.
Rejected: borrower not found or inactive.
Reservations: 1
Notices: 1

The staff example B2 is allowed ten days under the inherited staff rule. Student B1’s ten-day request is rejected. The missing identifier also creates no record. Only the accepted request creates a notice. These outcomes use a map-backed provider example, not real directory access.

2. Read the design before the listings

2.1 What changed

New fileResponsibilityPart of the running project?
Borrower.javaImmutable application identifier/category valueYes
BorrowerDirectory.javaApplication-owned presence/absence lookup contractYes
DirectoryException.javaIdentifiable directory failure or malformed dataYes
LegacyDirectoryClient.javaIllustrative provider contract and raw recordYes, as a synthetic boundary model
LegacyDirectoryAdapter.javaTranslate provider codes, null and declared failureYes
DirectoryReservationService.javaLook up borrower, then invoke existing reservation behaviourYes
Chapter08Demo.javaReproducible map-backed integration demonstrationYes
patterns/PatternExamples.javaFour separate pattern exercisesNo application wiring
DirectoryAdapterTest.javaTranslation and application-boundary testsTest source
patterns/PatternExamplesTest.javaConstruction, immutability and decorator-order testsTest source

The main application’s earlier classes stay in the unnamed package to preserve the existing version. The isolated examples use the named patterns package and depend only on Java APIs. They do not import the unnamed-package application classes. Package and layer restructuring is deferred to Chapter 10.

2.2 Adapter contract and error precedence

LegacyDirectoryAdapter accepts a nonblank identifier. It invokes the provider once and translates only its declared Failure. A null result means absence. A returned identifier must match exactly. Kind S means student and T means staff; unknown or null kind is malformed. Status Y means active and N means inactive; other values are malformed.

Identifier and kind checks precede inactivity. Thus Person("B1", "X", "N") is an error, not an empty result. Codes are case-sensitive and no whitespace normalisation occurs. This policy is explicit rather than a presumed property of all university directories.

The service validates blank identifiers and a null start date before lookup. Duration is checked by the existing coordinator after successful lookup. Optional.empty() means a completed lookup found no usable borrower. A DirectoryException preserves the distinction between that result and an unavailable or malformed provider response. No category is guessed.

The application interface expects a non-null Optional. A custom implementation returning null violates that contract and will fail rather than silently allow a reservation. The service also checks the returned domain borrower’s identifier, so a non-provider implementation cannot accidentally substitute another person unnoticed.

2.3 Entry point, not authentication

The new reserve method accepts borrower ID, item ID, start date and days, but no category. It obtains the category from the directory. The earlier public coordinator methods still exist for trusted internal use and regression examples, so this is not a security enforcement boundary. A real application needs authenticated identity and controlled access to the exposed entry path.

Directory errors happen before coordinator mutation. Once a valid borrower reaches the coordinator, all earlier policy, conflict and notification behaviour applies. There is no directory caching, HTTP client, retry, persistence or cancellation lifecycle in this increment.

2.4 How to read the isolated patterns

ExportJob.run invokes the overridden createExporter hook: this is the Factory Method example. simpleExporter is an ordinary selecting function with the same possible output formats. Output similarity does not make the structures identical.

ReceiptFamily creates two product roles, Heading and Body. Each concrete family supplies matching plain or HTML renderers. Products are represented by small lambdas; introducing named classes is an optional readability change, not a prerequisite for either pattern.

ReportRequest.Builder stages configuration and publishes an immutable product. It is the common fluent variant, not a full multiple-representation Director arrangement. Defaults and validation are explicit; the request copies columns at construction. The column labels are sample configuration, not an implemented reporting engine.

AuditedSender and RetryOnceSender share the Sender interface. They preserve the method shape but change observations and attempt behaviour. Their interface is deliberately separate from the running NotificationSender. Installing retry in the latter would change its one-attempt contract.

The HTML helpers escape ampersand and angle brackets for text-node content. They do not generate attributes, validate arbitrary markup, or claim to be a general HTML sanitiser. Audit events are a mutable in-memory test list, not a durable audit service.

3. Complete application and isolated example sources

All listings in this section belong under after/src/main/java. Inherited files are included so the version is reproducible without reconstructing earlier chapters first.

3.1 after/src/main/java/Borrower.java

import java.util.Objects;

public record Borrower(String id, BorrowerCategory category) {
    public Borrower {
        if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
        Objects.requireNonNull(category, "category");
    }
}

3.2 after/src/main/java/BorrowerCategory.java

public enum BorrowerCategory {
    STUDENT, STAFF
}

3.3 after/src/main/java/BorrowerDirectory.java

import java.util.Optional;

/** Empty means absent or inactive, never a provider outage. */
@FunctionalInterface
public interface BorrowerDirectory {
    Optional<Borrower> find(String borrowerId);
}

3.4 after/src/main/java/BorrowingPolicy.java

/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

3.5 after/src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.6 after/src/main/java/Chapter05Demo.java

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.7 after/src/main/java/Chapter07Demo.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

public final class Chapter07Demo {
    public static void main(String[] args) {
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator reservations = ReservationApplication.create(inbox);
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println("Available before reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        System.out.println(reservations.reserve("B1", "CAM1", day, 2));
        System.out.println("Available after reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        LoanService loans = new LoanService(reservations,
                Clock.fixed(Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        System.out.println("Loan active after checkout: " + loans.checkOut("R1").active());
        System.out.println("Loan active after return: " + loans.returnLoan("R1").active());
        System.out.println("Accepted-reservation notices: " + inbox.received().size());
    }
}

3.8 after/src/main/java/Chapter08Demo.java

import java.time.LocalDate;
import java.util.Map;

public final class Chapter08Demo {
    public static void main(String[] args) {
        var rows = Map.of(
                "B1", new LegacyDirectoryClient.Person("B1", "S", "Y"),
                "B2", new LegacyDirectoryClient.Person("B2", "T", "Y"));
        BorrowerDirectory directory = new LegacyDirectoryAdapter(rows::get);
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var service = new DirectoryReservationService(directory, coordinator);
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(service.reserve("B2", "CAM1", start, 10));
        System.out.println(service.reserve("B1", "MIC1", start, 10));
        System.out.println(service.reserve("UNKNOWN", "MIC1", start, 2));
        System.out.println("Reservations: " + coordinator.reservations().size());
        System.out.println("Notices: " + notices.received().size());
    }
}

3.9 after/src/main/java/ConfirmationFormatter.java

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

3.10 after/src/main/java/ConsoleNotificationSender.java

import java.io.PrintStream;
import java.util.Objects;

public final class ConsoleNotificationSender implements NotificationSender {
    private final PrintStream output;
    private final ConfirmationFormatter formatter;

    public ConsoleNotificationSender(PrintStream output, ConfirmationFormatter formatter) {
        this.output = Objects.requireNonNull(output, "output");
        this.formatter = Objects.requireNonNull(formatter, "formatter");
    }

    @Override
    public void send(Reservation reservation) {
        output.println("Notice: " + formatter.format(reservation));
        if (output.checkError()) {
            throw new NotificationException("Console output failed.");
        }
    }
}

3.11 after/src/main/java/DirectoryException.java

public final class DirectoryException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public DirectoryException(String message) { super(message); }
    public DirectoryException(String message, Throwable cause) { super(message, cause); }
}

3.12 after/src/main/java/DirectoryReservationService.java

import java.time.LocalDate;
import java.util.Objects;

/** Directory-backed entry point; this does not authenticate the caller. */
public final class DirectoryReservationService {
    private final BorrowerDirectory directory;
    private final ReservationCoordinator coordinator;

    public DirectoryReservationService(BorrowerDirectory directory,
                                       ReservationCoordinator coordinator) {
        this.directory = Objects.requireNonNull(directory, "directory");
        this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
    }

    public String reserve(String borrowerId, String itemId, LocalDate start, int days) {
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
        var found = directory.find(borrowerId);
        if (found.isEmpty()) return "Rejected: borrower not found or inactive.";
        Borrower borrower = found.get();
        if (!borrowerId.equals(borrower.id())) {
            throw new DirectoryException("Directory returned a different borrower");
        }
        return coordinator.reserve(borrower.id(), itemId, start, days, borrower.category());
    }
}

3.13 after/src/main/java/LegacyDirectoryAdapter.java

import java.util.Objects;
import java.util.Optional;

public final class LegacyDirectoryAdapter implements BorrowerDirectory {
    private final LegacyDirectoryClient client;

    public LegacyDirectoryAdapter(LegacyDirectoryClient client) {
        this.client = Objects.requireNonNull(client, "client");
    }

    @Override
    public Optional<Borrower> find(String borrowerId) {
        if (borrowerId == null || borrowerId.isBlank()) {
            throw new IllegalArgumentException("Borrower id required");
        }
        LegacyDirectoryClient.Person row;
        try {
            row = client.lookup(borrowerId);
        } catch (LegacyDirectoryClient.Failure failure) {
            throw new DirectoryException("Borrower directory unavailable", failure);
        }
        if (row == null) return Optional.empty();
        if (!borrowerId.equals(row.key())) throw new DirectoryException("Mismatched borrower id");
        BorrowerCategory category;
        if ("S".equals(row.kind())) category = BorrowerCategory.STUDENT;
        else if ("T".equals(row.kind())) category = BorrowerCategory.STAFF;
        else throw new DirectoryException("Unsupported borrower category");
        if ("N".equals(row.status())) return Optional.empty();
        if (!"Y".equals(row.status())) throw new DirectoryException("Unsupported borrower status");
        return Optional.of(new Borrower(row.key(), category));
    }
}

3.14 after/src/main/java/LegacyDirectoryClient.java

/** Illustrative provider contract, not an actual university API or network client. */
@FunctionalInterface
public interface LegacyDirectoryClient {
    // null means no row; codes are exact and case-sensitive.
    Person lookup(String key);

    record Person(String key, String kind, String status) { }

    final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public Failure(String message) { super(message); }
    }
}

3.15 after/src/main/java/Loan.java

import java.time.LocalDate;
import java.util.Objects;

/** Immutable loan state. A null returnedOn means this loan is active. */
public record Loan(String reservationId, String borrowerId, String itemId,
                   LocalDate borrowedOn, LocalDate returnedOn) {
    public Loan {
        if (reservationId == null || reservationId.isBlank()
                || borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Loan identifiers must not be blank.");
        }
        Objects.requireNonNull(borrowedOn, "borrowedOn");
        if (returnedOn != null && returnedOn.isBefore(borrowedOn)) {
            throw new IllegalArgumentException("Return cannot precede checkout.");
        }
    }
    public boolean active() { return returnedOn == null; }
}

3.16 after/src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

3.17 after/src/main/java/LoanService.java

import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** One service per coordinator/session; sequential, in-memory teaching flow. */
public final class LoanService {
    private final ReservationCoordinator reservations;
    private final Clock clock;
    private final Map<String, Loan> loans = new LinkedHashMap<>();

    public LoanService(ReservationCoordinator reservations, Clock clock) {
        this.reservations = Objects.requireNonNull(reservations, "reservations");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public Loan checkOut(String reservationId) {
        Reservation reservation = reservations.reservations().stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown reservation."));
        if (loans.containsKey(reservationId)) {
            throw new IllegalStateException("Reservation already used for a loan.");
        }
        LocalDate today = LocalDate.now(clock);
        if (today.isBefore(reservation.period().start())
                || !today.isBefore(reservation.period().endExclusive())) {
            throw new IllegalStateException("Checkout must be within the reservation period.");
        }
        boolean itemAlreadyOut = loans.values().stream()
                .anyMatch(loan -> loan.active() && loan.itemId().equals(reservation.itemId()));
        if (itemAlreadyOut) throw new IllegalStateException("Item is already on loan.");
        Loan loan = new Loan(reservation.id(), reservation.borrowerId(),
                reservation.itemId(), today, null);
        loans.put(reservationId, loan);
        return loan;
    }

    public Loan returnLoan(String reservationId) {
        Loan current = loans.get(reservationId);
        if (current == null || !current.active()) {
            throw new IllegalStateException("No active loan for this reservation.");
        }
        Loan returned = new Loan(current.reservationId(), current.borrowerId(),
                current.itemId(), current.borrowedOn(), LocalDate.now(clock));
        loans.put(reservationId, returned);
        return returned;
    }

    public List<Loan> loans() { return List.copyOf(loans.values()); }
}

3.18 after/src/main/java/NotificationException.java

public final class NotificationException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public NotificationException(String message) { super(message); }
}

3.19 after/src/main/java/NotificationSender.java

/**
 * Accept one non-null confirmed reservation for this implementation's local output.
 * Do not modify the reservation or coordinator. Report expected output failure with
 * NotificationException. Normal return is not a promise of email delivery.
 */
@FunctionalInterface
public interface NotificationSender {
    void send(Reservation reservation);
}

3.20 after/src/main/java/RecordingNotificationSender.java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class RecordingNotificationSender implements NotificationSender {
    private final List<Reservation> received = new ArrayList<>();

    @Override
    public void send(Reservation reservation) {
        received.add(Objects.requireNonNull(reservation, "reservation"));
    }

    public List<Reservation> received() { return List.copyOf(received); }
}

3.21 after/src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

3.22 after/src/main/java/ReservationApplication.java

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                new ConfirmationFormatter(), sender);
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

3.23 after/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        if (hasConflict(itemId, requested)) {
            return "Rejected: item unavailable.";
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        LoanPeriod requested = new LoanPeriod(start, endExclusive);
        return !hasConflict(itemId, requested);
    }

    private boolean hasConflict(String itemId, LoanPeriod requested) {
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId) && existing.period().overlaps(requested)) {
                return true;
            }
        }
        return false;
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

3.24 after/src/main/java/ReservationRules.java

public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

3.25 after/src/main/java/StaffReservationRules.java

/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

3.26 after/src/main/java/patterns/PatternExamples.java

package patterns;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Isolated teaching examples; none is wired into the lending application. */
public final class PatternExamples {
    private PatternExamples() { }

    // Factory Method: inherited workflow invokes an overridable creation hook.
    public interface Exporter { String export(String value); }
    public abstract static class ExportJob {
        protected abstract Exporter createExporter();
        public final String run(String value) {
            Objects.requireNonNull(value, "value");
            return createExporter().export(value);
        }
    }
    public static final class PlainJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "TEXT: " + value; }
    }
    public static final class HtmlJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "<p>" + escape(value) + "</p>"; }
    }
    // Simple factory: useful, but no overridden creation hook.
    public static Exporter simpleExporter(boolean html) {
        return html ? value -> "<p>" + escape(value) + "</p>" : value -> "TEXT: " + value;
    }

    // Abstract Factory: two product roles, supplied as a related family.
    public interface Heading { String render(String text); }
    public interface Body { String render(String text); }
    public interface ReceiptFamily {
        Heading heading();
        Body body();
    }
    public static final class PlainFamily implements ReceiptFamily {
        public Heading heading() { return text -> text + "\n"; }
        public Body body() { return text -> text + "\n"; }
    }
    public static final class HtmlFamily implements ReceiptFamily {
        public Heading heading() { return text -> "<h1>" + escape(text) + "</h1>"; }
        public Body body() { return text -> "<p>" + escape(text) + "</p>"; }
    }
    public static String receipt(ReceiptFamily family, String title, String detail) {
        Objects.requireNonNull(family, "family");
        return family.heading().render(title) + family.body().render(detail);
    }
    private static String escape(String text) {
        return Objects.requireNonNull(text, "text").replace("&", "&amp;")
                .replace("<", "&lt;").replace(">", "&gt;");
    }

    // Fluent builder variant: staged configuration, one immutable product.
    public static final class ReportRequest {
        private final String borrowerId;
        private final LocalDate from;
        private final LocalDate toExclusive;
        private final boolean includeReturned;
        private final List<String> columns;
        private ReportRequest(Builder builder) {
            if ((builder.from == null) != (builder.toExclusive == null)) {
                throw new IllegalArgumentException("Both dates or neither are required");
            }
            if (builder.from != null && !builder.from.isBefore(builder.toExclusive)) {
                throw new IllegalArgumentException("Positive interval required");
            }
            borrowerId = builder.borrowerId;
            from = builder.from;
            toExclusive = builder.toExclusive;
            includeReturned = builder.includeReturned;
            columns = List.copyOf(builder.columns);
        }
        public String borrowerId() { return borrowerId; }
        public LocalDate from() { return from; }
        public LocalDate toExclusive() { return toExclusive; }
        public boolean includeReturned() { return includeReturned; }
        public List<String> columns() { return columns; }
        public static Builder forBorrower(String id) { return new Builder(id); }
        public static final class Builder {
            private final String borrowerId;
            private LocalDate from;
            private LocalDate toExclusive;
            private boolean includeReturned;
            private final List<String> columns = new ArrayList<>();
            private Builder(String id) {
                if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
                borrowerId = id;
            }
            public Builder during(LocalDate from, LocalDate toExclusive) {
                Objects.requireNonNull(from, "from");
                Objects.requireNonNull(toExclusive, "toExclusive");
                this.from = from;
                this.toExclusive = toExclusive;
                return this;
            }
            public Builder includeReturned(boolean value) { includeReturned = value; return this; }
            public Builder column(String value) {
                if (value == null || value.isBlank()) throw new IllegalArgumentException("Column required");
                columns.add(value);
                return this;
            }
            public ReportRequest build() { return new ReportRequest(this); }
        }
    }

    // Decorators: same interface, explicit order and expected failure type.
    public interface Sender { void send(String message); }
    public static final class DeliveryFailure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public DeliveryFailure(String message) { super(message); }
    }
    public static final class AuditedSender implements Sender {
        private final Sender delegate;
        private final List<String> events;
        public AuditedSender(Sender delegate, List<String> events) {
            this.delegate = Objects.requireNonNull(delegate, "delegate");
            this.events = Objects.requireNonNull(events, "events");
        }
        public void send(String message) {
            events.add("attempt");
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                events.add("failed");
                throw failure;
            }
            events.add("sent");
        }
    }
    public static final class RetryOnceSender implements Sender {
        private final Sender delegate;
        public RetryOnceSender(Sender delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); }
        public void send(String message) {
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                delegate.send(message);
            }
        }
    }
}

4. Complete test sources

The inherited tests remain unchanged. The two new test classes check the directory boundary and the isolated patterns.

4.1 after/src/test/java/AvailabilityTddTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTddTest {
    @Test
    void overlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying() {
        // Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator coordinator = ReservationApplication.create(inbox);
        LocalDate start = LocalDate.of(2026, 10, 20);
        coordinator.reserve("B1", "CAM1", start, 2);
        List<Reservation> recordsBefore = coordinator.reservations();
        List<Reservation> noticesBefore = inbox.received();

        // Act: inspect the overlapping interval without requesting a reservation.
        boolean available = coordinator.isAvailable("CAM1", start.plusDays(1), start.plusDays(3));

        // Assert: the answer and both kinds of state matter.
        assertAll(
                () -> assertFalse(available, "An overlapping item must be unavailable"),
                () -> assertEquals(recordsBefore, coordinator.reservations()),
                () -> assertEquals(noticesBefore, inbox.received()));
    }
}

4.2 after/src/test/java/AvailabilityTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);
    private final RecordingNotificationSender inbox = new RecordingNotificationSender();
    private final ReservationCoordinator c = ReservationApplication.create(inbox);

    @Test void emptyScheduleIsAvailableWithoutAnySideEffect() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(2)));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void adjacentPeriodAndDifferentItemAreAvailable() {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertTrue(c.isAvailable("CAM1", day.plusDays(2), day.plusDays(3)));
        assertTrue(c.isAvailable("MIC1", day, day.plusDays(2)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @ParameterizedTest @ValueSource(ints = {0, -1})
    void invalidPeriodFailsWithoutChangingState(int endOffset) {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertThrows(IllegalArgumentException.class,
                () -> c.isAvailable("CAM1", day, day.plusDays(endOffset)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @Test void malformedIdentifiersAndNullDatesAreRejected() {
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(" ", day, day.plusDays(1)));
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(null, day, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", null, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", day, null));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void queryDoesNotAllocateAnIdentifier() {
        c.isAvailable("CAM1", day, day.plusDays(1));
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-21).",
                c.reserve("B1", "CAM1", day, 1));
    }

    @Test void availabilityDoesNotMeanBorrowerEligibility() {
        for (String item : List.of("CAM1", "MIC1", "TRI1")) c.reserve("B1", item, day, 1);
        assertTrue(c.isAvailable("CAM2", day, day.plusDays(1)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM2", day, 1));
        assertEquals(3, inbox.received().size());
    }

    @Test void queryCanSpanMoreDaysThanAStudentMayReserve() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(30)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 30));
    }
}

4.3 after/src/test/java/BorrowingPolicyTest.java

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BorrowingPolicyTest {
    @ParameterizedTest(name = "{0}: count={1}, days={2} -> {3}")
    @CsvSource({
        "STUDENT, 0, 1, true", "STUDENT, 2, 7, true",
        "STUDENT, 3, 1, false", "STUDENT, 0, 8, false",
        "STAFF, 4, 14, true", "STAFF, 5, 1, false",
        "STAFF, 0, 15, false", "STUDENT, -1, 1, false",
        "STAFF, 0, 0, false", "STAFF, 0, -1, false"
    })
    void documentedBoundaries(BorrowerCategory category, int count, int days, boolean expected) {
        BorrowingPolicy policy = category == BorrowerCategory.STUDENT
                ? new ReservationRules() : new StaffReservationRules();
        assertEquals(expected, policy.mayRequest(count, days));
    }
}

4.4 after/src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = ReservationApplication.create(new RecordingNotificationSender());
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.5 after/src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = ReservationApplication.create(new RecordingNotificationSender());
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

4.6 after/src/test/java/Chapter05Checks.java

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = ReservationApplication.create(new RecordingNotificationSender());
        ReservationCoordinator explicitStudent = ReservationApplication.create(new RecordingNotificationSender());
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.7 after/src/test/java/Chapter06Checks.java

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public final class Chapter06Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator c = ReservationApplication.create(inbox);
        String first = c.reserve("B1", "CAM1", day, 2);
        check(first.equals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "accepted return unchanged");
        check(inbox.received().size() == 1, "one notification per accepted request");
        check(inbox.received().get(0) == c.reservations().get(0), "accepted record supplied");
        List<Reservation> oldInbox = inbox.received();
        expect(UnsupportedOperationException.class, oldInbox::clear, "inbox snapshot unmodifiable");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "conflict result preserved");
        check(inbox.received().size() == 1, "conflict sends nothing");
        c.reserve("B1", "MIC1", day, 8);
        check(inbox.received().size() == 1, "duration rejection sends nothing");
        expect(IllegalArgumentException.class, () -> c.reserve("", "CAM1", day, 1), "bad ID");
        check(inbox.received().size() == 1, "invalid argument sends nothing");
        c.reserve("B2", "CAM1", day.plusDays(2), 1);
        check(inbox.received().size() == 2, "adjacent acceptance sends once");
        check(oldInbox.size() == 1, "earlier inbox snapshot stable");

        FailingSender failure = new FailingSender();
        ReservationCoordinator failed = ReservationApplication.create(failure);
        String failureResult = failed.reserve("B1", "CAM1", day, 2);
        check(failureResult.equals(first + " Notification failed; reservation retained."),
                "explicit partial-success result");
        check(failed.reservations().size() == 1, "notification failure retains accepted record");
        check(failure.calls == 1, "one attempt and no automatic retry");
        check(failed.reserve("B1", "CAM1", day, 2).equals("Rejected: item unavailable."),
                "retrying reservation is still a conflict");
        check(failure.calls == 1, "conflicting repeat does not attempt notification");

        ReservationCoordinator unexpected = ReservationApplication.create(new BrokenSender());
        expect(IllegalStateException.class, () -> unexpected.reserve("B1", "CAM1", day, 2),
                "unexpected sender failure is not swallowed");
        check(unexpected.reservations().size() == 1, "record is stored before the unexpected failure");

        Map<BorrowerCategory, BorrowingPolicy> configured = policies();
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                null, new ConfirmationFormatter(), inbox), "null policy map");
        expect(IllegalArgumentException.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules()),
                new ConfirmationFormatter(), inbox), "missing staff policy");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, null, inbox), "null formatter");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, new ConfirmationFormatter(), null), "null sender");
        Map<BorrowerCategory, BorrowingPolicy> mutable = new EnumMap<>(configured);
        ReservationCoordinator copied = new ReservationCoordinator(mutable,
                new ConfirmationFormatter(), new RecordingNotificationSender());
        mutable.put(BorrowerCategory.STUDENT, (count, days) -> false);
        check(copied.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "caller map mutation cannot rewire coordinator");
        Map<BorrowerCategory, BorrowingPolicy> restrictive = new EnumMap<>(configured);
        restrictive.put(BorrowerCategory.STUDENT, (count, days) -> false);
        RecordingNotificationSender restrictedInbox = new RecordingNotificationSender();
        ReservationCoordinator restricted = new ReservationCoordinator(restrictive,
                new ConfirmationFormatter(), restrictedInbox);
        check(restricted.reserve("B1", "CAM1", day, 1).equals(
                "Rejected: borrower limit or duration."), "injected policy is used");
        check(restrictedInbox.received().isEmpty(), "injected rejection sends nothing");

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        ConsoleNotificationSender console = new ConsoleNotificationSender(
                new PrintStream(bytes, true, StandardCharsets.UTF_8), new ConfirmationFormatter());
        console.send(c.reservations().get(0));
        check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: " + first + System.lineSeparator()),
                "console implementation uses supplied stream");
        PrintStream brokenOutput = new PrintStream(new OutputStream() {
            @Override public void write(int value) throws IOException {
                throw new IOException("Expected output failure");
            }
        });
        ConsoleNotificationSender brokenConsole = new ConsoleNotificationSender(
                brokenOutput, new ConfirmationFormatter());
        expect(NotificationException.class, () -> brokenConsole.send(c.reservations().get(0)),
                "console reports expected output failure");
        expect(NullPointerException.class, () -> inbox.send(null), "null notice rejected");
        System.out.println("All " + checks + " Chapter 6 checks passed.");
    }

    private static Map<BorrowerCategory, BorrowingPolicy> policies() {
        return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                      BorrowerCategory.STAFF, new StaffReservationRules());
    }

    private static final class FailingSender implements NotificationSender {
        private int calls;
        @Override public void send(Reservation reservation) {
            calls++;
            throw new NotificationException("Expected teaching failure");
        }
    }

    // A programming defect, not an expected output failure: it must not be swallowed.
    private static final class BrokenSender implements NotificationSender {
        @Override public void send(Reservation reservation) {
            throw new IllegalStateException("Unexpected programming failure");
        }
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action, String label) {
        try { action.run(); }
        catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.8 after/src/test/java/DirectoryAdapterTest.java

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class DirectoryAdapterTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);
    @ParameterizedTest
    @CsvSource({"S,STUDENT", "T,STAFF"})
    void mapsKnownActiveCategories(String code, BorrowerCategory expected) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, code, "Y"));
        assertEquals(new Borrower("B1", expected), adapter.find("B1").orElseThrow());
    }
    @Test void absenceAndInactiveAreEmpty() {
        assertTrue(new LegacyDirectoryAdapter(id -> null).find("B1").isEmpty());
        assertTrue(new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, "S", "N"))
                .find("B1").isEmpty());
    }
    @ParameterizedTest
    @CsvSource({"X,Y", "S,X", "s,Y", "S,y"})
    void unknownCodesAreErrors(String kind, String status) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, kind, status));
        assertThrows(DirectoryException.class, () -> adapter.find("B1"));
    }
    @Test void malformedRowsAreErrors() {
        for (var row : new LegacyDirectoryClient.Person[] {
                new LegacyDirectoryClient.Person("OTHER", "S", "Y"),
                new LegacyDirectoryClient.Person(null, "S", "Y"),
                new LegacyDirectoryClient.Person("B1", null, "Y"),
                new LegacyDirectoryClient.Person("B1", "S", null),
                new LegacyDirectoryClient.Person("B1", "X", "N")}) {
            assertThrows(DirectoryException.class, () -> new LegacyDirectoryAdapter(id -> row).find("B1"));
        }
    }
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {" "})
    void invalidIdentifierDoesNotReachProvider(String id) {
        var calls = new AtomicInteger();
        var adapter = new LegacyDirectoryAdapter(key -> { calls.incrementAndGet(); return null; });
        assertThrows(IllegalArgumentException.class, () -> adapter.find(id));
        assertEquals(0, calls.get());
    }
    @Test void translatesExpectedFailureAndKeepsCause() {
        var original = new LegacyDirectoryClient.Failure("offline");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        var result = assertThrows(DirectoryException.class, () -> adapter.find("B1"));
        assertSame(original, result.getCause());
    }
    @Test void unexpectedProgrammingFailureIsNotDisguisedAsAbsence() {
        var original = new IllegalStateException("bug");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        assertSame(original, assertThrows(IllegalStateException.class, () -> adapter.find("B1")));
    }
    @Test void categoryFromDirectoryControlsReservationPolicy() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var directory = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(
                id, id.equals("STAFF1") ? "T" : "S", "Y"));
        var service = new DirectoryReservationService(directory, coordinator);
        assertTrue(service.reserve("STAFF1", "CAM1", start, 10).startsWith("Confirmed: R1"));
        assertEquals("Rejected: borrower limit or duration.", service.reserve("STUDENT1", "MIC1", start, 10));
        assertEquals(1, coordinator.reservations().size());
        assertEquals("STAFF1", coordinator.reservations().get(0).borrowerId());
        assertEquals(1, notices.received().size());
    }
    @Test void absenceAndOutageDoNotStoreNotifyOrConsumeId() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var missing = new DirectoryReservationService(id -> Optional.empty(), coordinator);
        assertEquals("Rejected: borrower not found or inactive.", missing.reserve("B1", "CAM1", start, 2));
        var failing = new DirectoryReservationService(new LegacyDirectoryAdapter(id -> {
            throw new LegacyDirectoryClient.Failure("offline");
        }), coordinator);
        assertThrows(DirectoryException.class, () -> failing.reserve("B1", "CAM1", start, 2));
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(notices.received().isEmpty());
        var valid = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        assertTrue(valid.reserve("B1", "CAM1", start, 2).startsWith("Confirmed: R1"));
    }
    @Test void mismatchedDomainBorrowerIsRejectedBeforeMutation() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower("OTHER", BorrowerCategory.STAFF)), coordinator);
        assertThrows(DirectoryException.class, () -> service.reserve("B1", "CAM1", start, 10));
        assertTrue(coordinator.reservations().isEmpty());
    }
    @Test void localArgumentErrorsPrecedeLookup() {
        var calls = new AtomicInteger();
        var service = new DirectoryReservationService(id -> { calls.incrementAndGet(); return Optional.empty(); },
                ReservationApplication.create(new RecordingNotificationSender()));
        assertThrows(IllegalArgumentException.class, () -> service.reserve("B1", " ", start, 2));
        assertThrows(NullPointerException.class, () -> service.reserve("B1", "CAM1", null, 2));
        assertEquals(0, calls.get());
    }
}

4.9 after/src/test/java/LegacyRegressionTest.java

import org.junit.jupiter.api.Test;

class LegacyRegressionTest {
    @Test void chapter02() { Chapter02Checks.main(new String[0]); }
    @Test void chapter03() { Chapter03AcceptanceExamples.main(new String[0]); }
    @Test void chapter05() { Chapter05Checks.main(new String[0]); }
    @Test void chapter06() { Chapter06Checks.main(new String[0]); }
}

4.10 after/src/test/java/LoanServiceTest.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class LoanServiceTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);

    private ReservationCoordinator reserved() {
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        c.reserve("B1", "CAM1", start, 2);
        return c;
    }

    @Test void checkoutAndReturnUseTheSuppliedClock() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        Loan active = service.checkOut("R1");
        List<Loan> snapshot = service.loans();
        assertThrows(UnsupportedOperationException.class, snapshot::clear);
        assertEquals(start, active.borrowedOn());
        assertTrue(active.active());
        clock.set(Instant.parse("2026-10-21T09:00:00Z"));
        Loan returned = service.returnLoan("R1");
        assertAll(() -> assertFalse(returned.active()),
                () -> assertEquals(start.plusDays(1), returned.returnedOn()),
                () -> assertTrue(snapshot.get(0).active(), "Old immutable snapshot stays active"),
                () -> assertEquals(List.of(returned), service.loans()));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertEquals(List.of(returned), service.loans());
    }

    @ParameterizedTest
    @ValueSource(strings = {"2026-10-19T12:00:00Z", "2026-10-22T00:00:00Z"})
    void checkoutOutsideHalfOpenPeriodIsRejected(String instant) {
        LoanService service = new LoanService(reserved(), Clock.fixed(Instant.parse(instant), ZoneOffset.UTC));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void unknownReservationAndInactiveReturnChangeNothing() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        assertThrows(IllegalArgumentException.class, () -> service.checkOut("R99"));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void aReservationCannotBeCheckedOutTwiceEvenAfterReturn() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        service.checkOut("R1");
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        service.returnLoan("R1");
        List<Loan> before = service.loans();
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertEquals(before, service.loans());
    }

    @Test void unreturnedItemBlocksTheNextAdjacentReservationUntilReturned() {
        ReservationCoordinator c = reserved();
        c.reserve("B2", "CAM1", start.plusDays(2), 1);
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(c, clock);
        service.checkOut("R1");
        clock.set(Instant.parse("2026-10-22T09:00:00Z"));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R2"));
        assertEquals(1, service.loans().size());
        service.returnLoan("R1");
        assertTrue(service.checkOut("R2").active());
    }

    @Test void clockZoneDeterminesTheCheckoutDate() {
        Clock local = Clock.fixed(Instant.parse("2026-10-19T22:30:00Z"), ZoneId.of("Asia/Baghdad"));
        LoanService service = new LoanService(reserved(), local);
        assertEquals(start, service.checkOut("R1").borrowedOn());
    }

    @Test void backwardClockCannotRecordReturnBeforeCheckout() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        service.checkOut("R1");
        List<Loan> before = service.loans();
        clock.set(Instant.parse("2026-10-19T09:00:00Z"));
        assertThrows(IllegalArgumentException.class, () -> service.returnLoan("R1"));
        assertEquals(before, service.loans());
    }

    @Test void returningLoanDoesNotRewriteHistoricalReservationsOrTheirCount() {
        ReservationCoordinator c = reserved();
        LoanService service = new LoanService(c, Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        List<Reservation> before = c.reservations();
        service.checkOut("R1");
        service.returnLoan("R1");
        assertEquals(before, c.reservations());
        assertFalse(c.isAvailable("CAM1", start, start.plusDays(1)));
    }

    static final class AdjustableClock extends Clock {
        private final AtomicReference<Instant> now;
        private final ZoneId zone;
        AdjustableClock(Instant instant) { this(new AtomicReference<>(instant), ZoneOffset.UTC); }
        private AdjustableClock(AtomicReference<Instant> now, ZoneId zone) {
            this.now = now; this.zone = zone;
        }
        void set(Instant instant) { now.set(instant); }
        @Override public ZoneId getZone() { return zone; }
        @Override public Clock withZone(ZoneId zone) { return new AdjustableClock(now, zone); }
        @Override public Instant instant() { return now.get(); }
    }
}

4.11 after/src/test/java/NotificationMockitoTest.java

import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class NotificationMockitoTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);

    @Test void acceptedReservationIsSentOnceWithItsDomainDetails() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        c.reserve("B1", "CAM1", day, 2);
        ArgumentCaptor<Reservation> captured = ArgumentCaptor.forClass(Reservation.class);
        verify(sender).send(captured.capture());
        Reservation sent = captured.getValue();
        assertAll(() -> assertEquals("R1", sent.id()),
                () -> assertEquals("B1", sent.borrowerId()),
                () -> assertEquals("CAM1", sent.itemId()),
                () -> assertEquals(day, sent.period().start()),
                () -> assertEquals(day.plusDays(2), sent.period().endExclusive()));
    }

    @Test void rejectedDurationDoesNotNotify() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 8));
        assertTrue(c.reservations().isEmpty());
        verifyNoInteractions(sender);
    }

    @Test void expectedNotificationFailureRetainsReservationWithoutRetry() {
        NotificationSender sender = mock(NotificationSender.class);
        doThrow(new NotificationException("Expected test failure")).when(sender).send(any(Reservation.class));
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."
                + " Notification failed; reservation retained.", c.reserve("B1", "CAM1", day, 2));
        assertEquals(1, c.reservations().size());
        verify(sender, times(1)).send(any(Reservation.class));
    }
}

4.12 after/src/test/java/patterns/PatternExamplesTest.java

package patterns;

import static org.junit.jupiter.api.Assertions.*;
import static patterns.PatternExamples.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class PatternExamplesTest {
    @Test void factoryMethodChangesProductWithoutReplacingWorkflow() {
        ExportJob plain = new PlainJob();
        ExportJob html = new HtmlJob();
        assertEquals("TEXT: A&B", plain.run("A&B"));
        assertEquals("<p>A&amp;B</p>", html.run("A&B"));
        assertThrows(NullPointerException.class, () -> html.run(null));
        assertThrows(NullPointerException.class, () -> plain.run(null));
    }
    @Test void creatorInvokesOverriddenHookOnce() {
        var calls = new AtomicInteger();
        ExportJob job = new ExportJob() {
            protected Exporter createExporter() {
                calls.incrementAndGet();
                return value -> "chosen:" + value;
            }
        };
        assertEquals("chosen:R1", job.run("R1"));
        assertEquals(1, calls.get());
    }
    @Test void simpleFactoryCanProduceSameOutputs() {
        assertEquals("TEXT: R1", simpleExporter(false).export("R1"));
        assertEquals("<p>&lt;R1&gt;</p>", simpleExporter(true).export("<R1>"));
    }
    @Test void abstractFactorySuppliesBothProductsInEachFamily() {
        assertEquals("Loan\nR1\n", receipt(new PlainFamily(), "Loan", "R1"));
        assertEquals("<h1>Loan</h1><p>&lt;R1&gt;</p>", receipt(new HtmlFamily(), "Loan", "<R1>"));
    }
    @Test void builderDefaultsAndExplicitOptions() {
        var minimal = ReportRequest.forBorrower("B1").build();
        assertFalse(minimal.includeReturned());
        assertNull(minimal.from());
        assertTrue(minimal.columns().isEmpty());
        var from = LocalDate.of(2026, 10, 20);
        var full = ReportRequest.forBorrower("B1").during(from, from.plusDays(2))
                .includeReturned(true).column("item").build();
        assertEquals("B1", full.borrowerId());
        assertEquals(from, full.from());
        assertEquals(from.plusDays(2), full.toExclusive());
        assertTrue(full.includeReturned());
        assertEquals(List.of("item"), full.columns());
    }
    @Test void builtValueIsIndependentOfReusableBuilder() {
        var builder = ReportRequest.forBorrower("B1").column("item");
        var first = builder.build();
        builder.column("date");
        assertEquals(List.of("item"), first.columns());
        assertEquals(List.of("item", "date"), builder.build().columns());
        assertThrows(UnsupportedOperationException.class, () -> first.columns().add("other"));
    }
    @Test void builderRejectsInvalidConfiguration() {
        var date = LocalDate.of(2026, 10, 20);
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower(" "));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date).build());
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date.minusDays(1)).build());
        assertThrows(NullPointerException.class, () -> ReportRequest.forBorrower("B1").during(date, null));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").column(" "));
    }
    @Test void auditOutsideRetryObservesOneLogicalCall() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new AuditedSender(new RetryOnceSender(flaky), events).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "sent"), events);
    }
    @Test void retryOutsideAuditObservesEachAttempt() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new RetryOnceSender(new AuditedSender(flaky, events)).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "failed", "attempt", "sent"), events);
    }
    @Test void secondExpectedFailurePropagatesWithoutThirdAttempt() {
        var calls = new AtomicInteger();
        var original = new DeliveryFailure("still offline");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(DeliveryFailure.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(2, calls.get());
    }
    @Test void unexpectedFailureIsNotRetried() {
        var calls = new AtomicInteger();
        var original = new IllegalArgumentException("bad message");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(IllegalArgumentException.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(1, calls.get());
    }
}

5. Build and supporting files

Preserve the test-resource path exactly. Mockito is used by the inherited tests; the new tests use manual examples.

5.1 after/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>equipment-lending</artifactId>
  <version>0.8.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

5.2 after/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

mock-maker-proxy

5.3 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
"${CHAPTER_MVN:-mvn}" --batch-mode verify

5.4 after/.gitignore

/target/
/build/
*.class

5.4a after/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps scripts/check.sh in LF form when Git on Windows converts line endings. Without it, a CRLF copy of the script fails under WSL or Linux bash.

5.5 after/README.md

# Chapter 8 checkpoint

Java 17; Maven 3.9.11. JUnit 5.13.4 and Mockito 5.20.0 remain pinned in pom.xml.

From this directory:

```bash
mvn --batch-mode verify
java -cp target/classes Chapter08Demo
java -cp target/classes Chapter07Demo
```

New cumulative entry point: DirectoryReservationService, using BorrowerDirectory and LegacyDirectoryAdapter.
LegacyDirectoryClient models a synthetic external contract. Chapter08Demo uses a map fixture, not a network service.

The patterns package contains isolated Factory Method, Abstract Factory, fluent Builder and Decorator examples. It is not wired into the lending application. Its JUnit tests serve as executable examples.

All Chapter 7 application sources and test files remain unchanged. Chapter 7's four legacy wrappers still execute the earlier plain-Java runners. The complete build executes 63 JUnit invocations: 35 inherited, 17 directory-boundary cases and 11 pattern cases.

The new entry point maps categories before reservation. It does not authenticate callers. Direct coordinator methods remain available for trusted internal examples. In-memory state, reservation lifecycle limitations and the original one-attempt notification contract remain unchanged.

No real university API, database, hosted CI or production delivery service is used.

6. Worked reading and test evidence

6.1 Follow one successful directory lookup

For reserve("B2", "CAM1", 2026-10-20, 10), the service first checks the local arguments. The adapter calls lookup("B2"). The demo example returns Person("B2", "T", "Y"). Identity matches, T maps to STAFF, and Y permits a present result. The service passes that category to the unchanged coordinator.

The coordinator applies the staff policy, finds no conflict, stores R1, formats the confirmation and makes its one notification attempt. The adapter neither calculates duration limits nor allocates reservation identifiers. If staff policy changes later, the mapping T to STAFF need not change.

For B1, the provider returns the student kind and the same ten-day request is rejected by student policy. That is a successful directory lookup followed by a reservation rejection. For UNKNOWN, lookup returns null and the service rejects before calling the coordinator. For a declared provider failure, the adapter throws a domain-facing exception and retains the original cause.

6.2 Acceptance mapping

PromiseExecutable evidence
Known active codes map to the correct categorymapsKnownActiveCategories: two parameter rows
Missing and valid inactive rows yield absenceabsenceAndInactiveAreEmpty
Unknown and differently cased codes are not defaultedunknownCodesAreErrors: four parameter rows
Null fields, identity mismatch and malformed inactive rows are errorsmalformedRowsAreErrors
Invalid lookup identifiers never reach the providerinvalidIdentifierDoesNotReachProvider: null, empty, blank
Expected outage is translated with its causetranslatesExpectedFailureAndKeepsCause
Unexpected programming failure remains visibleunexpectedProgrammingFailureIsNotDisguisedAsAbsence
Directory category selects the inherited duration policycategoryFromDirectoryControlsReservationPolicy
Absence/outage leave records and notices unchanged and preserve R1absenceAndOutageDoNotStoreNotifyOrConsumeId
A different returned domain identity cannot be reservedmismatchedDomainBorrowerIsRejectedBeforeMutation
Local argument failures precede lookuplocalArgumentErrorsPrecedeLookup

The table names the tests in DirectoryAdapterTest. Some methods contain multiple related assertions; invocation count is not assertion count. The inherited tests continue to protect notification failures, reservation conflicts, query boundaries and the bounded loan lifecycle.

6.3 Factory Method versus the simple factory

The following two calls can produce equivalent plain output:

new PatternExamples.PlainJob().run("R1");
PatternExamples.simpleExporter(false).export("R1");

The first executes an inherited workflow that invokes the overridden creation hook. The second calls a product returned by a selecting function. The examples differ in who selects the product and how the construction decision is extended. Neither is better solely because it has a more formal pattern name.

A composition alternative is a job with an Exporter constructor parameter and a run method that invokes it. Choose this when callers should supply the product directly. Choose the demonstrated Factory Method when a framework or inherited workflow explicitly needs subclass-controlled creation. A real requirement for such an extension point should precede the hierarchy.

The test creatorInvokesOverriddenHookOnce constructs an anonymous creator for observation. It returns a product with a distinctive output and counts the hook invocation. This intentionally examines the creation mechanism being taught. It does not imply that tests of the lending domain should verify private helper calls.

6.4 Abstract Factory and a plausible alternative

receipt(new PlainFamily(), "Loan", "R1") produces two text lines. receipt(new HtmlFamily(), "Loan", "<R1>") produces a heading and an escaped paragraph. Both products come from the selected family.

A plausible simpler design is one ReceiptRenderer per format that returns the whole receipt. It avoids two product interfaces and is appropriate when the parts never vary or collaborate independently. The family design becomes more useful when clients genuinely need several related product roles through stable interfaces.

Adding a Markdown family would require one new implementation supplying both roles; the receipt workflow need not change. Adding a footer role would change ReceiptFamily and require each family to implement it. That asymmetry is the main extension trade-off to explain in a review.

Returning an HTML heading and plain body from one defective factory is still possible. The interfaces establish product roles; the concrete implementation and complete-output tests establish the selected family behaviour.

6.5 Builder and snapshot independence

The builder’s mutable columns list must not become the request’s mutable backing storage. In the final implementation, construction uses List.copyOf. A built request therefore preserves its columns when the builder receives another column, and callers cannot modify the returned list.

The test builtValueIsIndependentOfReusableBuilder verifies both properties. The immutability claim concerns this object’s fields: Strings and LocalDates are immutable, the scalar option is copied, and the list snapshot contains Strings. A shallow copy of mutable element objects in a different design would need further analysis.

The two date arguments are checked for null before the builder changes either date field. Cross-field ordering is checked at build(). This lets the builder temporarily hold reversed dates while configuring, but prevents publication of an invalid request. There is no partially valid request object returned to the caller.

The optional interval uses null for both omitted dates inside this small configuration object. The product makes that convention explicit; it does not make every optional application field nullable. A separate interval value could be a reasonable alternative if reporting becomes real running behaviour.

6.6 Decorator order and the failure path

AuditedSender(new RetryOnceSender(flaky), events) records one logical call. Retry absorbs the first declared failure and calls the underlying sender again. Audit sees only eventual normal return, so its history is attempt, sent.

RetryOnceSender(new AuditedSender(flaky, events)) invokes the audit layer on each attempt. The first history is attempt, failed, and the second is attempt, sent. Reversing the wrappers changes observations while leaving the outer method signature identical.

After a second declared failure, the second exception escapes. The retry implementation does not recursively invoke itself. If the first error is an unexpected IllegalArgumentException, it escapes immediately and the counter remains one. These outcomes are tested separately from eventual success.

The audit wrapper only labels the declared delivery failure. Unexpected exceptions propagate without a failed audit event in this deliberately narrow implementation. The example’s ordinary mutable ArrayList is assumed to accept events. A real audit sink would require a reviewed contract for recording failures, especially if both audit and delivery fail.

Do not copy the retry example into ReservationApplication.create as an exercise shortcut. The running sender interface accepts a Reservation, and its existing contract specifies one attempt. Changing both the type and policy is a feature change requiring reviewed outcomes, not merely adaptation of syntax.

7. Intentionally wrong candidates for diagnosis

These fragments are deliberately incorrect alternatives, not files to add to the active source directory. Review them against the actual tests and restore the correct source after any isolated experiment.

7.1 Unsafe category fallback

// WRONG: an unfamiliar provider code silently acquires staff privileges.
BorrowerCategory category = "S".equals(row.kind())
        ? BorrowerCategory.STUDENT : BorrowerCategory.STAFF;

The X,Y and s,Y rows of unknownCodesAreErrors require a DirectoryException. This candidate instead constructs a staff borrower. The correct implementation explicitly recognises S and T and rejects every other kind. Whether future categories should be supported is a requirement decision, not a fallback default.

7.2 Conflating outage with absence

// WRONG: turns provider or programming failures into a normal empty lookup.
try {
    row = client.lookup(borrowerId);
} catch (RuntimeException failure) {
    return Optional.empty();
}

This loses the outage distinction and hides unexpected defects. translatesExpectedFailureAndKeepsCause and unexpectedProgrammingFailureIsNotDisguisedAsAbsence specify different outcomes. Catch the provider’s declared failure at its boundary, translate it with the cause, and let unrelated failures remain visible.

7.3 A builder that shares mutable state

// WRONG inside ReportRequest construction:
columns = builder.columns;

The next builder change would alter an already built value, and a caller could modify the exposed list. The final implementation copies at construction. The snapshot-independence test contains observations for both defects.

The three candidates were applied, one at a time, to a copy of the project and run through mvn test. Each was rejected by the tests named above: the unsafe fallback by unknownCodesAreErrors and malformedRowsAreErrors, the catch-all by translatesExpectedFailureAndKeepsCause and absenceAndOutageDoNotStoreNotifyOrConsumeId, and the shared builder list by builtValueIsIndependentOfReusableBuilder.

8. Suggested practical solution and design decision

8.1 Integration solution

The complete listings provide one suggested solution. Keep ReservationCoordinator independent of the provider record, codes and failure type. Implement BorrowerDirectory with the adapter. Construct the coordinator normally, then supply it and the adapter to DirectoryReservationService. Use synthetic rows for the demonstration and tests.

The acceptance outcomes are the mapping table plus preservation of reservation behaviour. No lookup result may introduce a borrower category outside the agreed translation. Missing/inactive lookup produces the selected rejection string; outage or malformed data produces an identifiable exception before storage. The service does not catch that exception and turn it into a confirmation or fallback category.

A different design can still be valid. For example, an application may return a structured result distinguishing rejection and operational failure rather than using strings and exceptions. Such a proposal must define how callers handle it and identify any changed contracts. It should not quietly rewrite all earlier APIs merely to fit a diagram.

8.2 Example problem–solution–consequence note

Problem: the directory’s lookup method, raw codes, null absence and provider exception differ from the reservation application’s needs. Putting those conventions in the coordinator would make policy code depend on one provider.

Solution: introduce the application-owned BorrowerDirectory interface and an object adapter. A separate application service looks up a borrower before invoking the coordinator with the mapped category. Keep the earlier assembly function.

Consequences: translation is located in one tested boundary. Provider changes have a clear first location for review. The design adds several small types and mapping tests, and local examples cannot establish real-provider compatibility. Authentication, retries and lifecycle changes remain separate requirements.

Alternatives declined: a creator hierarchy is unnecessary for the existing assembly; there is no provider product family requiring Abstract Factory; core values have small constructors; installing retry would alter the established notification policy.

Revisit when: an actual provider SDK contract is available, an authenticated entry point is designed, or a concrete requirement adds a related family of integration products. Review those changes individually rather than pre-building every possible variation.

8.3 Second-scenario comparison

For a report containing just borrower ID and two required dates, a constructor or named creation method is sufficient. If several optional configuration choices make positional calls difficult to read, a builder can clarify intent and publish a validated immutable snapshot. The provided example demonstrates that latter pressure but does not require a builder for every record.

For exporter selection, an injected Exporter is preferable when the caller can choose it directly. Factory Method is justified when subclass-controlled creation is an intentional part of an inherited workflow. Both alternatives can satisfy the same output tests; the structural requirement decides between them.

A good submission names an actual change that becomes easier and a concrete cost. “Uses more patterns” is neither a benefit nor an acceptance condition.

8.4 Builder and Decorator implementation exercises

These solutions extend the isolated patterns examples only. They do not touch the lending application, and they are not part of the 63-invocation inventory.

Builder: an optional maximum row count. In ReportRequest, add an OptionalInt maximumRows field and accessor (import java.util.OptionalInt), copy it in the constructor with maximumRows = builder.maximumRows;, and give the builder an OptionalInt maximumRows = OptionalInt.empty(); field and this method:

public Builder maximumRows(int value) {
    if (value <= 0) throw new IllegalArgumentException("Positive row limit required");
    maximumRows = OptionalInt.of(value);
    return this;
}

OptionalInt is immutable, so copying the reference at construction is enough to keep a built request independent of later builder changes. Absence is represented explicitly and not by a sentinel such as zero.

@Test void maximumRowsIsAbsentByDefaultAndMustBePositive() {
    assertTrue(ReportRequest.forBorrower("B1").build().maximumRows().isEmpty());
    assertEquals(5, ReportRequest.forBorrower("B1").maximumRows(5).build().maximumRows().getAsInt());
    assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").maximumRows(0));
    assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").maximumRows(-1));
}

@Test void laterBuilderChangeDoesNotAffectABuiltRowLimit() {
    var builder = ReportRequest.forBorrower("B1").maximumRows(5);
    var first = builder.build();
    builder.maximumRows(9);
    assertEquals(5, first.maximumRows().getAsInt());
    assertEquals(9, builder.build().maximumRows().getAsInt());
}

Decorator: counting attempts. Add this class beside the other senders (import java.util.concurrent.atomic.AtomicInteger):

public static final class CountingSender implements Sender {
    private final Sender delegate;
    private final AtomicInteger attempts;
    public CountingSender(Sender delegate, AtomicInteger attempts) {
        this.delegate = Objects.requireNonNull(delegate, "delegate");
        this.attempts = Objects.requireNonNull(attempts, "attempts");
    }
    public void send(String message) {
        attempts.incrementAndGet();
        delegate.send(message);
    }
}

The prediction is the point of the exercise. With one failure followed by success, a counter outside the retry decorator sees one logical call, so it counts 1. A counter inside it sees both delegate attempts, so it counts 2. It is the same order effect as the audit histories, now measured by a number.

@Test void countingOutsideRetryCountsCallsAndInsideCountsAttempts() {
    var flakyCalls = new AtomicInteger();
    Sender flaky = message -> { if (flakyCalls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
    var outside = new AtomicInteger();
    new CountingSender(new RetryOnceSender(flaky), outside).send("R1");
    assertEquals(1, outside.get());
    flakyCalls.set(0);
    var inside = new AtomicInteger();
    new RetryOnceSender(new CountingSender(flaky, inside)).send("R1");
    assertEquals(2, inside.get());
}

With these three tests added, the class PatternExamplesTest runs 14 tests and all pass. Accept other designs that preserve the stated behaviour. A sentinel such as 0 meaning “no limit” is a weaker design here, because it makes “zero rows” unrepresentable and ambiguous; ask students to justify it if they choose it.

Your team’s project step (practical step 9). There is no single answer. A strong note names a concrete external or mismatched interface in the team’s code, shows the problem–solution–consequence reasoning, and says what the team would decline to build. Reject answers that propose an adapter where two components already share the same contract and meaning.

9. AI activity solution

Fictional claimDiagnosis and correctionEvidence or decision
Renaming static create makes Factory MethodNames do not create a subclass hookCompare simpleExporter with ExportJob.createExporter
One product role requires Abstract FactoryNo related set has been establishedRetain simple assembly until a family requirement exists
Catch every exception and return staffHides failure and grants an unsupported categoryUnknown-code and exception-cause tests
Wrap all senders with retryChanges the one-attempt running contractKeep retry isolated; review any proposed policy change
Decorator order does not matterChanges which layer observes each attemptThe two exact audit-history tests
A builder guarantees immutable productsMutable aliases can remain after buildSnapshot and unmodifiable-list tests

Pattern classification needs source inspection as well as output tests. A test cannot prove that a static function became a subclass-based Factory Method because the function was renamed. Conversely, finding an appropriate structure does not establish correct error handling. Check both design reasoning and behaviour.

For a live AI exchange, retain only an actual transcript or relevant excerpts. Record which claims you checked and which you rejected. The fictional example is sufficient for learners without an AI account and should never be presented as output from a real service.

10. Review answers and transfer solution

10.1 Four concept answers

1. Factory Method versus simple factory: ExportJob owns the inherited workflow and subclasses override a product-creation hook. simpleExporter selects and returns a product in an ordinary function. A method named create or a return type that is an interface does not alone establish the former structure.

2. A genuine product family: several product roles must vary together for a meaningful compatibility reason. In the example, heading and body share an output format. Constructing an unrelated clock and directory together does not establish that requirement. A family interface also needs correct concrete implementations; it cannot prevent every mixed family by itself.

3. Independence after build: the request copies mutable builder state, validates before publication and exposes no mutation operation. In this example the list elements and date values are themselves immutable. Returning a final reference to the builder’s mutable list would not suffice.

4. Adapter versus Decorator: Adapter translates one contract into the contract its client expects. Decorator retains the component interface while adding optional responsibilities around delegation. Holding another object is a common mechanism, not a sufficient classification. Here category translation is Adapter; audit/retry composition is Decorator.

10.2 Applied diagnosis answers

5. Broad exception handling: it can conceal a legitimate provider outage and a programming defect such as an unexpected illegal state. Both become indistinguishable from a completed lookup finding no borrower. The application may display a misleading rejection and operators lose the meaningful cause. Translate the declared provider failure with cause retention; keep malformed data explicit and unexpected defects visible.

6. Wrapper order: with one failure followed by success, audit outside retry records attempt, sent; retry outside audit records attempt, failed, attempt, sent. Choose the former when the record concerns logical operations, or the latter when every delivery attempt must be observed. Also decide how a real recording failure affects the operation. Interface compatibility alone does not decide those semantics.

10.3 Library-patron transfer

First obtain an explicit provider mapping. For a hypothetical contract only, assume category 10 means student, category 20 means staff, and an inactive flag excludes an otherwise valid patron. Do not transplant the equipment example’s string codes into a numeric API without that agreement.

Define an application-owned PatronDirectory.find(id) returning presence/absence and an identifiable operational failure. Adapt provider records into immutable patron values. Keep borrowing eligibility policy outside the translation layer.

Three failure examples are an unknown category, a returned identifier different from the requested one, and a declared provider outage. All must prevent the borrowing operation before state changes; the outage retains its cause. A valid inactive row yields the agreed empty result, provided the policy follows the chapter’s absence convention. Add a test for each outcome and clarify malformed-inactive precedence.

If the receipt has one interchangeable renderer, use direct injection or a simple factory. If multiple independently used receipt components must be supplied as matching plain/HTML families, Abstract Factory becomes a candidate. Explain the compatibility relationship and the cost of adding another product role. Neither patron lookup nor receipt rendering automatically requires a builder or retry decorator.

11. Instructor guidance

11.1 Suggested teaching sequence

Demonstrate the simple factory and Factory Method side by side. Ask students to locate the point where the product choice varies, then run the two output examples and the hook-observation test. Explain why matching output does not settle the pattern classification.

Next trace the directory adapter. Use one staff success, one unknown code and one provider failure. Ask students where the first reservation mutation is possible and what must remain unchanged before that point. Run the full test suite after the new entry point is introduced.

Assign the family and builder examples as guided reading during the midterm week. Ask students to predict decorator event histories before running the order tests. Use the model answers for discussion, accepting alternatives that preserve the agreed semantics and explain their costs. Do not assess by number of patterns or require the separate examples to be wired into the running application.

The official midterm scope remains Weeks 1–6, Chapters 2–7. This chapter adds no assessment weights, deadlines or official contact-hour allocation.

11.2 Do the tests detect faults?

Twenty faults, listed in the table below (8 + 3 + 2 + 4 + 2 + 1), were introduced one at a time into copies of the project, and each copy was run through mvn test. All twenty are caught.

AreaFaults triedResult
AdapterUnknown kind defaults to staff (Section 7.1); catch-all returns empty (7.2); inactive checked before kind; identifier mismatch unchecked; codes case-insensitive; any non-N status accepted; blank identifier reaches the provider; outage cause droppedAll eight caught by DirectoryAdapterTest
Directory serviceDirectory category ignored; absent borrower gets a student default; returned identity uncheckedAll three caught
BuilderShared mutable column list (7.3); interval not validatedBoth caught
DecoratorsRetry on any RuntimeException; retry twice; no failed audit event; audit swallows the failureAll four caught
Abstract Factory and escapingHTML heading uses the plain renderer; angle brackets not escapedBoth caught
Factory MethodExportJob.run skips its null checkCaught by plain.run(null) in the existing test. Without that call only HtmlJob.run(null) would be tested, and HtmlJob fails on null anyway through escape, so PlainJob.run(null) would silently return TEXT: null

The 35 inherited invocations include four wrappers around the earlier plain-Java check runners. Their internal counts remain distinct: 34 Chapter 2 checks, five Chapter 3 example groups, 44 Chapter 5 checks and 28 Chapter 6 checks.

The Mockito dynamic-agent warning described in Chapter 7 also appears in this project’s log on a recent JDK. It does not affect results, and -DargLine=-XX:+EnableDynamicAgentLoading removes it.

Do not add the inherited check counts as if every unit were the same kind of test.

Local success does not prove real-provider compatibility, authentication, durable storage, concurrency safety or absence of all defects. It establishes the documented examples for this source and configuration. The intentionally wrong fragments in Section 7 are diagnosis material and were not part of the twenty-fault experiment above; no other mutation-run result is claimed.

References

Sources checked 12 October 2026. See the chapter’s source notes for the distinction between the classic book’s bibliographic reference and the publisher excerpts inspected online.

  1. Gamma, E., Helm, R., Johnson, R. and Vlissides, J., Design Patterns: Elements of Reusable Object-Oriented Software. Publisher catalogue.
  2. Gamma et al., Abstract Factory excerpt and Adapter excerpt.
  3. Oracle, Java SE 17 Optional and FilterInputStream.
  4. JUnit Team, JUnit 5.13.4 User Guide.

Chapter 9 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 9 Companion: Code, Explanations and Solutions.

Designing Changing Behaviour
OO Development and Agile Practices

1. Scope and reproducible execution

This file accompanies Chapter 9, Designing Changing Behaviour. It includes the complete running version, separate before/after lifecycle examples, tests, model solutions and teaching notes. Use Java 17 and the same pinned dependencies as Chapter 8.

Starting point: Chapter 8’s directory-backed reservation flow. Running option: NotificationPublisher can be supplied through the existing sender interface. Action example: ReserveEquipmentCommand invokes the real directory-backed service when executed. Separate exercise: behaviour.LifecycleExamples contains a proposed lifecycle, with conditional and State versions. It does not modify the actual reservation or loan model.

1.1 Reconstruct from the listings

Create a directory called after. Copy the content of every labelled file listing in Sections 3–5 to its stated path, creating intermediate directories and preserving case. Do not copy fence markers. The README listing uses four backticks because its own content includes three-backtick code fences.

For an incremental update, copy Chapter 8’s completed after directory. Add the five new main-source files and four new test files identified in Section 2, then replace the POM and README with this chapter’s versions. The project version changes to 0.9.0. Every inherited source file remains unchanged.

ToolTested configuration
JavaRelease 17
Maven3.9.11
JUnit Jupiter5.13.4
Mockito5.20.0; inherited interface proxy configuration
Compiler Plugin3.14.1
Surefire Plugin3.5.4; fail if no tests are found

From after:

mvn --batch-mode verify
java -cp target/classes Chapter09Demo

To run only this chapter’s new tests:

mvn --batch-mode -Dtest=BehaviourStrategyTest,LifecycleExamplesTest,NotificationPublisherTest,ReserveEquipmentCommandTest test

The first Maven build needs dependency access. The Bash wrapper bash scripts/check.sh still delegates to the full Maven gate. The separate lifecycle and Chapter 8 pattern examples have no standalone launchers; their tests are their executable demonstrations.

1.2 Expected demonstration

Before execution: 0
Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22). Notification failed; reservation retained.
Observer events: [Desk: R1, Audit: R1]
Stored reservations: 1
Separate lifecycle exercise: RETURNED

The command is constructed before the first line and executed for the second. One synthetic observer fails with an expected notification exception. The later audit observer still receives the record. The final line concerns a different object in the isolated lifecycle exercise; it does not return or cancel R1 in the running application.

The full build executes 107 JUnit invocations. This includes 63 inherited cases and 44 new cases. Invocation count is an execution inventory, not a measure of design quality or a claim of exhaustive application correctness.

2. Design map and behaviour contracts

2.1 New files

FilePurpose
NotificationPublisher.javaOptional Observer-based sender; nested callback and subscription contracts
Command.javaResult-bearing action interface
ReserveEquipmentCommand.javaCaptured request invoking the existing service
Chapter09Demo.javaExplicitly composed publisher and command demonstration
behaviour/LifecycleExamples.javaSeparate proposed lifecycle; conditional starting point and State completion
BehaviourStrategyTest.javaCompare existing strategies with a small conditional and an independent table
behaviour/LifecycleExamplesTest.javaAll 20 status/action pairs plus independent-context test
NotificationPublisherTest.javaDispatch, subscription and partial-failure behaviour
ReserveEquipmentCommandTest.javaLazy construction, execution state, replay and failure propagation

The existing patterns package remains Chapter 8’s isolated material. The new behaviour package contains only the lifecycle exercise. Neither named package imports the running application’s unnamed-package classes. Chapter 10 will address application structure explicitly.

2.2 Strategy is already present

BorrowingPolicy is the common contract. ReservationRules and StaffReservationRules are the existing strategies. The coordinator’s injected map chooses the applicable policy. No new runtime policy class is required to demonstrate the pattern.

The conditional comparison exists only inside BehaviourStrategyTest. It assumes the two currently supported category enum values; its default branch is not a general policy for unknown future categories. Each implementation is tested against explicit expected values rather than only against the other implementation.

The alternative staff lambda is a test example with limits of one counted reservation and two days. It obeys the common side-effect-free boolean contract. It is not adopted as the default staff rule.

2.3 Proposed lifecycle contract

The lifecycle starts REQUESTED. Allowed edges are REQUESTED to CONFIRMED or CANCELLED; CONFIRMED to ON_LOAN or CANCELLED; and ON_LOAN to RETURNED. All other operations reject with IllegalStateException and preserve status. RETURNED and CANCELLED are terminal for the four exposed actions.

The ConditionalReservation class is the starting example, not intentionally broken code. StateReservation is the alternative organisation. Private Phase enum constants contain stateless behaviour; each context owns its own phase reference. There is no public setter, no date handling, no storage and no connection to LoanService.

2.4 Publisher contract

The publisher calls a snapshot of registered observers in registration order. Subscription changes during callbacks apply to later publications. Registering the same observer reference twice is rejected; separate observer objects are not deduplicated. Each subscription handle refers to one entry, so closing an old handle cannot remove a newer registration.

Expected NotificationException failures are collected as suppressed exceptions on one aggregate exception after remaining observers have been attempted. Unexpected exceptions stop dispatch immediately and propagate. If an unexpected failure occurs after earlier expected failures, that unexpected failure is the propagated outcome; this small implementation does not attach the earlier aggregate to it.

The coordinator stores first, then calls the publisher. Its existing catch handles the aggregate expected failure and retains the record. An unexpected failure escapes after storage. The publisher’s finally block resets a reentrancy guard in either case. The guard is not thread safety or rollback. Callbacks must not mutate the coordinator or start new reservation operations from inside notification.

Zero subscribers yields normal return without any delivery claim. A subscription can be closed repeatedly. Nested publication through the same publisher is rejected. There is no retry, durable event storage, remote messaging or automatic recovery.

2.5 Command contract

The command captures a receiver and request arguments. Required references and identifiers are checked at construction; duration eligibility remains with the receiver at execution. Constructing the command performs no lookup or reservation. execute delegates synchronously on the caller’s thread.

The record’s fields are final, but its receiver is a mutable service. Construction therefore does not capture an availability snapshot or guarantee a later successful reservation. Repeated execution re-enters the service; no deduplication, undo or transactional semantics are supplied.

3. Complete application and example sources

Inherited files are included so the version can be reconstructed independently. New code is additional to the previous version rather than a rewrite of its contracts.

3.1 after/src/main/java/Borrower.java

import java.util.Objects;

public record Borrower(String id, BorrowerCategory category) {
    public Borrower {
        if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
        Objects.requireNonNull(category, "category");
    }
}

3.2 after/src/main/java/BorrowerCategory.java

public enum BorrowerCategory {
    STUDENT, STAFF
}

3.3 after/src/main/java/BorrowerDirectory.java

import java.util.Optional;

/** Empty means absent or inactive, never a provider outage. */
@FunctionalInterface
public interface BorrowerDirectory {
    Optional<Borrower> find(String borrowerId);
}

3.4 after/src/main/java/BorrowingPolicy.java

/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

3.5 after/src/main/java/Chapter04Demo.java

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.6 after/src/main/java/Chapter05Demo.java

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.7 after/src/main/java/Chapter07Demo.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

public final class Chapter07Demo {
    public static void main(String[] args) {
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator reservations = ReservationApplication.create(inbox);
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println("Available before reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        System.out.println(reservations.reserve("B1", "CAM1", day, 2));
        System.out.println("Available after reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        LoanService loans = new LoanService(reservations,
                Clock.fixed(Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        System.out.println("Loan active after checkout: " + loans.checkOut("R1").active());
        System.out.println("Loan active after return: " + loans.returnLoan("R1").active());
        System.out.println("Accepted-reservation notices: " + inbox.received().size());
    }
}

3.8 after/src/main/java/Chapter08Demo.java

import java.time.LocalDate;
import java.util.Map;

public final class Chapter08Demo {
    public static void main(String[] args) {
        var rows = Map.of(
                "B1", new LegacyDirectoryClient.Person("B1", "S", "Y"),
                "B2", new LegacyDirectoryClient.Person("B2", "T", "Y"));
        BorrowerDirectory directory = new LegacyDirectoryAdapter(rows::get);
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var service = new DirectoryReservationService(directory, coordinator);
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(service.reserve("B2", "CAM1", start, 10));
        System.out.println(service.reserve("B1", "MIC1", start, 10));
        System.out.println(service.reserve("UNKNOWN", "MIC1", start, 2));
        System.out.println("Reservations: " + coordinator.reservations().size());
        System.out.println("Notices: " + notices.received().size());
    }
}

3.9 after/src/main/java/Chapter09Demo.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Optional;
import behaviour.LifecycleExamples.StateReservation;

public final class Chapter09Demo {
    public static void main(String[] args) {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(reservation -> events.add("Desk: " + reservation.id()));
        publisher.subscribe(reservation -> { throw new NotificationException("Display offline"); });
        publisher.subscribe(reservation -> events.add("Audit: " + reservation.id()));
        var coordinator = ReservationApplication.create(publisher);
        var service = new DirectoryReservationService(
                id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        Command<String> request = new ReserveEquipmentCommand(
                service, "B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Before execution: " + coordinator.reservations().size());
        System.out.println(request.execute());
        System.out.println("Observer events: " + events);
        System.out.println("Stored reservations: " + coordinator.reservations().size());
        var lifecycle = new StateReservation();
        lifecycle.confirm();
        lifecycle.checkOut();
        lifecycle.returnItem();
        System.out.println("Separate lifecycle exercise: " + lifecycle.status());
    }
}

3.10 after/src/main/java/Command.java

/** Executes synchronously on the caller's thread; no queue or retry is implied. */
@FunctionalInterface
public interface Command<R> {
    R execute();
}

3.11 after/src/main/java/ConfirmationFormatter.java

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

3.12 after/src/main/java/ConsoleNotificationSender.java

import java.io.PrintStream;
import java.util.Objects;

public final class ConsoleNotificationSender implements NotificationSender {
    private final PrintStream output;
    private final ConfirmationFormatter formatter;

    public ConsoleNotificationSender(PrintStream output, ConfirmationFormatter formatter) {
        this.output = Objects.requireNonNull(output, "output");
        this.formatter = Objects.requireNonNull(formatter, "formatter");
    }

    @Override
    public void send(Reservation reservation) {
        output.println("Notice: " + formatter.format(reservation));
        if (output.checkError()) {
            throw new NotificationException("Console output failed.");
        }
    }
}

3.13 after/src/main/java/DirectoryException.java

public final class DirectoryException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public DirectoryException(String message) { super(message); }
    public DirectoryException(String message, Throwable cause) { super(message, cause); }
}

3.14 after/src/main/java/DirectoryReservationService.java

import java.time.LocalDate;
import java.util.Objects;

/** Directory-backed entry point; this does not authenticate the caller. */
public final class DirectoryReservationService {
    private final BorrowerDirectory directory;
    private final ReservationCoordinator coordinator;

    public DirectoryReservationService(BorrowerDirectory directory,
                                       ReservationCoordinator coordinator) {
        this.directory = Objects.requireNonNull(directory, "directory");
        this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
    }

    public String reserve(String borrowerId, String itemId, LocalDate start, int days) {
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
        var found = directory.find(borrowerId);
        if (found.isEmpty()) return "Rejected: borrower not found or inactive.";
        Borrower borrower = found.get();
        if (!borrowerId.equals(borrower.id())) {
            throw new DirectoryException("Directory returned a different borrower");
        }
        return coordinator.reserve(borrower.id(), itemId, start, days, borrower.category());
    }
}

3.15 after/src/main/java/LegacyDirectoryAdapter.java

import java.util.Objects;
import java.util.Optional;

public final class LegacyDirectoryAdapter implements BorrowerDirectory {
    private final LegacyDirectoryClient client;

    public LegacyDirectoryAdapter(LegacyDirectoryClient client) {
        this.client = Objects.requireNonNull(client, "client");
    }

    @Override
    public Optional<Borrower> find(String borrowerId) {
        if (borrowerId == null || borrowerId.isBlank()) {
            throw new IllegalArgumentException("Borrower id required");
        }
        LegacyDirectoryClient.Person row;
        try {
            row = client.lookup(borrowerId);
        } catch (LegacyDirectoryClient.Failure failure) {
            throw new DirectoryException("Borrower directory unavailable", failure);
        }
        if (row == null) return Optional.empty();
        if (!borrowerId.equals(row.key())) throw new DirectoryException("Mismatched borrower id");
        BorrowerCategory category;
        if ("S".equals(row.kind())) category = BorrowerCategory.STUDENT;
        else if ("T".equals(row.kind())) category = BorrowerCategory.STAFF;
        else throw new DirectoryException("Unsupported borrower category");
        if ("N".equals(row.status())) return Optional.empty();
        if (!"Y".equals(row.status())) throw new DirectoryException("Unsupported borrower status");
        return Optional.of(new Borrower(row.key(), category));
    }
}

3.16 after/src/main/java/LegacyDirectoryClient.java

/** Illustrative provider contract, not an actual university API or network client. */
@FunctionalInterface
public interface LegacyDirectoryClient {
    // null means no row; codes are exact and case-sensitive.
    Person lookup(String key);

    record Person(String key, String kind, String status) { }

    final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public Failure(String message) { super(message); }
    }
}

3.17 after/src/main/java/Loan.java

import java.time.LocalDate;
import java.util.Objects;

/** Immutable loan state. A null returnedOn means this loan is active. */
public record Loan(String reservationId, String borrowerId, String itemId,
                   LocalDate borrowedOn, LocalDate returnedOn) {
    public Loan {
        if (reservationId == null || reservationId.isBlank()
                || borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Loan identifiers must not be blank.");
        }
        Objects.requireNonNull(borrowedOn, "borrowedOn");
        if (returnedOn != null && returnedOn.isBefore(borrowedOn)) {
            throw new IllegalArgumentException("Return cannot precede checkout.");
        }
    }
    public boolean active() { return returnedOn == null; }
}

3.18 after/src/main/java/LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

3.19 after/src/main/java/LoanService.java

import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** One service per coordinator/session; sequential, in-memory teaching flow. */
public final class LoanService {
    private final ReservationCoordinator reservations;
    private final Clock clock;
    private final Map<String, Loan> loans = new LinkedHashMap<>();

    public LoanService(ReservationCoordinator reservations, Clock clock) {
        this.reservations = Objects.requireNonNull(reservations, "reservations");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public Loan checkOut(String reservationId) {
        Reservation reservation = reservations.reservations().stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown reservation."));
        if (loans.containsKey(reservationId)) {
            throw new IllegalStateException("Reservation already used for a loan.");
        }
        LocalDate today = LocalDate.now(clock);
        if (today.isBefore(reservation.period().start())
                || !today.isBefore(reservation.period().endExclusive())) {
            throw new IllegalStateException("Checkout must be within the reservation period.");
        }
        boolean itemAlreadyOut = loans.values().stream()
                .anyMatch(loan -> loan.active() && loan.itemId().equals(reservation.itemId()));
        if (itemAlreadyOut) throw new IllegalStateException("Item is already on loan.");
        Loan loan = new Loan(reservation.id(), reservation.borrowerId(),
                reservation.itemId(), today, null);
        loans.put(reservationId, loan);
        return loan;
    }

    public Loan returnLoan(String reservationId) {
        Loan current = loans.get(reservationId);
        if (current == null || !current.active()) {
            throw new IllegalStateException("No active loan for this reservation.");
        }
        Loan returned = new Loan(current.reservationId(), current.borrowerId(),
                current.itemId(), current.borrowedOn(), LocalDate.now(clock));
        loans.put(reservationId, returned);
        return returned;
    }

    public List<Loan> loans() { return List.copyOf(loans.values()); }
}

3.20 after/src/main/java/NotificationException.java

public final class NotificationException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public NotificationException(String message) { super(message); }
}

3.21 after/src/main/java/NotificationPublisher.java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Sequential, in-process Observer example. No retries or durable delivery. */
public final class NotificationPublisher implements NotificationSender {
    @FunctionalInterface
    public interface Observer { void onConfirmed(Reservation reservation); }
    @FunctionalInterface
    public interface Subscription extends AutoCloseable { @Override void close(); }
    private static final class Entry {
        private final Observer observer;
        private Entry(Observer observer) { this.observer = observer; }
    }
    private final List<Entry> entries = new ArrayList<>();
    private boolean publishing;

    public Subscription subscribe(Observer observer) {
        Objects.requireNonNull(observer, "observer");
        if (entries.stream().anyMatch(entry -> entry.observer == observer)) {
            throw new IllegalArgumentException("Observer already subscribed");
        }
        Entry entry = new Entry(observer);
        entries.add(entry);
        return () -> entries.remove(entry);
    }

    @Override
    public void send(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        if (publishing) throw new IllegalStateException("Reentrant publication is not supported");
        publishing = true;
        try {
            NotificationException aggregate = null;
            for (Entry entry : List.copyOf(entries)) {
                try {
                    entry.observer.onConfirmed(reservation);
                } catch (NotificationException failure) {
                    if (aggregate == null) aggregate = new NotificationException("One or more observers failed");
                    aggregate.addSuppressed(failure);
                }
            }
            if (aggregate != null) throw aggregate;
        } finally {
            publishing = false;
        }
    }
}

3.22 after/src/main/java/NotificationSender.java

/**
 * Accept one non-null confirmed reservation for this implementation's local output.
 * Do not modify the reservation or coordinator. Report expected output failure with
 * NotificationException. Normal return is not a promise of email delivery.
 */
@FunctionalInterface
public interface NotificationSender {
    void send(Reservation reservation);
}

3.23 after/src/main/java/RecordingNotificationSender.java

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class RecordingNotificationSender implements NotificationSender {
    private final List<Reservation> received = new ArrayList<>();

    @Override
    public void send(Reservation reservation) {
        received.add(Objects.requireNonNull(reservation, "reservation"));
    }

    public List<Reservation> received() { return List.copyOf(received); }
}

3.24 after/src/main/java/Reservation.java

import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

3.25 after/src/main/java/ReservationApplication.java

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                new ConfirmationFormatter(), sender);
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

3.26 after/src/main/java/ReservationCoordinator.java

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final List<Reservation> reservations = new ArrayList<>();
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : reservations) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        if (hasConflict(itemId, requested)) {
            return "Rejected: item unavailable.";
        }

        Reservation reservation = new Reservation(
                "R" + (reservations.size() + 1), borrowerId, itemId, requested);
        reservations.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        LoanPeriod requested = new LoanPeriod(start, endExclusive);
        return !hasConflict(itemId, requested);
    }

    private boolean hasConflict(String itemId, LoanPeriod requested) {
        for (Reservation existing : reservations) {
            if (existing.itemId().equals(itemId) && existing.period().overlaps(requested)) {
                return true;
            }
        }
        return false;
    }

    public List<Reservation> reservations() {
        return List.copyOf(reservations);
    }
}

3.27 after/src/main/java/ReservationRules.java

public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

3.28 after/src/main/java/ReserveEquipmentCommand.java

import java.time.LocalDate;
import java.util.Objects;

/** Captures a request and receiver; constructing it performs no reservation. */
public record ReserveEquipmentCommand(DirectoryReservationService receiver,
        String borrowerId, String itemId, LocalDate start, int days) implements Command<String> {
    public ReserveEquipmentCommand {
        Objects.requireNonNull(receiver, "receiver");
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
    }
    @Override public String execute() {
        return receiver.reserve(borrowerId, itemId, start, days);
    }
}

3.29 after/src/main/java/StaffReservationRules.java

/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

3.30 after/src/main/java/behaviour/LifecycleExamples.java

package behaviour;

/** Proposed lifecycle only: not integrated with ReservationCoordinator or LoanService. */
public final class LifecycleExamples {
    private LifecycleExamples() { }
    public enum Status { REQUESTED, CONFIRMED, ON_LOAN, RETURNED, CANCELLED }
    public interface Lifecycle {
        Status status();
        void confirm();
        void checkOut();
        void returnItem();
        void cancel();
    }

    // Starting design: operation-specific switches repeat state decisions.
    public static final class ConditionalReservation implements Lifecycle {
        private Status status = Status.REQUESTED;
        public Status status() { return status; }
        public void confirm() {
            switch (status) {
                case REQUESTED -> status = Status.CONFIRMED;
                default -> throw rejected("confirm", status);
            }
        }
        public void checkOut() {
            switch (status) {
                case CONFIRMED -> status = Status.ON_LOAN;
                default -> throw rejected("check out", status);
            }
        }
        public void returnItem() {
            switch (status) {
                case ON_LOAN -> status = Status.RETURNED;
                default -> throw rejected("return", status);
            }
        }
        public void cancel() {
            switch (status) {
                case REQUESTED, CONFIRMED -> status = Status.CANCELLED;
                default -> throw rejected("cancel", status);
            }
        }
    }

    // Completed State design: context delegates to the current state object.
    public static final class StateReservation implements Lifecycle {
        private Phase phase = Phase.REQUESTED;
        public Status status() { return phase.status; }
        public void confirm() { phase = phase.confirm(); }
        public void checkOut() { phase = phase.checkOut(); }
        public void returnItem() { phase = phase.returnItem(); }
        public void cancel() { phase = phase.cancel(); }
    }
    private enum Phase {
        REQUESTED(Status.REQUESTED) {
            @Override Phase confirm() { return CONFIRMED; }
            @Override Phase cancel() { return CANCELLED; }
        },
        CONFIRMED(Status.CONFIRMED) {
            @Override Phase checkOut() { return ON_LOAN; }
            @Override Phase cancel() { return CANCELLED; }
        },
        ON_LOAN(Status.ON_LOAN) {
            @Override Phase returnItem() { return RETURNED; }
        },
        RETURNED(Status.RETURNED),
        CANCELLED(Status.CANCELLED);

        final Status status;
        Phase(Status status) { this.status = status; }
        Phase confirm() { throw rejected("confirm", status); }
        Phase checkOut() { throw rejected("check out", status); }
        Phase returnItem() { throw rejected("return", status); }
        Phase cancel() { throw rejected("cancel", status); }
    }
    private static IllegalStateException rejected(String action, Status status) {
        return new IllegalStateException("Cannot " + action + " in " + status);
    }
}

3.31 after/src/main/java/patterns/PatternExamples.java

package patterns;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Isolated teaching examples; none is wired into the lending application. */
public final class PatternExamples {
    private PatternExamples() { }

    // Factory Method: inherited workflow invokes an overridable creation hook.
    public interface Exporter { String export(String value); }
    public abstract static class ExportJob {
        protected abstract Exporter createExporter();
        public final String run(String value) {
            Objects.requireNonNull(value, "value");
            return createExporter().export(value);
        }
    }
    public static final class PlainJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "TEXT: " + value; }
    }
    public static final class HtmlJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "<p>" + escape(value) + "</p>"; }
    }
    // Simple factory: useful, but no overridden creation hook.
    public static Exporter simpleExporter(boolean html) {
        return html ? value -> "<p>" + escape(value) + "</p>" : value -> "TEXT: " + value;
    }

    // Abstract Factory: two product roles, supplied as a related family.
    public interface Heading { String render(String text); }
    public interface Body { String render(String text); }
    public interface ReceiptFamily {
        Heading heading();
        Body body();
    }
    public static final class PlainFamily implements ReceiptFamily {
        public Heading heading() { return text -> text + "\n"; }
        public Body body() { return text -> text + "\n"; }
    }
    public static final class HtmlFamily implements ReceiptFamily {
        public Heading heading() { return text -> "<h1>" + escape(text) + "</h1>"; }
        public Body body() { return text -> "<p>" + escape(text) + "</p>"; }
    }
    public static String receipt(ReceiptFamily family, String title, String detail) {
        Objects.requireNonNull(family, "family");
        return family.heading().render(title) + family.body().render(detail);
    }
    private static String escape(String text) {
        return Objects.requireNonNull(text, "text").replace("&", "&amp;")
                .replace("<", "&lt;").replace(">", "&gt;");
    }

    // Fluent builder variant: staged configuration, one immutable product.
    public static final class ReportRequest {
        private final String borrowerId;
        private final LocalDate from;
        private final LocalDate toExclusive;
        private final boolean includeReturned;
        private final List<String> columns;
        private ReportRequest(Builder builder) {
            if ((builder.from == null) != (builder.toExclusive == null)) {
                throw new IllegalArgumentException("Both dates or neither are required");
            }
            if (builder.from != null && !builder.from.isBefore(builder.toExclusive)) {
                throw new IllegalArgumentException("Positive interval required");
            }
            borrowerId = builder.borrowerId;
            from = builder.from;
            toExclusive = builder.toExclusive;
            includeReturned = builder.includeReturned;
            columns = List.copyOf(builder.columns);
        }
        public String borrowerId() { return borrowerId; }
        public LocalDate from() { return from; }
        public LocalDate toExclusive() { return toExclusive; }
        public boolean includeReturned() { return includeReturned; }
        public List<String> columns() { return columns; }
        public static Builder forBorrower(String id) { return new Builder(id); }
        public static final class Builder {
            private final String borrowerId;
            private LocalDate from;
            private LocalDate toExclusive;
            private boolean includeReturned;
            private final List<String> columns = new ArrayList<>();
            private Builder(String id) {
                if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
                borrowerId = id;
            }
            public Builder during(LocalDate from, LocalDate toExclusive) {
                Objects.requireNonNull(from, "from");
                Objects.requireNonNull(toExclusive, "toExclusive");
                this.from = from;
                this.toExclusive = toExclusive;
                return this;
            }
            public Builder includeReturned(boolean value) { includeReturned = value; return this; }
            public Builder column(String value) {
                if (value == null || value.isBlank()) throw new IllegalArgumentException("Column required");
                columns.add(value);
                return this;
            }
            public ReportRequest build() { return new ReportRequest(this); }
        }
    }

    // Decorators: same interface, explicit order and expected failure type.
    public interface Sender { void send(String message); }
    public static final class DeliveryFailure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public DeliveryFailure(String message) { super(message); }
    }
    public static final class AuditedSender implements Sender {
        private final Sender delegate;
        private final List<String> events;
        public AuditedSender(Sender delegate, List<String> events) {
            this.delegate = Objects.requireNonNull(delegate, "delegate");
            this.events = Objects.requireNonNull(events, "events");
        }
        public void send(String message) {
            events.add("attempt");
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                events.add("failed");
                throw failure;
            }
            events.add("sent");
        }
    }
    public static final class RetryOnceSender implements Sender {
        private final Sender delegate;
        public RetryOnceSender(Sender delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); }
        public void send(String message) {
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                delegate.send(message);
            }
        }
    }
}

4. Complete test sources

The original tests remain in the build. The four new classes cover this chapter’s declared behaviours.

4.1 after/src/test/java/AvailabilityTddTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTddTest {
    @Test
    void overlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying() {
        // Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator coordinator = ReservationApplication.create(inbox);
        LocalDate start = LocalDate.of(2026, 10, 20);
        coordinator.reserve("B1", "CAM1", start, 2);
        List<Reservation> recordsBefore = coordinator.reservations();
        List<Reservation> noticesBefore = inbox.received();

        // Act: inspect the overlapping interval without requesting a reservation.
        boolean available = coordinator.isAvailable("CAM1", start.plusDays(1), start.plusDays(3));

        // Assert: the answer and both kinds of state matter.
        assertAll(
                () -> assertFalse(available, "An overlapping item must be unavailable"),
                () -> assertEquals(recordsBefore, coordinator.reservations()),
                () -> assertEquals(noticesBefore, inbox.received()));
    }
}

4.2 after/src/test/java/AvailabilityTest.java

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);
    private final RecordingNotificationSender inbox = new RecordingNotificationSender();
    private final ReservationCoordinator c = ReservationApplication.create(inbox);

    @Test void emptyScheduleIsAvailableWithoutAnySideEffect() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(2)));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void adjacentPeriodAndDifferentItemAreAvailable() {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertTrue(c.isAvailable("CAM1", day.plusDays(2), day.plusDays(3)));
        assertTrue(c.isAvailable("MIC1", day, day.plusDays(2)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @ParameterizedTest @ValueSource(ints = {0, -1})
    void invalidPeriodFailsWithoutChangingState(int endOffset) {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertThrows(IllegalArgumentException.class,
                () -> c.isAvailable("CAM1", day, day.plusDays(endOffset)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @Test void malformedIdentifiersAndNullDatesAreRejected() {
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(" ", day, day.plusDays(1)));
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(null, day, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", null, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", day, null));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void queryDoesNotAllocateAnIdentifier() {
        c.isAvailable("CAM1", day, day.plusDays(1));
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-21).",
                c.reserve("B1", "CAM1", day, 1));
    }

    @Test void availabilityDoesNotMeanBorrowerEligibility() {
        for (String item : List.of("CAM1", "MIC1", "TRI1")) c.reserve("B1", item, day, 1);
        assertTrue(c.isAvailable("CAM2", day, day.plusDays(1)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM2", day, 1));
        assertEquals(3, inbox.received().size());
    }

    @Test void queryCanSpanMoreDaysThanAStudentMayReserve() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(30)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 30));
    }
}

4.3 after/src/test/java/BehaviourStrategyTest.java

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class BehaviourStrategyTest {
    // Starting conditional design retained only for comparison in this exercise.
    private boolean conditional(BorrowerCategory category, int count, int days) {
        if (category == BorrowerCategory.STUDENT) {
            return count >= 0 && count < 3 && days >= 1 && days <= 7;
        }
        return count >= 0 && count < 5 && days >= 1 && days <= 14;
    }
    @ParameterizedTest
    @CsvSource({"STUDENT,2,7,true", "STUDENT,3,7,false", "STUDENT,0,8,false",
            "STUDENT,-1,2,false", "STAFF,4,14,true", "STAFF,5,14,false",
            "STAFF,0,15,false", "STAFF,0,0,false"})
    void strategiesAndConditionalMatchIndependentExamples(BorrowerCategory category,
            int count, int days, boolean expected) {
        var policies = Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                BorrowerCategory.STAFF, new StaffReservationRules());
        assertEquals(expected, conditional(category, count, days));
        assertEquals(expected, policies.get(category).mayRequest(count, days));
    }
    @Test void injectedPolicyChangesDecisionWithoutChangingCoordinator() {
        BorrowingPolicy temporaryStaffRule = (count, days) -> count >= 0 && count < 1 && days >= 1 && days <= 2;
        var coordinator = new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(), BorrowerCategory.STAFF, temporaryStaffRule),
                new ConfirmationFormatter(), new RecordingNotificationSender());
        var start = LocalDate.of(2026, 10, 20);
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("T1", "CAM1", start, 3, BorrowerCategory.STAFF));
        assertTrue(coordinator.reserve("T1", "CAM1", start, 2, BorrowerCategory.STAFF).startsWith("Confirmed: R1"));
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("T1", "MIC1", start, 1, BorrowerCategory.STAFF));
        assertTrue(coordinator.reserve("S1", "MIC1", start, 7, BorrowerCategory.STUDENT).startsWith("Confirmed: R2"));
    }
}

4.4 after/src/test/java/BorrowingPolicyTest.java

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BorrowingPolicyTest {
    @ParameterizedTest(name = "{0}: count={1}, days={2} -> {3}")
    @CsvSource({
        "STUDENT, 0, 1, true", "STUDENT, 2, 7, true",
        "STUDENT, 3, 1, false", "STUDENT, 0, 8, false",
        "STAFF, 4, 14, true", "STAFF, 5, 1, false",
        "STAFF, 0, 15, false", "STUDENT, -1, 1, false",
        "STAFF, 0, 0, false", "STAFF, 0, -1, false"
    })
    void documentedBoundaries(BorrowerCategory category, int count, int days, boolean expected) {
        BorrowingPolicy policy = category == BorrowerCategory.STUDENT
                ? new ReservationRules() : new StaffReservationRules();
        assertEquals(expected, policy.mayRequest(count, days));
    }
}

4.5 after/src/test/java/Chapter02Checks.java

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = ReservationApplication.create(new RecordingNotificationSender());
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.6 after/src/test/java/Chapter03AcceptanceExamples.java

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = ReservationApplication.create(new RecordingNotificationSender());
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

4.7 after/src/test/java/Chapter05Checks.java

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = ReservationApplication.create(new RecordingNotificationSender());
        ReservationCoordinator explicitStudent = ReservationApplication.create(new RecordingNotificationSender());
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.8 after/src/test/java/Chapter06Checks.java

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public final class Chapter06Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator c = ReservationApplication.create(inbox);
        String first = c.reserve("B1", "CAM1", day, 2);
        check(first.equals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "accepted return unchanged");
        check(inbox.received().size() == 1, "one notification per accepted request");
        check(inbox.received().get(0) == c.reservations().get(0), "accepted record supplied");
        List<Reservation> oldInbox = inbox.received();
        expect(UnsupportedOperationException.class, oldInbox::clear, "inbox snapshot unmodifiable");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "conflict result preserved");
        check(inbox.received().size() == 1, "conflict sends nothing");
        c.reserve("B1", "MIC1", day, 8);
        check(inbox.received().size() == 1, "duration rejection sends nothing");
        expect(IllegalArgumentException.class, () -> c.reserve("", "CAM1", day, 1), "bad ID");
        check(inbox.received().size() == 1, "invalid argument sends nothing");
        c.reserve("B2", "CAM1", day.plusDays(2), 1);
        check(inbox.received().size() == 2, "adjacent acceptance sends once");
        check(oldInbox.size() == 1, "earlier inbox snapshot stable");

        FailingSender failure = new FailingSender();
        ReservationCoordinator failed = ReservationApplication.create(failure);
        String failureResult = failed.reserve("B1", "CAM1", day, 2);
        check(failureResult.equals(first + " Notification failed; reservation retained."),
                "explicit partial-success result");
        check(failed.reservations().size() == 1, "notification failure retains accepted record");
        check(failure.calls == 1, "one attempt and no automatic retry");
        check(failed.reserve("B1", "CAM1", day, 2).equals("Rejected: item unavailable."),
                "retrying reservation is still a conflict");
        check(failure.calls == 1, "conflicting repeat does not attempt notification");

        ReservationCoordinator unexpected = ReservationApplication.create(new BrokenSender());
        expect(IllegalStateException.class, () -> unexpected.reserve("B1", "CAM1", day, 2),
                "unexpected sender failure is not swallowed");
        check(unexpected.reservations().size() == 1, "record is stored before the unexpected failure");

        Map<BorrowerCategory, BorrowingPolicy> configured = policies();
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                null, new ConfirmationFormatter(), inbox), "null policy map");
        expect(IllegalArgumentException.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules()),
                new ConfirmationFormatter(), inbox), "missing staff policy");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, null, inbox), "null formatter");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, new ConfirmationFormatter(), null), "null sender");
        Map<BorrowerCategory, BorrowingPolicy> mutable = new EnumMap<>(configured);
        ReservationCoordinator copied = new ReservationCoordinator(mutable,
                new ConfirmationFormatter(), new RecordingNotificationSender());
        mutable.put(BorrowerCategory.STUDENT, (count, days) -> false);
        check(copied.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "caller map mutation cannot rewire coordinator");
        Map<BorrowerCategory, BorrowingPolicy> restrictive = new EnumMap<>(configured);
        restrictive.put(BorrowerCategory.STUDENT, (count, days) -> false);
        RecordingNotificationSender restrictedInbox = new RecordingNotificationSender();
        ReservationCoordinator restricted = new ReservationCoordinator(restrictive,
                new ConfirmationFormatter(), restrictedInbox);
        check(restricted.reserve("B1", "CAM1", day, 1).equals(
                "Rejected: borrower limit or duration."), "injected policy is used");
        check(restrictedInbox.received().isEmpty(), "injected rejection sends nothing");

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        ConsoleNotificationSender console = new ConsoleNotificationSender(
                new PrintStream(bytes, true, StandardCharsets.UTF_8), new ConfirmationFormatter());
        console.send(c.reservations().get(0));
        check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: " + first + System.lineSeparator()),
                "console implementation uses supplied stream");
        PrintStream brokenOutput = new PrintStream(new OutputStream() {
            @Override public void write(int value) throws IOException {
                throw new IOException("Expected output failure");
            }
        });
        ConsoleNotificationSender brokenConsole = new ConsoleNotificationSender(
                brokenOutput, new ConfirmationFormatter());
        expect(NotificationException.class, () -> brokenConsole.send(c.reservations().get(0)),
                "console reports expected output failure");
        expect(NullPointerException.class, () -> inbox.send(null), "null notice rejected");
        System.out.println("All " + checks + " Chapter 6 checks passed.");
    }

    private static Map<BorrowerCategory, BorrowingPolicy> policies() {
        return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                      BorrowerCategory.STAFF, new StaffReservationRules());
    }

    private static final class FailingSender implements NotificationSender {
        private int calls;
        @Override public void send(Reservation reservation) {
            calls++;
            throw new NotificationException("Expected teaching failure");
        }
    }

    // A programming defect, not an expected output failure: it must not be swallowed.
    private static final class BrokenSender implements NotificationSender {
        @Override public void send(Reservation reservation) {
            throw new IllegalStateException("Unexpected programming failure");
        }
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action, String label) {
        try { action.run(); }
        catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.9 after/src/test/java/DirectoryAdapterTest.java

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class DirectoryAdapterTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);
    @ParameterizedTest
    @CsvSource({"S,STUDENT", "T,STAFF"})
    void mapsKnownActiveCategories(String code, BorrowerCategory expected) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, code, "Y"));
        assertEquals(new Borrower("B1", expected), adapter.find("B1").orElseThrow());
    }
    @Test void absenceAndInactiveAreEmpty() {
        assertTrue(new LegacyDirectoryAdapter(id -> null).find("B1").isEmpty());
        assertTrue(new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, "S", "N"))
                .find("B1").isEmpty());
    }
    @ParameterizedTest
    @CsvSource({"X,Y", "S,X", "s,Y", "S,y"})
    void unknownCodesAreErrors(String kind, String status) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, kind, status));
        assertThrows(DirectoryException.class, () -> adapter.find("B1"));
    }
    @Test void malformedRowsAreErrors() {
        for (var row : new LegacyDirectoryClient.Person[] {
                new LegacyDirectoryClient.Person("OTHER", "S", "Y"),
                new LegacyDirectoryClient.Person(null, "S", "Y"),
                new LegacyDirectoryClient.Person("B1", null, "Y"),
                new LegacyDirectoryClient.Person("B1", "S", null),
                new LegacyDirectoryClient.Person("B1", "X", "N")}) {
            assertThrows(DirectoryException.class, () -> new LegacyDirectoryAdapter(id -> row).find("B1"));
        }
    }
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {" "})
    void invalidIdentifierDoesNotReachProvider(String id) {
        var calls = new AtomicInteger();
        var adapter = new LegacyDirectoryAdapter(key -> { calls.incrementAndGet(); return null; });
        assertThrows(IllegalArgumentException.class, () -> adapter.find(id));
        assertEquals(0, calls.get());
    }
    @Test void translatesExpectedFailureAndKeepsCause() {
        var original = new LegacyDirectoryClient.Failure("offline");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        var result = assertThrows(DirectoryException.class, () -> adapter.find("B1"));
        assertSame(original, result.getCause());
    }
    @Test void unexpectedProgrammingFailureIsNotDisguisedAsAbsence() {
        var original = new IllegalStateException("bug");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        assertSame(original, assertThrows(IllegalStateException.class, () -> adapter.find("B1")));
    }
    @Test void categoryFromDirectoryControlsReservationPolicy() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var directory = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(
                id, id.equals("STAFF1") ? "T" : "S", "Y"));
        var service = new DirectoryReservationService(directory, coordinator);
        assertTrue(service.reserve("STAFF1", "CAM1", start, 10).startsWith("Confirmed: R1"));
        assertEquals("Rejected: borrower limit or duration.", service.reserve("STUDENT1", "MIC1", start, 10));
        assertEquals(1, coordinator.reservations().size());
        assertEquals("STAFF1", coordinator.reservations().get(0).borrowerId());
        assertEquals(1, notices.received().size());
    }
    @Test void absenceAndOutageDoNotStoreNotifyOrConsumeId() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var missing = new DirectoryReservationService(id -> Optional.empty(), coordinator);
        assertEquals("Rejected: borrower not found or inactive.", missing.reserve("B1", "CAM1", start, 2));
        var failing = new DirectoryReservationService(new LegacyDirectoryAdapter(id -> {
            throw new LegacyDirectoryClient.Failure("offline");
        }), coordinator);
        assertThrows(DirectoryException.class, () -> failing.reserve("B1", "CAM1", start, 2));
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(notices.received().isEmpty());
        var valid = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        assertTrue(valid.reserve("B1", "CAM1", start, 2).startsWith("Confirmed: R1"));
    }
    @Test void mismatchedDomainBorrowerIsRejectedBeforeMutation() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower("OTHER", BorrowerCategory.STAFF)), coordinator);
        assertThrows(DirectoryException.class, () -> service.reserve("B1", "CAM1", start, 10));
        assertTrue(coordinator.reservations().isEmpty());
    }
    @Test void localArgumentErrorsPrecedeLookup() {
        var calls = new AtomicInteger();
        var service = new DirectoryReservationService(id -> { calls.incrementAndGet(); return Optional.empty(); },
                ReservationApplication.create(new RecordingNotificationSender()));
        assertThrows(IllegalArgumentException.class, () -> service.reserve("B1", " ", start, 2));
        assertThrows(NullPointerException.class, () -> service.reserve("B1", "CAM1", null, 2));
        assertEquals(0, calls.get());
    }
}

4.10 after/src/test/java/LegacyRegressionTest.java

import org.junit.jupiter.api.Test;

class LegacyRegressionTest {
    @Test void chapter02() { Chapter02Checks.main(new String[0]); }
    @Test void chapter03() { Chapter03AcceptanceExamples.main(new String[0]); }
    @Test void chapter05() { Chapter05Checks.main(new String[0]); }
    @Test void chapter06() { Chapter06Checks.main(new String[0]); }
}

4.11 after/src/test/java/LoanServiceTest.java

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class LoanServiceTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);

    private ReservationCoordinator reserved() {
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        c.reserve("B1", "CAM1", start, 2);
        return c;
    }

    @Test void checkoutAndReturnUseTheSuppliedClock() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        Loan active = service.checkOut("R1");
        List<Loan> snapshot = service.loans();
        assertThrows(UnsupportedOperationException.class, snapshot::clear);
        assertEquals(start, active.borrowedOn());
        assertTrue(active.active());
        clock.set(Instant.parse("2026-10-21T09:00:00Z"));
        Loan returned = service.returnLoan("R1");
        assertAll(() -> assertFalse(returned.active()),
                () -> assertEquals(start.plusDays(1), returned.returnedOn()),
                () -> assertTrue(snapshot.get(0).active(), "Old immutable snapshot stays active"),
                () -> assertEquals(List.of(returned), service.loans()));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertEquals(List.of(returned), service.loans());
    }

    @ParameterizedTest
    @ValueSource(strings = {"2026-10-19T12:00:00Z", "2026-10-22T00:00:00Z"})
    void checkoutOutsideHalfOpenPeriodIsRejected(String instant) {
        LoanService service = new LoanService(reserved(), Clock.fixed(Instant.parse(instant), ZoneOffset.UTC));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void unknownReservationAndInactiveReturnChangeNothing() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        assertThrows(IllegalArgumentException.class, () -> service.checkOut("R99"));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void aReservationCannotBeCheckedOutTwiceEvenAfterReturn() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        service.checkOut("R1");
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        service.returnLoan("R1");
        List<Loan> before = service.loans();
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertEquals(before, service.loans());
    }

    @Test void unreturnedItemBlocksTheNextAdjacentReservationUntilReturned() {
        ReservationCoordinator c = reserved();
        c.reserve("B2", "CAM1", start.plusDays(2), 1);
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(c, clock);
        service.checkOut("R1");
        clock.set(Instant.parse("2026-10-22T09:00:00Z"));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R2"));
        assertEquals(1, service.loans().size());
        service.returnLoan("R1");
        assertTrue(service.checkOut("R2").active());
    }

    @Test void clockZoneDeterminesTheCheckoutDate() {
        Clock local = Clock.fixed(Instant.parse("2026-10-19T22:30:00Z"), ZoneId.of("Asia/Baghdad"));
        LoanService service = new LoanService(reserved(), local);
        assertEquals(start, service.checkOut("R1").borrowedOn());
    }

    @Test void backwardClockCannotRecordReturnBeforeCheckout() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        service.checkOut("R1");
        List<Loan> before = service.loans();
        clock.set(Instant.parse("2026-10-19T09:00:00Z"));
        assertThrows(IllegalArgumentException.class, () -> service.returnLoan("R1"));
        assertEquals(before, service.loans());
    }

    @Test void returningLoanDoesNotRewriteHistoricalReservationsOrTheirCount() {
        ReservationCoordinator c = reserved();
        LoanService service = new LoanService(c, Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        List<Reservation> before = c.reservations();
        service.checkOut("R1");
        service.returnLoan("R1");
        assertEquals(before, c.reservations());
        assertFalse(c.isAvailable("CAM1", start, start.plusDays(1)));
    }

    static final class AdjustableClock extends Clock {
        private final AtomicReference<Instant> now;
        private final ZoneId zone;
        AdjustableClock(Instant instant) { this(new AtomicReference<>(instant), ZoneOffset.UTC); }
        private AdjustableClock(AtomicReference<Instant> now, ZoneId zone) {
            this.now = now; this.zone = zone;
        }
        void set(Instant instant) { now.set(instant); }
        @Override public ZoneId getZone() { return zone; }
        @Override public Clock withZone(ZoneId zone) { return new AdjustableClock(now, zone); }
        @Override public Instant instant() { return now.get(); }
    }
}

4.12 after/src/test/java/NotificationMockitoTest.java

import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class NotificationMockitoTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);

    @Test void acceptedReservationIsSentOnceWithItsDomainDetails() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        c.reserve("B1", "CAM1", day, 2);
        ArgumentCaptor<Reservation> captured = ArgumentCaptor.forClass(Reservation.class);
        verify(sender).send(captured.capture());
        Reservation sent = captured.getValue();
        assertAll(() -> assertEquals("R1", sent.id()),
                () -> assertEquals("B1", sent.borrowerId()),
                () -> assertEquals("CAM1", sent.itemId()),
                () -> assertEquals(day, sent.period().start()),
                () -> assertEquals(day.plusDays(2), sent.period().endExclusive()));
    }

    @Test void rejectedDurationDoesNotNotify() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 8));
        assertTrue(c.reservations().isEmpty());
        verifyNoInteractions(sender);
    }

    @Test void expectedNotificationFailureRetainsReservationWithoutRetry() {
        NotificationSender sender = mock(NotificationSender.class);
        doThrow(new NotificationException("Expected test failure")).when(sender).send(any(Reservation.class));
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."
                + " Notification failed; reservation retained.", c.reserve("B1", "CAM1", day, 2));
        assertEquals(1, c.reservations().size());
        verify(sender, times(1)).send(any(Reservation.class));
    }
}

4.13 after/src/test/java/NotificationPublisherTest.java

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;

class NotificationPublisherTest {
    private Reservation reservation() {
        return new Reservation("R1", "B1", "CAM1", new LoanPeriod(LocalDate.of(2026,10,20), LocalDate.of(2026,10,22)));
    }
    @Test void sendsInRegistrationOrderWithSameImmutableRecord() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var record = reservation();
        publisher.subscribe(value -> { assertSame(record, value); events.add("desk"); });
        publisher.subscribe(value -> { assertSame(record, value); events.add("audit"); });
        publisher.send(record);
        assertEquals(List.of("desk", "audit"), events);
    }
    @Test void expectedFailuresAreCollectedAndLaterObserversStillRun() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var first = new NotificationException("first");
        var second = new NotificationException("second");
        publisher.subscribe(value -> { throw first; });
        publisher.subscribe(value -> events.add("middle"));
        publisher.subscribe(value -> { throw second; });
        publisher.subscribe(value -> events.add("last"));
        var failure = assertThrows(NotificationException.class, () -> publisher.send(reservation()));
        assertEquals(List.of("middle", "last"), events);
        assertArrayEquals(new Throwable[] {first, second}, failure.getSuppressed());
    }
    @Test void coordinatorRetainsRecordAndReportsExpectedPartialFailure() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(value -> { throw new NotificationException("offline"); });
        publisher.subscribe(value -> events.add(value.id()));
        var coordinator = ReservationApplication.create(publisher);
        var result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 2);
        assertTrue(result.endsWith("Notification failed; reservation retained."));
        assertEquals(1, coordinator.reservations().size());
        assertEquals(List.of("R1"), events);
    }
    @Test void rejectedReservationPublishesNothing() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(value -> events.add(value.id()));
        var coordinator = ReservationApplication.create(publisher);
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 8));
        assertTrue(events.isEmpty());
    }
    @Test void subscriptionChangesTakeEffectOnNextPublication() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var second = new AtomicReference<NotificationPublisher.Subscription>();
        var changed = new AtomicBoolean();
        publisher.subscribe(value -> {
            events.add("first");
            if (changed.compareAndSet(false, true)) {
                second.get().close();
                publisher.subscribe(later -> events.add("third"));
            }
        });
        second.set(publisher.subscribe(value -> events.add("second")));
        publisher.send(reservation());
        publisher.send(reservation());
        assertEquals(List.of("first", "second", "first", "third"), events);
    }
    @Test void duplicateIdentityAndOldSubscriptionHandleAreHandled() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        NotificationPublisher.Observer observer = value -> events.add(value.id());
        var old = publisher.subscribe(observer);
        assertThrows(IllegalArgumentException.class, () -> publisher.subscribe(observer));
        old.close();
        publisher.subscribe(observer);
        old.close();
        publisher.send(reservation());
        assertEquals(List.of("R1"), events);
    }
    @Test void unexpectedFailureStopsDispatchAfterReservationWasStored() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var bug = new IllegalStateException("observer bug");
        publisher.subscribe(value -> events.add("first"));
        publisher.subscribe(value -> { throw bug; });
        publisher.subscribe(value -> events.add("last"));
        var coordinator = ReservationApplication.create(publisher);
        assertSame(bug, assertThrows(IllegalStateException.class,
                () -> coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 2)));
        assertEquals(List.of("first"), events);
        assertEquals(1, coordinator.reservations().size());
    }
    @Test void guardResetsAfterUnexpectedFailure() {
        var publisher = new NotificationPublisher();
        var bad = publisher.subscribe(value -> { throw new IllegalStateException("bug"); });
        assertThrows(IllegalStateException.class, () -> publisher.send(reservation()));
        bad.close();
        assertDoesNotThrow(() -> publisher.send(reservation()));
    }
    @Test void nestedPublicationIsRejectedAndGuardResets() {
        var publisher = new NotificationPublisher();
        var nested = publisher.subscribe(publisher::send);
        assertThrows(IllegalStateException.class, () -> publisher.send(reservation()));
        nested.close();
        assertDoesNotThrow(() -> publisher.send(reservation()));
    }
    @Test void emptyPublisherAndNullArgumentsHaveExplicitOutcomes() {
        var publisher = new NotificationPublisher();
        assertDoesNotThrow(() -> publisher.send(reservation()));
        assertThrows(NullPointerException.class, () -> publisher.send(null));
        assertThrows(NullPointerException.class, () -> publisher.subscribe(null));
    }
}

4.14 after/src/test/java/ReserveEquipmentCommandTest.java

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class ReserveEquipmentCommandTest {
    private final LocalDate start = LocalDate.of(2026,10,20);
    @Test void constructionIsLazyAndExecutionUsesTheCapturedRequest() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var lookups = new AtomicInteger();
        var service = new DirectoryReservationService(id -> {
            lookups.incrementAndGet();
            return Optional.of(new Borrower(id, BorrowerCategory.STUDENT));
        }, coordinator);
        Command<String> command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertThrows(NullPointerException.class, () -> new ReserveEquipmentCommand(null, "B1", "CAM1", start, 2));
        assertThrows(IllegalArgumentException.class, () -> new ReserveEquipmentCommand(service, " ", "CAM1", start, 2));
        assertThrows(IllegalArgumentException.class, () -> new ReserveEquipmentCommand(service, "B1", null, start, 2));
        assertThrows(NullPointerException.class, () -> new ReserveEquipmentCommand(service, "B1", "CAM1", null, 2));
        assertEquals(0, lookups.get());
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(notices.received().isEmpty());
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", command.execute());
        assertEquals(1, lookups.get());
        assertEquals(1, notices.received().size());
    }
    @Test void replayReevaluatesAndDoesNotPromiseTheSameResult() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertTrue(command.execute().startsWith("Confirmed:"));
        assertEquals("Rejected: item unavailable.", command.execute());
        assertEquals(1, coordinator.reservations().size());
        assertEquals(1, notices.received().size());
    }
    @Test void receiverStateAtExecutionDeterminesTheResult() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        coordinator.reserve("OTHER", "CAM1", start, 2);
        assertEquals("Rejected: item unavailable.", command.execute());
        assertEquals(1, coordinator.reservations().size());
    }
    @Test void receiverFailurePropagatesWithoutMutation() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var original = new DirectoryException("offline");
        var service = new DirectoryReservationService(id -> { throw original; }, coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertSame(original, assertThrows(DirectoryException.class, command::execute));
        assertTrue(coordinator.reservations().isEmpty());
    }
}

4.15 after/src/test/java/behaviour/LifecycleExamplesTest.java

package behaviour;

import static org.junit.jupiter.api.Assertions.*;
import static behaviour.LifecycleExamples.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class LifecycleExamplesTest {
    private enum Action { CONFIRM, CHECK_OUT, RETURN, CANCEL }
    private void act(Lifecycle lifecycle, Action action) {
        switch (action) {
            case CONFIRM -> lifecycle.confirm();
            case CHECK_OUT -> lifecycle.checkOut();
            case RETURN -> lifecycle.returnItem();
            case CANCEL -> lifecycle.cancel();
        }
    }
    private void reach(Lifecycle lifecycle, Status status) {
        switch (status) {
            case REQUESTED -> { }
            case CONFIRMED -> lifecycle.confirm();
            case ON_LOAN -> { lifecycle.confirm(); lifecycle.checkOut(); }
            case RETURNED -> { lifecycle.confirm(); lifecycle.checkOut(); lifecycle.returnItem(); }
            case CANCELLED -> lifecycle.cancel();
        }
    }
    @ParameterizedTest
    @CsvSource({
        "REQUESTED,CONFIRM,CONFIRMED,true", "REQUESTED,CHECK_OUT,REQUESTED,false",
        "REQUESTED,RETURN,REQUESTED,false", "REQUESTED,CANCEL,CANCELLED,true",
        "CONFIRMED,CONFIRM,CONFIRMED,false", "CONFIRMED,CHECK_OUT,ON_LOAN,true",
        "CONFIRMED,RETURN,CONFIRMED,false", "CONFIRMED,CANCEL,CANCELLED,true",
        "ON_LOAN,CONFIRM,ON_LOAN,false", "ON_LOAN,CHECK_OUT,ON_LOAN,false",
        "ON_LOAN,RETURN,RETURNED,true", "ON_LOAN,CANCEL,ON_LOAN,false",
        "RETURNED,CONFIRM,RETURNED,false", "RETURNED,CHECK_OUT,RETURNED,false",
        "RETURNED,RETURN,RETURNED,false", "RETURNED,CANCEL,RETURNED,false",
        "CANCELLED,CONFIRM,CANCELLED,false", "CANCELLED,CHECK_OUT,CANCELLED,false",
        "CANCELLED,RETURN,CANCELLED,false", "CANCELLED,CANCEL,CANCELLED,false"})
    void bothImplementationsMatchTransitionTable(Status from, Action action, Status expected, boolean allowed) {
        for (Lifecycle lifecycle : List.of(new ConditionalReservation(), new StateReservation())) {
            reach(lifecycle, from);
            if (allowed) act(lifecycle, action);
            else assertThrows(IllegalStateException.class, () -> act(lifecycle, action));
            assertEquals(expected, lifecycle.status());
        }
    }
    @Test void stateObjectsDoNotShareReservationProgress() {
        var first = new StateReservation();
        var second = new StateReservation();
        first.confirm();
        first.checkOut();
        assertEquals(Status.ON_LOAN, first.status());
        assertEquals(Status.REQUESTED, second.status());
        second.cancel();
        assertEquals(Status.ON_LOAN, first.status());
    }
}

4.16 after/src/test/java/patterns/PatternExamplesTest.java

package patterns;

import static org.junit.jupiter.api.Assertions.*;
import static patterns.PatternExamples.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class PatternExamplesTest {
    @Test void factoryMethodChangesProductWithoutReplacingWorkflow() {
        ExportJob plain = new PlainJob();
        ExportJob html = new HtmlJob();
        assertEquals("TEXT: A&B", plain.run("A&B"));
        assertEquals("<p>A&amp;B</p>", html.run("A&B"));
        assertThrows(NullPointerException.class, () -> html.run(null));
        assertThrows(NullPointerException.class, () -> plain.run(null));
    }
    @Test void creatorInvokesOverriddenHookOnce() {
        var calls = new AtomicInteger();
        ExportJob job = new ExportJob() {
            protected Exporter createExporter() {
                calls.incrementAndGet();
                return value -> "chosen:" + value;
            }
        };
        assertEquals("chosen:R1", job.run("R1"));
        assertEquals(1, calls.get());
    }
    @Test void simpleFactoryCanProduceSameOutputs() {
        assertEquals("TEXT: R1", simpleExporter(false).export("R1"));
        assertEquals("<p>&lt;R1&gt;</p>", simpleExporter(true).export("<R1>"));
    }
    @Test void abstractFactorySuppliesBothProductsInEachFamily() {
        assertEquals("Loan\nR1\n", receipt(new PlainFamily(), "Loan", "R1"));
        assertEquals("<h1>Loan</h1><p>&lt;R1&gt;</p>", receipt(new HtmlFamily(), "Loan", "<R1>"));
    }
    @Test void builderDefaultsAndExplicitOptions() {
        var minimal = ReportRequest.forBorrower("B1").build();
        assertFalse(minimal.includeReturned());
        assertNull(minimal.from());
        assertTrue(minimal.columns().isEmpty());
        var from = LocalDate.of(2026, 10, 20);
        var full = ReportRequest.forBorrower("B1").during(from, from.plusDays(2))
                .includeReturned(true).column("item").build();
        assertEquals("B1", full.borrowerId());
        assertEquals(from, full.from());
        assertEquals(from.plusDays(2), full.toExclusive());
        assertTrue(full.includeReturned());
        assertEquals(List.of("item"), full.columns());
    }
    @Test void builtValueIsIndependentOfReusableBuilder() {
        var builder = ReportRequest.forBorrower("B1").column("item");
        var first = builder.build();
        builder.column("date");
        assertEquals(List.of("item"), first.columns());
        assertEquals(List.of("item", "date"), builder.build().columns());
        assertThrows(UnsupportedOperationException.class, () -> first.columns().add("other"));
    }
    @Test void builderRejectsInvalidConfiguration() {
        var date = LocalDate.of(2026, 10, 20);
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower(" "));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date).build());
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date.minusDays(1)).build());
        assertThrows(NullPointerException.class, () -> ReportRequest.forBorrower("B1").during(date, null));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").column(" "));
    }
    @Test void auditOutsideRetryObservesOneLogicalCall() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new AuditedSender(new RetryOnceSender(flaky), events).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "sent"), events);
    }
    @Test void retryOutsideAuditObservesEachAttempt() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new RetryOnceSender(new AuditedSender(flaky, events)).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "failed", "attempt", "sent"), events);
    }
    @Test void secondExpectedFailurePropagatesWithoutThirdAttempt() {
        var calls = new AtomicInteger();
        var original = new DeliveryFailure("still offline");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(DeliveryFailure.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(2, calls.get());
    }
    @Test void unexpectedFailureIsNotRetried() {
        var calls = new AtomicInteger();
        var original = new IllegalArgumentException("bad message");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(IllegalArgumentException.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(1, calls.get());
    }
}

5. Build and supporting files

Dependency and plugin versions are unchanged; Mockito remains necessary for inherited tests.

5.1 after/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>equipment-lending</artifactId>
  <version>0.9.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

5.2 after/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

mock-maker-proxy

5.3 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
"${CHAPTER_MVN:-mvn}" --batch-mode verify

5.4 after/.gitignore

/target/
/build/
*.class

5.4a after/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps scripts/check.sh in LF form when Git on Windows converts line endings. Without it, a CRLF copy of the script fails under WSL or Linux bash.

5.5 after/README.md

# Chapter 9 checkpoint

Java 17; Maven 3.9.11. Existing JUnit, Mockito and plugin versions remain pinned in pom.xml.

From this directory:

```bash
mvn --batch-mode verify
java -cp target/classes Chapter09Demo
java -cp target/classes Chapter08Demo
java -cp target/classes Chapter07Demo
```

The complete build executes 107 JUnit invocations: 63 inherited, nine Strategy cases, 21 lifecycle cases, ten publisher tests and four Command tests. Four inherited cases wrap the older plain-Java runners.

NotificationPublisher is an optional sequential, local sender implementation. It snapshots subscriptions, preserves registration order, continues after expected NotificationException failures, and propagates unexpected exceptions immediately. It has no retry or durable delivery. Chapter09Demo chooses it explicitly; older demonstrations are unchanged.

Command and ReserveEquipmentCommand demonstrate a captured request calling the existing directory-backed service. Constructing a command does not execute the request.

The behaviour package holds a separate proposed lifecycle, with conditional and State implementations checked against the same transition table. It does not change cumulative reservation counts, availability, cancellation or loan behaviour. The existing borrowing policies already demonstrate Strategy.

All Chapter 8 application and test sources are unchanged. The cumulative system remains sequential and in memory, with no authentication or actual university directory client. The patterns package from Chapter 8 is retained as isolated teaching material.

6. Worked traces and evidence mapping

6.1 Strategy: what varies and what remains fixed

The default application assembly supplies one student strategy and one staff strategy. The coordinator obtains the selected category’s strategy and asks mayRequest(activeCount, requestedDays). It still validates identifiers, counts existing records, detects conflicts and sends the acceptance notice.

BehaviourStrategyTest contains eight parameter rows with independent expected decisions. The values include a student at two records requesting seven days, a student already at three, and staff at four records requesting fourteen days. Negative counts, zero duration and excessive duration are also represented.

The ninth test supplies the temporary staff strategy. Its first three-day request is rejected before storage; a two-day request becomes R1; another request for the same staff borrower is rejected by the one-record maximum. A student’s separate seven-day request still succeeds under the unchanged student strategy. This trace shows a configuration change with no edit to coordinator logic.

For this particular count/duration problem, one LimitPolicy(maxCount, maxDays) could be a valid alternative. It would replace duplicated arithmetic with data while retaining the interface. Choose separate algorithms only when their behaviour has a reason to vary independently. The current chapter recognises the existing structure rather than adding a class solely to make the pattern visible.

6.2 State: the before/after comparison

Both lifecycle implementations expose the same five observations/operations: status, confirm, checkOut, returnItem and cancel. The starting conditional class selects behaviour inside each operation. The completed class selects behaviour through its current Phase object.

For REQUESTED then confirm, both end CONFIRMED. For CONFIRMED then cancel, both end CANCELLED. For ON_LOAN then cancel, both throw and remain ON_LOAN. These examples come from the reviewed table, not from reading what either implementation happens to do.

Each of the 20 parameter rows constructs fresh contexts, reaches the specified starting status using public operations, applies one action and checks the expected result. The same row checks both implementations, but it is still one JUnit invocation. The separate independence test brings the new lifecycle total to 21 invocations.

The Phase constants contain no mutable reservation fields. The context assigns phase = phase.operation() only after a successful return. Unsupported actions throw before assignment. A state implementation that mutates unrelated external storage before throwing would need additional protection; this exercise has no such side effects.

6.3 Observer: partial success and retained failures

The demonstration registers desk, failing display and audit observers. The accepted reservation is stored before publication. Desk records R1; display throws an expected failure; audit still records R1. The publisher then throws an aggregate failure, which the coordinator handles using its existing “reservation retained” message.

For direct publisher diagnostics, expectedFailuresAreCollectedAndLaterObserversStillRun registers two expected failures interleaved with successful observers. The aggregate’s suppressed exceptions contain the two original exception objects in dispatch order. This preserves their details without discarding later callback attempts.

The coordinator currently returns only its established confirmation string and suffix. It does not expose the aggregate or implement an operational diagnostics service. That limitation should be explicit if the example is proposed for a real application.

Unexpected errors follow a different path. The relevant test runs one successful observer, then an observer throwing IllegalStateException, then a final observer. The exception propagates; only the first event is recorded; the reservation still exists. The implementation does not pretend to roll back either the reservation or the successful earlier callback.

6.4 Observer lifetime and the snapshot rule

During the first publication in subscriptionChangesTakeEffectOnNextPublication, the first observer removes the second and adds a third. The snapshot already includes the second, so it still runs. The next publication observes the updated registrations. The complete history is:

first, second, first, third

The snapshot rule is a declared contract, not an incidental result of ArrayList iteration. Iterating a mutable list directly would make callback-time changes unsafe or ambiguous.

The registration entry object also matters. An old subscription is closed, the same observer object is registered again, and the old handle is closed a second time. The newer registration survives because the handles refer to different entry objects. An implementation that removed by observer identity on every close could incorrectly remove that new registration.

The same observer reference cannot be concurrently registered twice in this sequential model. Creating two separate but equivalent callbacks is still possible; there is no domain-recipient deduplication. Subscription ownership and delivery semantics should not be confused.

6.5 Command: three moments to inspect

Construction: the record receives its receiver and arguments. No directory lookup or notification happens. The constructor rejects missing required values, but does not decide borrowing eligibility.

Execution: the directory-backed service obtains the category and the coordinator applies current count/duration and conflict rules. If another reservation appeared after construction, the command can now be rejected.

Repeated execution: the same command is invoked again. In the supplied example, the previously stored reservation causes a conflict and the result differs from the first execution. One record and one notice remain. This is re-evaluation through the receiver, not cached completion or an idempotency protocol.

The receiver-failure test keeps the same thrown DirectoryException object and verifies no reservation was created. The command adds no catch block that would hide that outcome.

6.6 Evidence map

BehaviourNew test evidence
Independent strategy boundary decisionsEight rows of strategiesAndConditionalMatchIndependentExamples
Alternate policy without coordinator editsinjectedPolicyChangesDecisionWithoutChangingCoordinator
Every selected lifecycle status/action combinationTwenty rows of bothImplementationsMatchTransitionTable
Independent context progressstateObjectsDoNotShareReservationProgress
Observer order and immutable shared eventsendsInRegistrationOrderWithSameImmutableRecord
Continue after expected failures; retain originalsexpectedFailuresAreCollectedAndLaterObserversStillRun
Coordinator keeps acceptance after expected failurecoordinatorRetainsRecordAndReportsExpectedPartialFailure
No publication after reservation rejectionrejectedReservationPublishesNothing
Callback-time registration changessubscriptionChangesTakeEffectOnNextPublication
Duplicate observer and old handle behaviourduplicateIdentityAndOldSubscriptionHandleAreHandled
Unexpected failure has partial effectsunexpectedFailureStopsDispatchAfterReservationWasStored
Guard recovery and nested publicationThe two guard tests in NotificationPublisherTest
Empty publication and invalid argumentsemptyPublisherAndNullArgumentsHaveExplicitOutcomes
Lazy command and captured parametersconstructionIsLazyAndExecutionUsesTheCapturedRequest
Replay and changed receiver stateThe two execution-state tests in ReserveEquipmentCommandTest
Unhidden receiver failurereceiverFailurePropagatesWithoutMutation

7. Intentionally wrong alternatives for diagnosis

These are deliberately incorrect fragments or proposals, not extra source files for the active build. If experimenting, use a disposable copy and restore the correct source before the full gate. The cancel-on-loan alternative (7.2) and the stop-on-first-failure alternative (7.3) were applied and run, and both were rejected by the tests named below. The setter in 7.1 cannot be rejected by a runtime test, as explained there.

7.1 Invalid lifecycle jump

// WRONG as a public escape hatch around reviewed transitions:
public void setStatus(Status requestedStatus) {
    status = requestedStatus;
}

A caller can move directly from REQUESTED to RETURNED or resurrect a CANCELLED record. Hiding the status behind a setter still leaves the invariant unenforced. Remove the setter and expose reviewed operations. Tests should attempt checkout before confirmation, cancellation while on loan and repeated return, verifying both the exception and unchanged status.

The final State implementation does not expose a setter at all. Its private phase changes through state operations. Compile-time API review therefore complements runtime transition tests.

7.2 Incorrect terminal transition

// WRONG proposed State behaviour: permits cancelling equipment already on loan.
@Override Phase cancel() { return CANCELLED; }

If added to ON_LOAN, this contradicts the ON_LOAN/CANCEL row. The existing table expects an exception and ON_LOAN afterwards. Changing the expected result to CANCELLED would be a requirement change, not a repair to the test.

7.3 Stop on the first expected observer failure

// WRONG for the selected continuation contract:
for (Entry entry : List.copyOf(entries)) {
    entry.observer.onConfirmed(reservation);
}

This can be a valid publisher design under a different fail-fast contract, but it is incorrect here because an expected failure prevents later observers from running. The continuation test expects the middle and last callbacks and both original expected failures. State the intended failure policy before judging the structure.

7.4 Reserve while constructing the command

// WRONG inside the request constructor for this chapter's lazy-action contract:
receiver.reserve(borrowerId, itemId, start, days);

This performs lookup and side effects before execute. The lazy-construction test checks that there are no lookups, records or notices at that point. A command constructor should capture the request; the receiver performs the use case on execution.

8. Practical activity solution and design reasoning

8.1 Suggested State refactoring sequence

Begin with the complete transition table and ConditionalReservation. Run the table tests to establish what is being preserved. Introduce private State objects that implement the four operations, supplying rejection by default and overrides only for permitted transitions. Make the context delegate to its current state and accept a returned successor only on success.

Run the same table against both implementations. Add the independent-context check to guard against placing progress inside shared state objects. Inspect the public API for a setter that could bypass transitions. Keep the before implementation available for comparison rather than describing an unrecorded refactoring history as a completed TDD sequence.

The supplied files embody this before/after design comparison. The author’s process is ordinary implementation and verification; it is not presented as a recorded red–green–refactor history.

A reasoned conclusion may still favour the conditional design for the small model. The exercise demonstrates State and its costs; it does not prove that every five-state workflow needs state objects.

8.2 A second decision: adopt Observer selectively

Problem: several local components need independent reactions after a reservation is accepted, and their registrations can change. Embedding every recipient in the coordinator would mix reservation decisions with the recipient set.

Solution: an optional publisher implements the existing sender interface. It owns ordered subscription snapshots, invokes callbacks and applies the declared expected-failure continuation rule. The demonstration supplies it explicitly to the usual application assembly.

Consequences: the coordinator remains independent of observer identities. Publication can have partial effects, observers require lifetime management, and unexpected failures still escape after storage. This does not provide durable delivery, rollback or real audit persistence.

Simpler alternative: retain one directly injected sender for a single fixed output. If several mandatory steps form one business operation, consider explicit orchestration rather than hiding the sequence in observer registration.

Revisit when: actual durability, concurrency or recipient-lifecycle requirements are agreed. Do not turn the classroom publisher into broker infrastructure by assumption.

8.3 Decline a pattern with a concrete reason

A direct reservation button handler can call the service if it has no need to carry the request separately. Introducing Command adds a type without a demonstrated benefit in that case. If a UI invoker must receive different actions through one contract, a command or lambda can become useful.

Likewise, one count/duration formula parameterised by two limits may be sufficient for borrowing policies. Separate Strategy classes are useful when rules deserve independent implementations, but parameterisation can be the simpler solution when only values differ.

Explain which change each alternative makes easier and which obligation it adds. Naming the alternatives is insufficient without a decision about the actual requirement.

8.4 Optional real cancellation proposal

Before integrating the lifecycle exercise, clarify whether cancellation is allowed after confirmation, how it changes availability and borrower counts, whether it affects an active loan, and what should happen to existing notifications. Define identifier retention and audit/history requirements. Decide whether repeated cancellation is rejected or treated as an idempotent acknowledgement.

Then map those choices to ReservationCoordinator, LoanService, snapshots, count calculations, availability checks and tests. The current size-based identifier allocation assumes records are not removed. Deleting a cancelled reservation without revisiting identifiers could therefore introduce another defect.

This proposal is an analysis exercise. The provided version implements none of those lifecycle changes, so all earlier reservation and loan rules remain testable as before.

9. AI activity solution

9.1 Compare the two lifecycle proposals

Proposal A exposes policy selection to callers without a transition authority. A caller can choose the returned policy even when no checkout occurred. Labelling the classes strategies does not solve the requirement about legal histories.

Proposal B places the current phase in the context and restricts changes to reviewed actions. It is the stronger match for this lifecycle, provided its actual implementation rejects invalid transitions and preserves state after failure. Its more appropriate wording is not sufficient evidence; use the table and API inspection.

For category-based count/duration decisions, the pressure changes. Student and staff are selected policies, not stages through which a reservation progresses. Strategy or a parameterised policy fits that requirement without inventing transitions between borrower categories.

9.2 Reliability claim

Observer does not guarantee that every callback completes. The expected-failure policy continues after declared failures but still reports partial success. An unexpected failure stops later callbacks. Snapshot iteration and one attempt per entry do not establish durable or exactly-once delivery.

Command does not make replay safe. Repeated execution re-enters the receiver and can produce a different result or additional effects in other use cases. The supplied conflict check happens to stop a second identical booking, but it is not a general deduplication mechanism. Undo and compensation need separate agreed semantics.

9.3 Evidence to retain

For each AI claim, record the relevant requirement, the claimed structure, the confirming or rejecting test, and any remaining limitation. A useful entry is: “Model claims cancellation is legal from any state; transition table forbids ON_LOAN/CANCEL; test requires an exception and unchanged ON_LOAN; proposal rejected.”

The provided example is author-written fictional text. Do not report it as an actual response from an AI provider. If conducting a live activity, keep the relevant real output and verify it independently using synthetic data.

10. Review answers and transfer solution

10.1 Concept answers

1. Strategy versus State: category determines which count/duration policy to apply; that choice does not itself advance the reservation lifecycle. State makes the meaning or legality of an operation depend on the context’s current position and permitted next positions. Similar delegation structures can serve different purposes.

2. Shared states, separate progress: the enum constants contain only behaviour and a fixed status value. Each reservation context owns its current reference. Putting mutable reservation data in a shared constant would couple contexts. The independence test advances one context and observes the other still at REQUESTED.

3. Snapshot removal: an observer present when publication begins still receives that publication even if a prior callback closes its subscription. It is absent from the next snapshot. This rule is explicit; a different dispatch design could choose different semantics but would need different tests.

4. Captured command: it stores the receiver and request arguments, not a snapshot of receiver state or an advance permission to reserve. Lookup, eligibility and conflicts are evaluated when execute calls the service. Construction performs no use-case side effect.

10.2 Applied diagnosis answers

5. Unexpected observer failure: the coordinator stores before publishing. Earlier callbacks may have completed, so the thrown exception is compatible with a stored reservation and partial external effects. In this local model inspect the reservation snapshot and known event history before choosing recovery. Do not blindly repeat on the assumption that nothing happened; the repeated call may reject while earlier effects remain. A real application would need a reviewed way to identify the original operation and its outcome.

6. Unrestricted setter: it permits state changes that bypass the transition table. Remove or tightly control that mutation path. Check REQUESTED/check-out, CONFIRMED/return, ON_LOAN/cancel and repeated return; each rejected action must preserve status. Add valid sequences and independent contexts so the implementation cannot satisfy negative cases merely by rejecting everything. API inspection must also confirm that callers cannot force a terminal status directly.

10.3 Document-workflow transfer

A department-specific review algorithm is a Strategy candidate if its contract is stable and different implementations are needed. Draft/approved/published behaviour is a State candidate if actions and transitions depend on lifecycle position. Local views reacting to publication may use Observer. A UI-held publish request may use Command if the request needs a separate representation.

Do not require all four. A small transition table can stay conditional, one fixed view can be a direct call, and a single immediate action can invoke the service directly.

One possible invalid transition is publishing a draft before approval. It must leave the document a draft. Clarify whether revisions invalidate approval before implementing the rule. One observer failure case is a local preview failing after publication state was stored; decide whether remaining observers still run and what the caller sees. One stale-command case is a publish request captured while approved but executed after an edit returns the document to draft; the receiver must check the current state and reject if approval is now required.

Clarify who can approve, whether a published document can be withdrawn, how review policy is selected, and whether notifications are informational or mandatory steps. Those decisions come before selecting classes.

11. Instructor guidance and critical reading

Use the existing policy code to show that a design can have a pattern role before anyone gives it that label. Compare it with the conditional example and ask when parameterisation would be simpler. Then put the lifecycle table before both implementations and trace one legal and one illegal action.

For Observer, predict the demonstration transcript first. Ask whether the failed display means the desk and audit also failed, and whether the reservation exists. Run the unexpected-failure test as a contrasting declared behaviour, not as a claim that the system automatically recovers.

For Command, pause after construction and inspect the empty state. Execute once, then repeat. Ask students to separate the captured arguments from the evolving receiver state. Do not introduce queue infrastructure or CQRS to make the example appear more sophisticated.

The module descriptor places the critical book review in Week 8. Refer students to the module leader’s approved reading list and submission instructions. The student chapter refers to Appendix E, which develops the review method, and includes a short reading point that stands on its own. The review’s own requirements (length, additional sources, reflection on AI-enabled systems) are in the current assignment brief, and the chapter points to that brief and does not restate it.

A review should identify an actual author claim, locate it precisely, examine the example and evaluate a limitation or alternative using verified evidence. An AI summary of an unread book is not source-based critical reading. This guidance adds no marking rubric, weight or new deadline.

12. Fault experiment and limits

12.1 Do the tests detect faults?

Eighteen faults were introduced one at a time into copies of the project, and each copy was run through mvn test. All eighteen are caught.

AreaFaults triedResult
ObserverNo snapshot (live iteration); no reentrancy guard; guard never reset; stop at the first expected failure (7.3); unexpected failures swallowed; duplicate registration allowed; an old handle removing a newer registration; suppressed failures dropped; reverse dispatch order; null reservation acceptedAll ten caught by NotificationPublisherTest
CommandReserves while constructing (7.4); borrower and item swappedBoth caught
CommandConstruction validation removedCaught by a test that constructs a command with a blank identifier, a null receiver and a null start date. Without such a test the fault would survive, because nothing else exercises construction validation
CommandExecutes with one extra dayCaught by asserting the exact confirmation, period included. A check that only tests startsWith("Confirmed: R1 for B1 on CAM1") would miss it, because that is true for any duration
LifecycleOn-loan reservation may be cancelled (7.2, in State and in the conditional); a returned reservation may be cancelled; progress shared between contextsAll four caught by the transition table and the independence test

For Observer, all ten selected faults, including the subtle ones about handle ownership and the guard, were caught. That is an observation about these ten faults only; the fault sets in different chapters differ, so the results do not rank the quality of one chapter’s tests against another’s. The two Command gaps were the same kind of weakness as earlier survivors: a claim made in prose that no assertion checks.

Each lifecycle table row exercises both implementations, but it remains one reported parameterised test invocation. Four inherited JUnit cases invoke earlier plain-Java runners whose own counts remain distinct: 34 Chapter 2 checks, five Chapter 3 example groups, 44 Chapter 5 checks and 28 Chapter 6 checks.

The table does not establish concurrent safety, durable event delivery, authentication, transactionality or arbitrary command replay safety. One acceptance condition cannot be checked by a runtime test: that the State example has no public setter. That is established by reading the API, as Section 7.1 says, and a reflection test for it would be brittle.

The Mockito dynamic-agent warning described in Chapter 7 also appears in this project’s log on a recent JDK. It does not affect results, and -DargLine=-XX:+EnableDynamicAgentLoading removes it.

References

Sources checked 12 and 13 October 2026. The student chapter provides the bibliographic distinction and the wider reading context.

  1. Gamma, E., Helm, R., Johnson, R. and Vlissides, J., Design Patterns: Elements of Reusable Object-Oriented Software. Publisher catalogue.
  2. Oracle, Java SE 17 PropertyChangeSupport, Throwable, and Runnable.
  3. JUnit Team, JUnit 5.13.4 User Guide.
  4. Martin, R. C. (2002), Agile Software Development: Principles, Patterns, and Practices, Prentice Hall. The module’s required text; the Week 8 reading is its Strategy, Command, Observer and State chapters. Publication details as listed in the module descriptor.

Chapter 10 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 10 Companion: Code, Explanations and Solutions.

From Classes to Application Structure
OO Development and Agile Practices

1. Scope and execution

This file accompanies Chapter 10, From Classes to Application Structure. It contains the complete restructured version, migration instructions, an architecture decision, tests and model solutions. The starting implementation is the Chapter 9 companion.

The change introduces named application packages, a focused reservation repository boundary and a thin console adapter. It preserves the demonstrated use-case outcomes. Package names, imports, constructor wiring and launcher names change; source and binary compatibility with the unnamed-package version are not claimed.

1.1 Reconstruct a clean version

Create a new directory named after. Save every labelled file listing in Sections 3–5 at its indicated path, including .gitattributes and tools/PackageMetrics.java. Copy only fence contents, preserving case and creating intermediate directories. README and ADR listings use four backticks where appropriate. Start with a clean directory: leaving the old unnamed-package source files beside the migrated files would create a misleading mixed version.

Use a full Java 17 JDK, including the jdk.jdeps module, and Maven 3.9.11. JUnit Jupiter 5.13.4, Mockito 5.20.0, Compiler Plugin 3.14.1 and Surefire 3.5.4 remain pinned. The Maven project version becomes 0.10.0. Initial builds require dependency access.

From inside after:

mvn --batch-mode verify
java -cp target/classes edu.se506.lending.bootstrap.Chapter10Demo

The existing check wrapper remains available:

bash scripts/check.sh

The dependency check is already part of the normal test run. To examine class dependencies directly:

java --module jdk.jdeps/com.sun.tools.jdeps.Main -verbose:class -filter:none target/classes

This command reports dependencies; DependencyRulesTest applies this project’s selected rules to them. A full JDK is required even if a smaller runtime could launch the application.

1.2 Expected output and earlier launchers

The new demonstration prints the same five lines as Chapter 8’s example, now through a thin console adapter:

Confirmed: R1 for B2 on CAM1 [2026-10-20, 2026-10-30).
Rejected: borrower limit or duration.
Rejected: borrower not found or inactive.
Reservations: 1
Notices: 1

Earlier demonstrations now use fully qualified names:

java -cp target/classes edu.se506.lending.bootstrap.Chapter07Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter08Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter09Demo

Chapter04Demo, Chapter05Demo and ReservationApplication are likewise in edu.se506.lending.bootstrap. The old command java -cp target/classes Chapter09Demo no longer names the migrated class. This is a documented launcher change, not a failed preservation of the demo’s business output.

1.3 Package metrics

The metrics in Section 10.5 of the chapter come from a small program that is not part of the Maven build. After compiling, run it from the project root:

mvn --batch-mode compile
java tools/PackageMetrics.java target/classes

It starts the JDK’s jdeps analyser, so it needs the same full JDK as the dependency test. It prints one row per package:

package                                          classes   Ca   Ce         I
behaviour                                              1    1    0      0.00
edu.se506.lending.application                          6    9    4      0.31
edu.se506.lending.application.port                     5   14    3      0.18
edu.se506.lending.bootstrap                            7    0    7      1.00
edu.se506.lending.domain                               8   16    0      0.00
edu.se506.lending.infrastructure.directory             2    2    1      0.33
edu.se506.lending.infrastructure.notification          3    7    3      0.30
edu.se506.lending.infrastructure.persistence           1    1    1      0.50
edu.se506.lending.presentation                         1    1    1      0.50
examples.structure                                     1    0    0 undefined
patterns                                               1    0    0 undefined

The counting rules are in the program’s header comment and the chapter: classes are the unit, a nested class counts with its outer class, only this project’s own packages count, and the standard library is ignored. Ca is the number of classes outside a package that depend on it, Ce the number of its classes that depend on classes outside it, and I = Ce / (Ca + Ce). When Ca + Ce is zero the program prints undefined instead of guessing. Other tools count differently, so compare numbers only from one tool.

2. Migration and design map

2.1 Package mapping

All package names in this table are beneath edu.se506.lending unless otherwise stated.

DestinationTypes moved or introduced
domainBorrowerCategory, BorrowingPolicy, ReservationRules, StaffReservationRules, LoanPeriod, Reservation, Borrower, Loan
applicationConfirmationFormatter, ReservationCoordinator, DirectoryReservationService, LoanService, Command, ReserveEquipmentCommand
application.portBorrowerDirectory, NotificationSender, NotificationException, DirectoryException; new ReservationRepository
infrastructure.directoryLegacyDirectoryClient, LegacyDirectoryAdapter
infrastructure.notificationRecordingNotificationSender, ConsoleNotificationSender, NotificationPublisher
infrastructure.persistenceNew InMemoryReservationRepository
presentationNew ReservationConsole
bootstrapReservationApplication; Chapters 4, 5, 7, 8, 9 demos; new Chapter10Demo
regression in test sourcesAll formerly unnamed-package tests and legacy check runners
structure in test sourcesRepositoryBoundaryTest, ValueSemanticsTest, ReservationConsoleTest, DependencyRulesTest
Existing top-level patterns and behaviour packagesEarlier isolated examples and their tests, unchanged
New top-level examples.structurePeriodValue equality demonstration, not part of the running domain

Expected-failure types belong to the application ports because callers should not need provider-specific exception types. The adapter still translates the provider’s declared failure to the application-facing exception.

2.2 Constructor and storage change

Previously, ReservationCoordinator owned an ArrayList directly and accepted policies, formatter and sender. It now accepts those three dependencies plus a ReservationRepository as its fourth argument. Its scans use repository snapshots, insertion uses add, and reservations() returns the repository’s specified snapshot.

ReservationApplication.create(sender) constructs a fresh InMemoryReservationRepository and passes it to the coordinator. Direct coordinator constructions in the earlier checks and Strategy example are updated with the same explicit adapter. Their assertions and expected outcomes remain intact.

No compatibility constructor instantiates infrastructure inside the application package. Such a shortcut would undermine the dependency boundary being demonstrated. Consumers update construction or use the factory in its new bootstrap package.

The repository contract is append-only, insertion ordered, and keyed by explicit reservation ID. Duplicate IDs reject without replacement. Null addition rejects. Snapshot values are safe here because the Reservation graph is immutable. The port does not impose a generic CRUD lifecycle or promise database transaction semantics.

2.3 Supported ownership and preserved semantics

The factory supplies one new, empty repository for one sequential coordinator/session. Size-based R1, R2 allocation is retained. Arbitrary preloaded stores, deletion, multiple writers or shared independent loan services would need additional contracts and are not promised by introducing constructor injection.

Storage still precedes notification. A repository that throws before adding does not trigger notification. A future implementation that writes and then loses acknowledgement presents a different failure case; this chapter does not claim to solve it.

The existing running LoanPeriod remains immutable but keeps inherited Java object equality. PeriodValue separately demonstrates record equality and hashing. No entity lifecycle, policy limit, availability rule or cancellation behaviour changes during this restructuring.

2.4 Declared production dependency rules

Source packagePermitted local targets
DomainDomain
Application portsDomain and application ports
Application servicesDomain and application, including ports
InfrastructureDomain, application and infrastructure
PresentationDomain, application and presentation
BootstrapAll application packages and explicitly demonstrated isolated examples

Ordinary production layers cannot depend on patterns, behaviour or examples. Bootstrap may launch an isolated demonstration; test setups may assemble whatever they need. The gate examines compiled production classes, not test classes. Standard-library dependencies are outside this local package matrix.

The test catches local bytecode dependency edges, including fully qualified references. A second test builds the package-to-package graph from the same edges and fails if it contains a cycle; the matrix alone would let two infrastructure packages refer to each other. The cycle check looks at the lending packages only. It is not an enforcement of all possible third-party-library policies, reflection, configuration or semantic responsibility. The permitted infrastructure-to-application edge accommodates ports and the retained shared ConfirmationFormatter.

2.5 Assertions and fixes carried forward from Chapters 3–9

Several strengthened checks do not change the JUnit count, so a green total cannot show that a migration kept them. This table lists what must survive, and where it lives in this version.

OriginPreserved itemLocation in this version
Chapter 3The AC-03 request that ends exactly where an existing reservation starts, as well as the request that starts where one ends (five acceptance groups)regression/Chapter03AcceptanceExamples.java
Chapter 4.gitattributes keeping scripts/check.sh in LF form.gitattributes
Chapter 6BrokenSender: an unexpected sender failure is not swallowed and the record is stored first (28 checks)regression/Chapter06Checks.java
Chapters 6–8serialVersionUID on NotificationException, DirectoryException, LegacyDirectoryClient.Failure and PatternExamples.DeliveryFailurethose four files
Chapter 7assertThrows(UnsupportedOperationException.class, snapshot::clear) on the loan snapshotregression/LoanServiceTest.java (checkoutAndReturnUseTheSuppliedClock)
Chapter 8plain.run(null) throws NullPointerExceptionpatterns/PatternExamplesTest.java
Chapter 9Command construction rejects a null receiver, a blank borrower, a null item and a null start, and the confirmation is compared exactly, period includedregression/ReserveEquipmentCommandTest.java

To check a migration yourself, compare each migrated file with its Chapter 9 original, ignoring package and import lines. In this version the only differences are the repository argument added to coordinator constructions (in BehaviourStrategyTest, Chapter06Checks and ReservationApplication) and the coordinator’s own storage code.

2.6 What the package metrics do and do not show

The table in Section 1.3 is a measurement of the compiled version, not a judgement. With one to eight classes per package a single added class moves a ratio by several hundredths. The one arrow that points to a slightly less stable package (infrastructure.notification at 0.30 to application at 0.31) is the concrete ConfirmationFormatter dependency that Section 6.2 already describes. The isolated packages patterns and examples.structure have no local references in either direction, so their instability is undefined; behaviour is referenced by Chapter09Demo, so Ca = 1 and I = 0.

3. Complete production and isolated example sources

The following files reconstruct the complete version. Package declarations and directory paths are part of each listing and must be preserved.

3.1 after/src/main/java/behaviour/LifecycleExamples.java

package behaviour;

/** Proposed lifecycle only: not integrated with ReservationCoordinator or LoanService. */
public final class LifecycleExamples {
    private LifecycleExamples() { }
    public enum Status { REQUESTED, CONFIRMED, ON_LOAN, RETURNED, CANCELLED }
    public interface Lifecycle {
        Status status();
        void confirm();
        void checkOut();
        void returnItem();
        void cancel();
    }

    // Starting design: operation-specific switches repeat state decisions.
    public static final class ConditionalReservation implements Lifecycle {
        private Status status = Status.REQUESTED;
        public Status status() { return status; }
        public void confirm() {
            switch (status) {
                case REQUESTED -> status = Status.CONFIRMED;
                default -> throw rejected("confirm", status);
            }
        }
        public void checkOut() {
            switch (status) {
                case CONFIRMED -> status = Status.ON_LOAN;
                default -> throw rejected("check out", status);
            }
        }
        public void returnItem() {
            switch (status) {
                case ON_LOAN -> status = Status.RETURNED;
                default -> throw rejected("return", status);
            }
        }
        public void cancel() {
            switch (status) {
                case REQUESTED, CONFIRMED -> status = Status.CANCELLED;
                default -> throw rejected("cancel", status);
            }
        }
    }

    // Completed State design: context delegates to the current state object.
    public static final class StateReservation implements Lifecycle {
        private Phase phase = Phase.REQUESTED;
        public Status status() { return phase.status; }
        public void confirm() { phase = phase.confirm(); }
        public void checkOut() { phase = phase.checkOut(); }
        public void returnItem() { phase = phase.returnItem(); }
        public void cancel() { phase = phase.cancel(); }
    }
    private enum Phase {
        REQUESTED(Status.REQUESTED) {
            @Override Phase confirm() { return CONFIRMED; }
            @Override Phase cancel() { return CANCELLED; }
        },
        CONFIRMED(Status.CONFIRMED) {
            @Override Phase checkOut() { return ON_LOAN; }
            @Override Phase cancel() { return CANCELLED; }
        },
        ON_LOAN(Status.ON_LOAN) {
            @Override Phase returnItem() { return RETURNED; }
        },
        RETURNED(Status.RETURNED),
        CANCELLED(Status.CANCELLED);

        final Status status;
        Phase(Status status) { this.status = status; }
        Phase confirm() { throw rejected("confirm", status); }
        Phase checkOut() { throw rejected("check out", status); }
        Phase returnItem() { throw rejected("return", status); }
        Phase cancel() { throw rejected("cancel", status); }
    }
    private static IllegalStateException rejected(String action, Status status) {
        return new IllegalStateException("Cannot " + action + " in " + status);
    }
}

3.2 after/src/main/java/edu/se506/lending/application/Command.java

package edu.se506.lending.application;



/** Executes synchronously on the caller's thread; no queue or retry is implied. */
@FunctionalInterface
public interface Command<R> {
    R execute();
}

3.3 after/src/main/java/edu/se506/lending/application/ConfirmationFormatter.java

package edu.se506.lending.application;

import edu.se506.lending.domain.Reservation;

import java.util.Objects;

public final class ConfirmationFormatter {
    public String format(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        return "Confirmed: " + reservation.id()
                + " for " + reservation.borrowerId()
                + " on " + reservation.itemId()
                + " [" + reservation.period().start()
                + ", " + reservation.period().endExclusive() + ").";
    }
}

3.4 after/src/main/java/edu/se506/lending/application/DirectoryReservationService.java

package edu.se506.lending.application;

import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.domain.Borrower;

import java.time.LocalDate;
import java.util.Objects;

/** Directory-backed entry point; this does not authenticate the caller. */
public final class DirectoryReservationService {
    private final BorrowerDirectory directory;
    private final ReservationCoordinator coordinator;

    public DirectoryReservationService(BorrowerDirectory directory,
                                       ReservationCoordinator coordinator) {
        this.directory = Objects.requireNonNull(directory, "directory");
        this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
    }

    public String reserve(String borrowerId, String itemId, LocalDate start, int days) {
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
        var found = directory.find(borrowerId);
        if (found.isEmpty()) return "Rejected: borrower not found or inactive.";
        Borrower borrower = found.get();
        if (!borrowerId.equals(borrower.id())) {
            throw new DirectoryException("Directory returned a different borrower");
        }
        return coordinator.reserve(borrower.id(), itemId, start, days, borrower.category());
    }
}

3.5 after/src/main/java/edu/se506/lending/application/LoanService.java

package edu.se506.lending.application;

import edu.se506.lending.domain.Loan;
import edu.se506.lending.domain.Reservation;

import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** One service per coordinator/session; sequential, in-memory teaching flow. */
public final class LoanService {
    private final ReservationCoordinator reservations;
    private final Clock clock;
    private final Map<String, Loan> loans = new LinkedHashMap<>();

    public LoanService(ReservationCoordinator reservations, Clock clock) {
        this.reservations = Objects.requireNonNull(reservations, "reservations");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public Loan checkOut(String reservationId) {
        Reservation reservation = reservations.reservations().stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown reservation."));
        if (loans.containsKey(reservationId)) {
            throw new IllegalStateException("Reservation already used for a loan.");
        }
        LocalDate today = LocalDate.now(clock);
        if (today.isBefore(reservation.period().start())
                || !today.isBefore(reservation.period().endExclusive())) {
            throw new IllegalStateException("Checkout must be within the reservation period.");
        }
        boolean itemAlreadyOut = loans.values().stream()
                .anyMatch(loan -> loan.active() && loan.itemId().equals(reservation.itemId()));
        if (itemAlreadyOut) throw new IllegalStateException("Item is already on loan.");
        Loan loan = new Loan(reservation.id(), reservation.borrowerId(),
                reservation.itemId(), today, null);
        loans.put(reservationId, loan);
        return loan;
    }

    public Loan returnLoan(String reservationId) {
        Loan current = loans.get(reservationId);
        if (current == null || !current.active()) {
            throw new IllegalStateException("No active loan for this reservation.");
        }
        Loan returned = new Loan(current.reservationId(), current.borrowerId(),
                current.itemId(), current.borrowedOn(), LocalDate.now(clock));
        loans.put(reservationId, returned);
        return returned;
    }

    public List<Loan> loans() { return List.copyOf(loans.values()); }
}

3.6 after/src/main/java/edu/se506/lending/application/ReservationCoordinator.java

package edu.se506.lending.application;

import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.application.port.ReservationRepository;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.BorrowingPolicy;
import edu.se506.lending.domain.LoanPeriod;
import edu.se506.lending.domain.Reservation;

import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.Map;

public final class ReservationCoordinator {
    private final ReservationRepository repository;
    private final Map<BorrowerCategory, BorrowingPolicy> policies;
    private final ConfirmationFormatter formatter;
    private final NotificationSender notifications;

    public ReservationCoordinator(Map<BorrowerCategory, BorrowingPolicy> policies,
                                  ConfirmationFormatter formatter,
                                  NotificationSender notifications,
                                  ReservationRepository repository) {
        this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
        for (BorrowerCategory category : BorrowerCategory.values()) {
            if (!this.policies.containsKey(category)) {
                throw new IllegalArgumentException("Missing policy: " + category);
            }
        }
        this.formatter = Objects.requireNonNull(formatter, "formatter");
        this.notifications = Objects.requireNonNull(notifications, "notifications");
        this.repository = Objects.requireNonNull(repository, "repository");
    }

    public String reserve(String borrowerId, String itemId,
                          LocalDate start, int requestedDays) {
        return reserve(borrowerId, itemId, start, requestedDays, BorrowerCategory.STUDENT);
    }

    public String reserve(String borrowerId, String itemId, LocalDate start,
                          int requestedDays, BorrowerCategory category) {
        Objects.requireNonNull(category, "category");
        if (borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");

        int activeCount = 0;
        for (Reservation existing : repository.findAll()) {
            if (existing.borrowerId().equals(borrowerId)) {
                activeCount++;
            }
        }
        if (!policies.get(category).mayRequest(activeCount, requestedDays)) {
            return "Rejected: borrower limit or duration.";
        }

        LoanPeriod requested = new LoanPeriod(
                start, start.plusDays(requestedDays));
        if (hasConflict(itemId, requested)) {
            return "Rejected: item unavailable.";
        }

        Reservation reservation = new Reservation(
                "R" + (repository.findAll().size() + 1), borrowerId, itemId, requested);
        repository.add(reservation);
        String confirmation = formatter.format(reservation);
        try {
            notifications.send(reservation);
        } catch (NotificationException failure) {
            return confirmation + " Notification failed; reservation retained.";
        }
        return confirmation;
    }

    public boolean isAvailable(String itemId, LocalDate start, LocalDate endExclusive) {
        if (itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Item identifier must not be blank.");
        }
        LoanPeriod requested = new LoanPeriod(start, endExclusive);
        return !hasConflict(itemId, requested);
    }

    private boolean hasConflict(String itemId, LoanPeriod requested) {
        for (Reservation existing : repository.findAll()) {
            if (existing.itemId().equals(itemId) && existing.period().overlaps(requested)) {
                return true;
            }
        }
        return false;
    }

    public List<Reservation> reservations() {
        return repository.findAll();
    }
}

3.7 after/src/main/java/edu/se506/lending/application/ReserveEquipmentCommand.java

package edu.se506.lending.application;



import java.time.LocalDate;
import java.util.Objects;

/** Captures a request and receiver; constructing it performs no reservation. */
public record ReserveEquipmentCommand(DirectoryReservationService receiver,
        String borrowerId, String itemId, LocalDate start, int days) implements Command<String> {
    public ReserveEquipmentCommand {
        Objects.requireNonNull(receiver, "receiver");
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
    }
    @Override public String execute() {
        return receiver.reserve(borrowerId, itemId, start, days);
    }
}

3.8 after/src/main/java/edu/se506/lending/application/port/BorrowerDirectory.java

package edu.se506.lending.application.port;

import edu.se506.lending.domain.Borrower;

import java.util.Optional;

/** Empty means absent or inactive, never a provider outage. */
@FunctionalInterface
public interface BorrowerDirectory {
    Optional<Borrower> find(String borrowerId);
}

3.9 after/src/main/java/edu/se506/lending/application/port/DirectoryException.java

package edu.se506.lending.application.port;

public final class DirectoryException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public DirectoryException(String message) { super(message); }
    public DirectoryException(String message, Throwable cause) { super(message, cause); }
}

3.10 after/src/main/java/edu/se506/lending/application/port/NotificationException.java

package edu.se506.lending.application.port;

public final class NotificationException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public NotificationException(String message) { super(message); }
}

3.11 after/src/main/java/edu/se506/lending/application/port/NotificationSender.java

package edu.se506.lending.application.port;

import edu.se506.lending.domain.Reservation;

/**
 * Accept one non-null confirmed reservation for this implementation's local output.
 * Do not modify the reservation or coordinator. Report expected output failure with
 * NotificationException. Normal return is not a promise of email delivery.
 */
@FunctionalInterface
public interface NotificationSender {
    void send(Reservation reservation);
}

3.12 after/src/main/java/edu/se506/lending/application/port/ReservationRepository.java

package edu.se506.lending.application.port;

import edu.se506.lending.domain.Reservation;
import java.util.List;

/** Minimal append-only store contract for one sequential session. */
public interface ReservationRepository {
    /** Stable unmodifiable snapshot, in insertion order. Values are immutable. */
    List<Reservation> findAll();
    /** Add a non-null reservation; duplicate IDs reject without changing the store. */
    void add(Reservation reservation);
}

3.13 after/src/main/java/edu/se506/lending/bootstrap/Chapter04Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;

public final class Chapter04Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("B1", "CAM1", start, 2));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(1), 1));
        System.out.println(coordinator.reserve("B2", "CAM1", start.plusDays(2), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.14 after/src/main/java/edu/se506/lending/bootstrap/Chapter05Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;

public final class Chapter05Demo {
    public static void main(String[] args) {
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println(coordinator.reserve("S1", "CAM1", day, 14,
                BorrowerCategory.STAFF));
        System.out.println(coordinator.reserve("B1", "MIC1", day, 14));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(1), 1));
        System.out.println(coordinator.reserve("B1", "CAM1", day.plusDays(14), 1));
        System.out.println("Stored reservations: " + coordinator.reservations().size());
    }
}

3.15 after/src/main/java/edu/se506/lending/bootstrap/Chapter07Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.LoanService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.domain.Loan;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

public final class Chapter07Demo {
    public static void main(String[] args) {
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator reservations = ReservationApplication.create(inbox);
        LocalDate day = LocalDate.of(2026, 10, 20);
        System.out.println("Available before reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        System.out.println(reservations.reserve("B1", "CAM1", day, 2));
        System.out.println("Available after reservation: " + reservations.isAvailable("CAM1", day, day.plusDays(2)));
        LoanService loans = new LoanService(reservations,
                Clock.fixed(Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        System.out.println("Loan active after checkout: " + loans.checkOut("R1").active());
        System.out.println("Loan active after return: " + loans.returnLoan("R1").active());
        System.out.println("Accepted-reservation notices: " + inbox.received().size());
    }
}

3.16 after/src/main/java/edu/se506/lending/bootstrap/Chapter08Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;
import java.util.Map;

public final class Chapter08Demo {
    public static void main(String[] args) {
        var rows = Map.of(
                "B1", new LegacyDirectoryClient.Person("B1", "S", "Y"),
                "B2", new LegacyDirectoryClient.Person("B2", "T", "Y"));
        BorrowerDirectory directory = new LegacyDirectoryAdapter(rows::get);
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var service = new DirectoryReservationService(directory, coordinator);
        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println(service.reserve("B2", "CAM1", start, 10));
        System.out.println(service.reserve("B1", "MIC1", start, 10));
        System.out.println(service.reserve("UNKNOWN", "MIC1", start, 2));
        System.out.println("Reservations: " + coordinator.reservations().size());
        System.out.println("Notices: " + notices.received().size());
    }
}

3.17 after/src/main/java/edu/se506/lending/bootstrap/Chapter09Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.Command;
import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.ReserveEquipmentCommand;
import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.infrastructure.notification.NotificationPublisher;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Optional;
import behaviour.LifecycleExamples.StateReservation;

public final class Chapter09Demo {
    public static void main(String[] args) {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(reservation -> events.add("Desk: " + reservation.id()));
        publisher.subscribe(reservation -> { throw new NotificationException("Display offline"); });
        publisher.subscribe(reservation -> events.add("Audit: " + reservation.id()));
        var coordinator = ReservationApplication.create(publisher);
        var service = new DirectoryReservationService(
                id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        Command<String> request = new ReserveEquipmentCommand(
                service, "B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Before execution: " + coordinator.reservations().size());
        System.out.println(request.execute());
        System.out.println("Observer events: " + events);
        System.out.println("Stored reservations: " + coordinator.reservations().size());
        var lifecycle = new StateReservation();
        lifecycle.confirm();
        lifecycle.checkOut();
        lifecycle.returnItem();
        System.out.println("Separate lifecycle exercise: " + lifecycle.status());
    }
}

3.18 after/src/main/java/edu/se506/lending/bootstrap/Chapter10Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import edu.se506.lending.presentation.ReservationConsole;
import java.time.LocalDate;
import java.util.Map;

public final class Chapter10Demo {
    public static void main(String[] args) {
        var rows = Map.of(
                "B1", new LegacyDirectoryClient.Person("B1", "S", "Y"),
                "B2", new LegacyDirectoryClient.Person("B2", "T", "Y"));
        var sender = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(sender);
        var service = new DirectoryReservationService(new LegacyDirectoryAdapter(rows::get), coordinator);
        var console = new ReservationConsole(service, System.out);
        var start = LocalDate.of(2026, 10, 20);
        console.reserve("B2", "CAM1", start, 10);
        console.reserve("B1", "MIC1", start, 10);
        console.reserve("UNKNOWN", "MIC1", start, 2);
        System.out.println("Reservations: " + coordinator.reservations().size());
        System.out.println("Notices: " + sender.received().size());
    }
}

3.19 after/src/main/java/edu/se506/lending/bootstrap/ReservationApplication.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.ConfirmationFormatter;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;
import edu.se506.lending.infrastructure.notification.ConsoleNotificationSender;
import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules()),
                new ConfirmationFormatter(), sender, new InMemoryReservationRepository());
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

3.20 after/src/main/java/edu/se506/lending/domain/Borrower.java

package edu.se506.lending.domain;



import java.util.Objects;

public record Borrower(String id, BorrowerCategory category) {
    public Borrower {
        if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
        Objects.requireNonNull(category, "category");
    }
}

3.21 after/src/main/java/edu/se506/lending/domain/BorrowerCategory.java

package edu.se506.lending.domain;



public enum BorrowerCategory {
    STUDENT, STAFF
}

3.22 after/src/main/java/edu/se506/lending/domain/BorrowingPolicy.java

package edu.se506.lending.domain;



/**
 * A deterministic, side-effect-free borrowing decision for every pair of ints.
 * Return false for negative counts or nonpositive durations; do not throw for them.
 * Return false for requests outside this implementation's documented limits.
 * A true result concerns count/duration only, not item availability or identity.
 */
public interface BorrowingPolicy {
    boolean mayRequest(int activeReservations, int requestedDays);
}

3.23 after/src/main/java/edu/se506/lending/domain/Loan.java

package edu.se506.lending.domain;



import java.time.LocalDate;
import java.util.Objects;

/** Immutable loan state. A null returnedOn means this loan is active. */
public record Loan(String reservationId, String borrowerId, String itemId,
                   LocalDate borrowedOn, LocalDate returnedOn) {
    public Loan {
        if (reservationId == null || reservationId.isBlank()
                || borrowerId == null || borrowerId.isBlank()
                || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Loan identifiers must not be blank.");
        }
        Objects.requireNonNull(borrowedOn, "borrowedOn");
        if (returnedOn != null && returnedOn.isBefore(borrowedOn)) {
            throw new IllegalArgumentException("Return cannot precede checkout.");
        }
    }
    public boolean active() { return returnedOn == null; }
}

3.24 after/src/main/java/edu/se506/lending/domain/LoanPeriod.java

package edu.se506.lending.domain;



import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

3.25 after/src/main/java/edu/se506/lending/domain/Reservation.java

package edu.se506.lending.domain;



import java.util.Objects;

public final class Reservation {
    private final String id;
    private final String borrowerId;
    private final String itemId;
    private final LoanPeriod period;

    public Reservation(String id, String borrowerId, String itemId,
                       LoanPeriod period) {
        this.id = requireText(id);
        this.borrowerId = requireText(borrowerId);
        this.itemId = requireText(itemId);
        this.period = Objects.requireNonNull(period, "period");
    }

    private static String requireText(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        return value;
    }

    public String id() { return id; }
    public String borrowerId() { return borrowerId; }
    public String itemId() { return itemId; }
    public LoanPeriod period() { return period; }
}

3.26 after/src/main/java/edu/se506/lending/domain/ReservationRules.java

package edu.se506.lending.domain;



public final class ReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 7;
    }
}

3.27 after/src/main/java/edu/se506/lending/domain/StaffReservationRules.java

package edu.se506.lending.domain;



/** Illustrative teaching policy, not an approved institutional rule. */
public final class StaffReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 5
                && requestedDays >= 1
                && requestedDays <= 14;
    }
}

3.28 after/src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryAdapter.java

package edu.se506.lending.infrastructure.directory;

import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;

import java.util.Objects;
import java.util.Optional;

public final class LegacyDirectoryAdapter implements BorrowerDirectory {
    private final LegacyDirectoryClient client;

    public LegacyDirectoryAdapter(LegacyDirectoryClient client) {
        this.client = Objects.requireNonNull(client, "client");
    }

    @Override
    public Optional<Borrower> find(String borrowerId) {
        if (borrowerId == null || borrowerId.isBlank()) {
            throw new IllegalArgumentException("Borrower id required");
        }
        LegacyDirectoryClient.Person row;
        try {
            row = client.lookup(borrowerId);
        } catch (LegacyDirectoryClient.Failure failure) {
            throw new DirectoryException("Borrower directory unavailable", failure);
        }
        if (row == null) return Optional.empty();
        if (!borrowerId.equals(row.key())) throw new DirectoryException("Mismatched borrower id");
        BorrowerCategory category;
        if ("S".equals(row.kind())) category = BorrowerCategory.STUDENT;
        else if ("T".equals(row.kind())) category = BorrowerCategory.STAFF;
        else throw new DirectoryException("Unsupported borrower category");
        if ("N".equals(row.status())) return Optional.empty();
        if (!"Y".equals(row.status())) throw new DirectoryException("Unsupported borrower status");
        return Optional.of(new Borrower(row.key(), category));
    }
}

3.29 after/src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryClient.java

package edu.se506.lending.infrastructure.directory;

/** Illustrative provider contract, not an actual university API or network client. */
@FunctionalInterface
public interface LegacyDirectoryClient {
    // null means no row; codes are exact and case-sensitive.
    Person lookup(String key);

    record Person(String key, String kind, String status) { }

    final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public Failure(String message) { super(message); }
    }
}

3.30 after/src/main/java/edu/se506/lending/infrastructure/notification/ConsoleNotificationSender.java

package edu.se506.lending.infrastructure.notification;

import edu.se506.lending.application.ConfirmationFormatter;
import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.domain.Reservation;

import java.io.PrintStream;
import java.util.Objects;

public final class ConsoleNotificationSender implements NotificationSender {
    private final PrintStream output;
    private final ConfirmationFormatter formatter;

    public ConsoleNotificationSender(PrintStream output, ConfirmationFormatter formatter) {
        this.output = Objects.requireNonNull(output, "output");
        this.formatter = Objects.requireNonNull(formatter, "formatter");
    }

    @Override
    public void send(Reservation reservation) {
        output.println("Notice: " + formatter.format(reservation));
        if (output.checkError()) {
            throw new NotificationException("Console output failed.");
        }
    }
}

3.31 after/src/main/java/edu/se506/lending/infrastructure/notification/NotificationPublisher.java

package edu.se506.lending.infrastructure.notification;

import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.domain.Reservation;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Sequential, in-process Observer example. No retries or durable delivery. */
public final class NotificationPublisher implements NotificationSender {
    @FunctionalInterface
    public interface Observer { void onConfirmed(Reservation reservation); }
    @FunctionalInterface
    public interface Subscription extends AutoCloseable { @Override void close(); }
    private static final class Entry {
        private final Observer observer;
        private Entry(Observer observer) { this.observer = observer; }
    }
    private final List<Entry> entries = new ArrayList<>();
    private boolean publishing;

    public Subscription subscribe(Observer observer) {
        Objects.requireNonNull(observer, "observer");
        if (entries.stream().anyMatch(entry -> entry.observer == observer)) {
            throw new IllegalArgumentException("Observer already subscribed");
        }
        Entry entry = new Entry(observer);
        entries.add(entry);
        return () -> entries.remove(entry);
    }

    @Override
    public void send(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        if (publishing) throw new IllegalStateException("Reentrant publication is not supported");
        publishing = true;
        try {
            NotificationException aggregate = null;
            for (Entry entry : List.copyOf(entries)) {
                try {
                    entry.observer.onConfirmed(reservation);
                } catch (NotificationException failure) {
                    if (aggregate == null) aggregate = new NotificationException("One or more observers failed");
                    aggregate.addSuppressed(failure);
                }
            }
            if (aggregate != null) throw aggregate;
        } finally {
            publishing = false;
        }
    }
}

3.32 after/src/main/java/edu/se506/lending/infrastructure/notification/RecordingNotificationSender.java

package edu.se506.lending.infrastructure.notification;

import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.domain.Reservation;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class RecordingNotificationSender implements NotificationSender {
    private final List<Reservation> received = new ArrayList<>();

    @Override
    public void send(Reservation reservation) {
        received.add(Objects.requireNonNull(reservation, "reservation"));
    }

    public List<Reservation> received() { return List.copyOf(received); }
}

3.33 after/src/main/java/edu/se506/lending/infrastructure/persistence/InMemoryReservationRepository.java

package edu.se506.lending.infrastructure.persistence;

import edu.se506.lending.application.port.ReservationRepository;
import edu.se506.lending.domain.Reservation;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** Insertion ordered, append only, not persistent and not thread-safe. */
public final class InMemoryReservationRepository implements ReservationRepository {
    private final Map<String, Reservation> records = new LinkedHashMap<>();
    @Override public List<Reservation> findAll() { return List.copyOf(records.values()); }
    @Override public void add(Reservation reservation) {
        Objects.requireNonNull(reservation, "reservation");
        if (records.containsKey(reservation.id())) {
            throw new IllegalArgumentException("Duplicate reservation id: " + reservation.id());
        }
        records.put(reservation.id(), reservation);
    }
}

3.34 after/src/main/java/edu/se506/lending/presentation/ReservationConsole.java

package edu.se506.lending.presentation;

import edu.se506.lending.application.DirectoryReservationService;
import java.io.PrintStream;
import java.time.LocalDate;
import java.util.Objects;

/** Thin output adapter: delegates the use case and prints its existing result. */
public final class ReservationConsole {
    private final DirectoryReservationService service;
    private final PrintStream output;
    public ReservationConsole(DirectoryReservationService service, PrintStream output) {
        this.service = Objects.requireNonNull(service, "service");
        this.output = Objects.requireNonNull(output, "output");
    }
    public void reserve(String borrowerId, String itemId, LocalDate start, int days) {
        output.println(service.reserve(borrowerId, itemId, start, days));
    }
}

3.35 after/src/main/java/examples/structure/PeriodValue.java

package examples.structure;

import java.time.LocalDate;
import java.util.Objects;

/** Separate equality example; deliberately does not replace cumulative LoanPeriod. */
public record PeriodValue(LocalDate start, LocalDate endExclusive) {
    public PeriodValue {
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) throw new IllegalArgumentException("End must be after start.");
    }
}

3.36 after/src/main/java/patterns/PatternExamples.java

package patterns;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** Isolated teaching examples; none is wired into the lending application. */
public final class PatternExamples {
    private PatternExamples() { }

    // Factory Method: inherited workflow invokes an overridable creation hook.
    public interface Exporter { String export(String value); }
    public abstract static class ExportJob {
        protected abstract Exporter createExporter();
        public final String run(String value) {
            Objects.requireNonNull(value, "value");
            return createExporter().export(value);
        }
    }
    public static final class PlainJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "TEXT: " + value; }
    }
    public static final class HtmlJob extends ExportJob {
        @Override protected Exporter createExporter() { return value -> "<p>" + escape(value) + "</p>"; }
    }
    // Simple factory: useful, but no overridden creation hook.
    public static Exporter simpleExporter(boolean html) {
        return html ? value -> "<p>" + escape(value) + "</p>" : value -> "TEXT: " + value;
    }

    // Abstract Factory: two product roles, supplied as a related family.
    public interface Heading { String render(String text); }
    public interface Body { String render(String text); }
    public interface ReceiptFamily {
        Heading heading();
        Body body();
    }
    public static final class PlainFamily implements ReceiptFamily {
        public Heading heading() { return text -> text + "\n"; }
        public Body body() { return text -> text + "\n"; }
    }
    public static final class HtmlFamily implements ReceiptFamily {
        public Heading heading() { return text -> "<h1>" + escape(text) + "</h1>"; }
        public Body body() { return text -> "<p>" + escape(text) + "</p>"; }
    }
    public static String receipt(ReceiptFamily family, String title, String detail) {
        Objects.requireNonNull(family, "family");
        return family.heading().render(title) + family.body().render(detail);
    }
    private static String escape(String text) {
        return Objects.requireNonNull(text, "text").replace("&", "&amp;")
                .replace("<", "&lt;").replace(">", "&gt;");
    }

    // Fluent builder variant: staged configuration, one immutable product.
    public static final class ReportRequest {
        private final String borrowerId;
        private final LocalDate from;
        private final LocalDate toExclusive;
        private final boolean includeReturned;
        private final List<String> columns;
        private ReportRequest(Builder builder) {
            if ((builder.from == null) != (builder.toExclusive == null)) {
                throw new IllegalArgumentException("Both dates or neither are required");
            }
            if (builder.from != null && !builder.from.isBefore(builder.toExclusive)) {
                throw new IllegalArgumentException("Positive interval required");
            }
            borrowerId = builder.borrowerId;
            from = builder.from;
            toExclusive = builder.toExclusive;
            includeReturned = builder.includeReturned;
            columns = List.copyOf(builder.columns);
        }
        public String borrowerId() { return borrowerId; }
        public LocalDate from() { return from; }
        public LocalDate toExclusive() { return toExclusive; }
        public boolean includeReturned() { return includeReturned; }
        public List<String> columns() { return columns; }
        public static Builder forBorrower(String id) { return new Builder(id); }
        public static final class Builder {
            private final String borrowerId;
            private LocalDate from;
            private LocalDate toExclusive;
            private boolean includeReturned;
            private final List<String> columns = new ArrayList<>();
            private Builder(String id) {
                if (id == null || id.isBlank()) throw new IllegalArgumentException("Borrower id required");
                borrowerId = id;
            }
            public Builder during(LocalDate from, LocalDate toExclusive) {
                Objects.requireNonNull(from, "from");
                Objects.requireNonNull(toExclusive, "toExclusive");
                this.from = from;
                this.toExclusive = toExclusive;
                return this;
            }
            public Builder includeReturned(boolean value) { includeReturned = value; return this; }
            public Builder column(String value) {
                if (value == null || value.isBlank()) throw new IllegalArgumentException("Column required");
                columns.add(value);
                return this;
            }
            public ReportRequest build() { return new ReportRequest(this); }
        }
    }

    // Decorators: same interface, explicit order and expected failure type.
    public interface Sender { void send(String message); }
    public static final class DeliveryFailure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public DeliveryFailure(String message) { super(message); }
    }
    public static final class AuditedSender implements Sender {
        private final Sender delegate;
        private final List<String> events;
        public AuditedSender(Sender delegate, List<String> events) {
            this.delegate = Objects.requireNonNull(delegate, "delegate");
            this.events = Objects.requireNonNull(events, "events");
        }
        public void send(String message) {
            events.add("attempt");
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                events.add("failed");
                throw failure;
            }
            events.add("sent");
        }
    }
    public static final class RetryOnceSender implements Sender {
        private final Sender delegate;
        public RetryOnceSender(Sender delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); }
        public void send(String message) {
            try {
                delegate.send(message);
            } catch (DeliveryFailure failure) {
                delegate.send(message);
            }
        }
    }
}

4. Complete test sources

Migrated regression tests retain their original expectations. Structure tests examine the new boundaries and compiled references.

4.1 after/src/test/java/behaviour/LifecycleExamplesTest.java

package behaviour;

import static org.junit.jupiter.api.Assertions.*;
import static behaviour.LifecycleExamples.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class LifecycleExamplesTest {
    private enum Action { CONFIRM, CHECK_OUT, RETURN, CANCEL }
    private void act(Lifecycle lifecycle, Action action) {
        switch (action) {
            case CONFIRM -> lifecycle.confirm();
            case CHECK_OUT -> lifecycle.checkOut();
            case RETURN -> lifecycle.returnItem();
            case CANCEL -> lifecycle.cancel();
        }
    }
    private void reach(Lifecycle lifecycle, Status status) {
        switch (status) {
            case REQUESTED -> { }
            case CONFIRMED -> lifecycle.confirm();
            case ON_LOAN -> { lifecycle.confirm(); lifecycle.checkOut(); }
            case RETURNED -> { lifecycle.confirm(); lifecycle.checkOut(); lifecycle.returnItem(); }
            case CANCELLED -> lifecycle.cancel();
        }
    }
    @ParameterizedTest
    @CsvSource({
        "REQUESTED,CONFIRM,CONFIRMED,true", "REQUESTED,CHECK_OUT,REQUESTED,false",
        "REQUESTED,RETURN,REQUESTED,false", "REQUESTED,CANCEL,CANCELLED,true",
        "CONFIRMED,CONFIRM,CONFIRMED,false", "CONFIRMED,CHECK_OUT,ON_LOAN,true",
        "CONFIRMED,RETURN,CONFIRMED,false", "CONFIRMED,CANCEL,CANCELLED,true",
        "ON_LOAN,CONFIRM,ON_LOAN,false", "ON_LOAN,CHECK_OUT,ON_LOAN,false",
        "ON_LOAN,RETURN,RETURNED,true", "ON_LOAN,CANCEL,ON_LOAN,false",
        "RETURNED,CONFIRM,RETURNED,false", "RETURNED,CHECK_OUT,RETURNED,false",
        "RETURNED,RETURN,RETURNED,false", "RETURNED,CANCEL,RETURNED,false",
        "CANCELLED,CONFIRM,CANCELLED,false", "CANCELLED,CHECK_OUT,CANCELLED,false",
        "CANCELLED,RETURN,CANCELLED,false", "CANCELLED,CANCEL,CANCELLED,false"})
    void bothImplementationsMatchTransitionTable(Status from, Action action, Status expected, boolean allowed) {
        for (Lifecycle lifecycle : List.of(new ConditionalReservation(), new StateReservation())) {
            reach(lifecycle, from);
            if (allowed) act(lifecycle, action);
            else assertThrows(IllegalStateException.class, () -> act(lifecycle, action));
            assertEquals(expected, lifecycle.status());
        }
    }
    @Test void stateObjectsDoNotShareReservationProgress() {
        var first = new StateReservation();
        var second = new StateReservation();
        first.confirm();
        first.checkOut();
        assertEquals(Status.ON_LOAN, first.status());
        assertEquals(Status.REQUESTED, second.status());
        second.cancel();
        assertEquals(Status.ON_LOAN, first.status());
    }
}

4.2 after/src/test/java/edu/se506/lending/regression/AvailabilityTddTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTddTest {
    @Test
    void overlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying() {
        // Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator coordinator = ReservationApplication.create(inbox);
        LocalDate start = LocalDate.of(2026, 10, 20);
        coordinator.reserve("B1", "CAM1", start, 2);
        List<Reservation> recordsBefore = coordinator.reservations();
        List<Reservation> noticesBefore = inbox.received();

        // Act: inspect the overlapping interval without requesting a reservation.
        boolean available = coordinator.isAvailable("CAM1", start.plusDays(1), start.plusDays(3));

        // Assert: the answer and both kinds of state matter.
        assertAll(
                () -> assertFalse(available, "An overlapping item must be unavailable"),
                () -> assertEquals(recordsBefore, coordinator.reservations()),
                () -> assertEquals(noticesBefore, inbox.received()));
    }
}

4.3 after/src/test/java/edu/se506/lending/regression/AvailabilityTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class AvailabilityTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);
    private final RecordingNotificationSender inbox = new RecordingNotificationSender();
    private final ReservationCoordinator c = ReservationApplication.create(inbox);

    @Test void emptyScheduleIsAvailableWithoutAnySideEffect() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(2)));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void adjacentPeriodAndDifferentItemAreAvailable() {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertTrue(c.isAvailable("CAM1", day.plusDays(2), day.plusDays(3)));
        assertTrue(c.isAvailable("MIC1", day, day.plusDays(2)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @ParameterizedTest @ValueSource(ints = {0, -1})
    void invalidPeriodFailsWithoutChangingState(int endOffset) {
        c.reserve("B1", "CAM1", day, 2);
        List<Reservation> records = c.reservations();
        List<Reservation> notices = inbox.received();
        assertThrows(IllegalArgumentException.class,
                () -> c.isAvailable("CAM1", day, day.plusDays(endOffset)));
        assertEquals(records, c.reservations());
        assertEquals(notices, inbox.received());
    }

    @Test void malformedIdentifiersAndNullDatesAreRejected() {
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(" ", day, day.plusDays(1)));
        assertThrows(IllegalArgumentException.class, () -> c.isAvailable(null, day, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", null, day.plusDays(1)));
        assertThrows(NullPointerException.class, () -> c.isAvailable("CAM1", day, null));
        assertTrue(c.reservations().isEmpty());
        assertTrue(inbox.received().isEmpty());
    }

    @Test void queryDoesNotAllocateAnIdentifier() {
        c.isAvailable("CAM1", day, day.plusDays(1));
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-21).",
                c.reserve("B1", "CAM1", day, 1));
    }

    @Test void availabilityDoesNotMeanBorrowerEligibility() {
        for (String item : List.of("CAM1", "MIC1", "TRI1")) c.reserve("B1", item, day, 1);
        assertTrue(c.isAvailable("CAM2", day, day.plusDays(1)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM2", day, 1));
        assertEquals(3, inbox.received().size());
    }

    @Test void queryCanSpanMoreDaysThanAStudentMayReserve() {
        assertTrue(c.isAvailable("CAM1", day, day.plusDays(30)));
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 30));
    }
}

4.4 after/src/test/java/edu/se506/lending/regression/BehaviourStrategyTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ConfirmationFormatter;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.BorrowingPolicy;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class BehaviourStrategyTest {
    // Starting conditional design retained only for comparison in this exercise.
    private boolean conditional(BorrowerCategory category, int count, int days) {
        if (category == BorrowerCategory.STUDENT) {
            return count >= 0 && count < 3 && days >= 1 && days <= 7;
        }
        return count >= 0 && count < 5 && days >= 1 && days <= 14;
    }
    @ParameterizedTest
    @CsvSource({"STUDENT,2,7,true", "STUDENT,3,7,false", "STUDENT,0,8,false",
            "STUDENT,-1,2,false", "STAFF,4,14,true", "STAFF,5,14,false",
            "STAFF,0,15,false", "STAFF,0,0,false"})
    void strategiesAndConditionalMatchIndependentExamples(BorrowerCategory category,
            int count, int days, boolean expected) {
        var policies = Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                BorrowerCategory.STAFF, new StaffReservationRules());
        assertEquals(expected, conditional(category, count, days));
        assertEquals(expected, policies.get(category).mayRequest(count, days));
    }
    @Test void injectedPolicyChangesDecisionWithoutChangingCoordinator() {
        BorrowingPolicy temporaryStaffRule = (count, days) -> count >= 0 && count < 1 && days >= 1 && days <= 2;
        var coordinator = new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(), BorrowerCategory.STAFF, temporaryStaffRule),
                new ConfirmationFormatter(), new RecordingNotificationSender(), new InMemoryReservationRepository());
        var start = LocalDate.of(2026, 10, 20);
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("T1", "CAM1", start, 3, BorrowerCategory.STAFF));
        assertTrue(coordinator.reserve("T1", "CAM1", start, 2, BorrowerCategory.STAFF).startsWith("Confirmed: R1"));
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("T1", "MIC1", start, 1, BorrowerCategory.STAFF));
        assertTrue(coordinator.reserve("S1", "MIC1", start, 7, BorrowerCategory.STUDENT).startsWith("Confirmed: R2"));
    }
}

4.5 after/src/test/java/edu/se506/lending/regression/BorrowingPolicyTest.java

package edu.se506.lending.regression;

import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.BorrowingPolicy;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BorrowingPolicyTest {
    @ParameterizedTest(name = "{0}: count={1}, days={2} -> {3}")
    @CsvSource({
        "STUDENT, 0, 1, true", "STUDENT, 2, 7, true",
        "STUDENT, 3, 1, false", "STUDENT, 0, 8, false",
        "STAFF, 4, 14, true", "STAFF, 5, 1, false",
        "STAFF, 0, 15, false", "STUDENT, -1, 1, false",
        "STAFF, 0, 0, false", "STAFF, 0, -1, false"
    })
    void documentedBoundaries(BorrowerCategory category, int count, int days, boolean expected) {
        BorrowingPolicy policy = category == BorrowerCategory.STUDENT
                ? new ReservationRules() : new StaffReservationRules();
        assertEquals(expected, policy.mayRequest(count, days));
    }
}

4.6 after/src/test/java/edu/se506/lending/regression/Chapter02Checks.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.LoanPeriod;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.List;

public class Chapter02Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        LoanPeriod first = new LoanPeriod(day, day.plusDays(2));
        LoanPeriod overlapping = new LoanPeriod(day.plusDays(1), day.plusDays(3));
        LoanPeriod adjacent = new LoanPeriod(day.plusDays(2), day.plusDays(3));
        check(first.overlaps(overlapping), "overlap detected");
        check(overlapping.overlaps(first), "overlap is symmetric");
        check(!first.overlaps(adjacent), "adjacent periods allowed");
        check(!adjacent.overlaps(first), "adjacency symmetric");
        check(first.overlaps(new LoanPeriod(day, day.plusDays(2))), "equal periods overlap");
        check(first.overlaps(new LoanPeriod(day.minusDays(1), day.plusDays(4))),
                "containment overlaps");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day),
                "zero-length period");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(day, day.minusDays(1)),
                "reversed period");
        expect(NullPointerException.class, () -> new LoanPeriod(null, day), "null start");
        expect(NullPointerException.class, () -> new LoanPeriod(day, null), "null end");

        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("B1", "CAM1", day, 2).equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "exact first confirmation");
        check(c.reservations().size() == 1, "one success stored");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "overlapping same item rejected");
        check(c.reservations().size() == 1, "conflict stores nothing");
        check(c.reserve("B2", "CAM1", day.plusDays(2), 1).equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "adjacent request and identifier after rejection");
        check(c.reserve("B1", "MIC1", day, 7).equals(
                "Confirmed: R3 for B1 on MIC1 [2026-10-20, 2026-10-27)."),
                "different item and seven-day limit allowed");
        check(c.reserve("B1", "TRI1", day, 1).startsWith("Confirmed:"),
                "third active reservation allowed");
        check(c.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "fourth rejected");
        check(c.reservations().size() == 4, "borrower-limit rejection stores nothing");

        List<Reservation> snapshot = c.reservations();
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot unmodifiable");
        check(c.reservations().size() == 4, "failed clear preserves coordinator state");
        check(c.reserve("B3", "CAM2", day, 1).startsWith("Confirmed:"),
                "another borrower unaffected by B1 limit");
        check(snapshot.size() == 4 && c.reservations().size() == 5,
                "old snapshot does not track later additions");
        check(snapshot.get(0).period().start().equals(day), "stored period preserved");

        ReservationCoordinator invalid = ReservationApplication.create(new RecordingNotificationSender());
        check(invalid.reserve("B1", "CAM1", day, 0).equals(
                "Rejected: borrower limit or duration."), "zero duration rejected");
        check(invalid.reserve("B1", "CAM1", day, -1).equals(
                "Rejected: borrower limit or duration."), "negative duration rejected");
        check(invalid.reserve("B1", "CAM1", day, 8).equals(
                "Rejected: borrower limit or duration."), "eight-day duration rejected");
        expect(IllegalArgumentException.class, () -> invalid.reserve(" ", "CAM1", day, 1),
                "blank borrower");
        expect(IllegalArgumentException.class, () -> invalid.reserve("B1", null, day, 1),
                "null item");
        expect(NullPointerException.class, () -> invalid.reserve("B1", "CAM1", null, 1),
                "null request date");
        expect(DateTimeException.class, () -> invalid.reserve("B1", "CAM1", LocalDate.MAX, 1),
                "unrepresentable end date");
        check(invalid.reservations().isEmpty(), "invalid requests store nothing");
        expect(IllegalArgumentException.class,
                () -> new Reservation("", "B1", "CAM1", first), "blank reservation identifier");
        expect(NullPointerException.class,
                () -> new Reservation("R1", "B1", "CAM1", null), "null reservation period");
        System.out.println("All " + checks + " chapter checks passed.");
    }

    private static void check(boolean condition, String label) {
        if (!condition) {
            throw new AssertionError(label);
        }
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.7 after/src/test/java/edu/se506/lending/regression/Chapter03AcceptanceExamples.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;

public class Chapter03AcceptanceExamples {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());

        // AC-01: an eligible request is stored once and confirmed.
        String accepted = coordinator.reserve("B1", "CAM1", day, 2);
        require(accepted.equals(
                "Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "AC-01 confirmation");
        require(coordinator.reservations().size() == 1, "AC-01 one record");
        System.out.println("AC-01 passed");

        // AC-02: an overlapping request for the same item changes no records.
        String conflict = coordinator.reserve("B2", "CAM1", day.plusDays(1), 1);
        require(conflict.equals("Rejected: item unavailable."), "AC-02 rejection");
        require(coordinator.reservations().size() == 1, "AC-02 unchanged count");
        require(coordinator.reservations().get(0).borrowerId().equals("B1"),
                "AC-02 original borrower preserved");
        System.out.println("AC-02 passed");

        // AC-03: a booking may start at an existing exclusive end,
        // or end exactly where an existing booking starts.
        String adjacent = coordinator.reserve("B2", "CAM1", day.plusDays(2), 1);
        require(adjacent.equals(
                "Confirmed: R2 for B2 on CAM1 [2026-10-22, 2026-10-23)."),
                "AC-03 adjacent confirmation");
        require(coordinator.reservations().size() == 2, "AC-03 second record");
        String before = coordinator.reserve("B3", "CAM1", day.minusDays(1), 1);
        require(before.equals(
                "Confirmed: R3 for B3 on CAM1 [2026-10-19, 2026-10-20)."),
                "AC-03 request ending at an existing start");
        require(coordinator.reservations().size() == 3, "AC-03 third record");
        System.out.println("AC-03 passed");

        // AC-04: count limits span items, not just one equipment item.
        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (String item : new String[] {"CAM1", "MIC1", "TRI1"}) {
            require(limits.reserve("B1", item, day, 1).startsWith("Confirmed:"),
                    "AC-04 establish three valid reservations");
        }
        require(limits.reserve("B1", "CAM2", day, 1).equals(
                "Rejected: borrower limit or duration."), "AC-04 fourth rejected");
        require(limits.reservations().size() == 3, "AC-04 unchanged count");
        System.out.println("AC-04 passed");

        // AC-05: one and seven days are allowed; zero, negative and eight are not.
        ReservationCoordinator durations = ReservationApplication.create(new RecordingNotificationSender());
        require(durations.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "AC-05 one day");
        require(durations.reserve("B1", "MIC1", day, 7).startsWith("Confirmed:"),
                "AC-05 seven days");
        for (int days : new int[] {0, -1, 8}) {
            require(durations.reserve("B1", "TRI1", day, days).equals(
                    "Rejected: borrower limit or duration."), "AC-05 invalid duration");
            require(durations.reservations().size() == 2, "AC-05 unchanged count");
        }
        System.out.println("AC-05 passed");
        System.out.println("All 5 acceptance examples passed.");
    }

    private static void require(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
    }
}

4.8 after/src/test/java/edu/se506/lending/regression/Chapter05Checks.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.BorrowingPolicy;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.LocalDate;
import java.util.List;

public final class Chapter05Checks {
    private static int checks;

    public static void main(String[] args) {
        BorrowingPolicy student = new ReservationRules();
        BorrowingPolicy staff = new StaffReservationRules();
        sharedPolicyContract(student);
        sharedPolicyContract(staff);
        check(student.mayRequest(2, 7), "student upper accepted boundaries");
        check(!student.mayRequest(3, 7), "student fourth rejected");
        check(!student.mayRequest(0, 8), "student eight days rejected");
        check(staff.mayRequest(4, 14), "staff upper accepted boundaries");
        check(!staff.mayRequest(5, 14), "staff sixth rejected");
        check(!staff.mayRequest(0, 15), "staff fifteen days rejected");

        LocalDate day = LocalDate.of(2026, 10, 20);
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        check(c.reserve("S1", "CAM1", day, 14, BorrowerCategory.STAFF).equals(
                "Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-11-03)."),
                "staff confirmation with new limit");
        List<Reservation> snapshot = c.reservations();
        check(c.reserve("B1", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "student conflicts with staff booking");
        check(c.reservations().equals(snapshot), "conflict preserves exact records");
        check(c.reserve("B1", "CAM1", day.plusDays(14), 1).equals(
                "Confirmed: R2 for B1 on CAM1 [2026-11-03, 2026-11-04)."),
                "cross-category adjacency and next identifier");
        List<Reservation> beforeInvalid = c.reservations();
        check(c.reserve("S1", "MIC1", day, 15, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff invalid duration result");
        check(c.reservations().equals(beforeInvalid), "invalid staff request preserves records");
        expect(NullPointerException.class,
                () -> c.reserve("S1", "MIC1", day, 1, null), "null category");
        check(c.reservations().equals(beforeInvalid), "null category preserves records");

        ReservationCoordinator limits = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 5; i++) {
            check(limits.reserve("S2", "ITEM" + i, day, 1, BorrowerCategory.STAFF)
                    .startsWith("Confirmed:"), "staff reservation " + i);
        }
        List<Reservation> five = limits.reservations();
        check(limits.reserve("S2", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "staff sixth rejected in coordinator");
        check(limits.reservations().equals(five), "staff limit preserves records");

        ReservationCoordinator defaultStudent = ReservationApplication.create(new RecordingNotificationSender());
        ReservationCoordinator explicitStudent = ReservationApplication.create(new RecordingNotificationSender());
        check(defaultStudent.reserve("B1", "MIC1", day, 7).equals(
                explicitStudent.reserve("B1", "MIC1", day, 7, BorrowerCategory.STUDENT)),
                "default and explicit student results agree");
        check(defaultStudent.reserve("B2", "TRI1", day, 8).equals(
                "Rejected: borrower limit or duration."), "default remains student");

        // Counts belong to a borrower ID across all records, not separate category buckets.
        ReservationCoordinator mixedFixture = ReservationApplication.create(new RecordingNotificationSender());
        for (int i = 1; i <= 3; i++) {
            mixedFixture.reserve("X1", "ITEM" + i, day, 1);
        }
        check(mixedFixture.reserve("X1", "ITEM4", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count three");
        check(mixedFixture.reserve("X1", "ITEM5", day, 1, BorrowerCategory.STAFF)
                .startsWith("Confirmed:"), "supplied staff policy uses existing count four");
        check(mixedFixture.reserve("X1", "ITEM6", day, 1, BorrowerCategory.STAFF).equals(
                "Rejected: borrower limit or duration."), "counts not reset by category selection");
        System.out.println("All " + checks + " Chapter 5 checks passed.");
    }

    private static void sharedPolicyContract(BorrowingPolicy policy) {
        check(!policy.mayRequest(-1, 1), "negative count returns false");
        check(!policy.mayRequest(Integer.MIN_VALUE, 1), "minimum count returns false");
        check(!policy.mayRequest(0, 0), "zero duration returns false");
        check(!policy.mayRequest(0, -1), "negative duration returns false");
        check(!policy.mayRequest(0, Integer.MIN_VALUE), "minimum duration returns false");
        check(policy.mayRequest(0, 1), "documented one-day request allowed");
        check(!policy.mayRequest(Integer.MAX_VALUE, 1), "above documented count limits");
        check(!policy.mayRequest(0, Integer.MAX_VALUE), "above documented duration limits");
        boolean first = policy.mayRequest(1, 2);
        policy.mayRequest(0, 1);
        check(policy.mayRequest(1, 2) == first, "repeat decision unchanged by intervening call");
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action,
                               String label) {
        try {
            action.run();
        } catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.9 after/src/test/java/edu/se506/lending/regression/Chapter06Checks.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ConfirmationFormatter;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.BorrowingPolicy;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;
import edu.se506.lending.infrastructure.notification.ConsoleNotificationSender;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public final class Chapter06Checks {
    private static int checks;

    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 10, 20);
        RecordingNotificationSender inbox = new RecordingNotificationSender();
        ReservationCoordinator c = ReservationApplication.create(inbox);
        String first = c.reserve("B1", "CAM1", day, 2);
        check(first.equals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."),
                "accepted return unchanged");
        check(inbox.received().size() == 1, "one notification per accepted request");
        check(inbox.received().get(0) == c.reservations().get(0), "accepted record supplied");
        List<Reservation> oldInbox = inbox.received();
        expect(UnsupportedOperationException.class, oldInbox::clear, "inbox snapshot unmodifiable");
        check(c.reserve("B2", "CAM1", day.plusDays(1), 1).equals(
                "Rejected: item unavailable."), "conflict result preserved");
        check(inbox.received().size() == 1, "conflict sends nothing");
        c.reserve("B1", "MIC1", day, 8);
        check(inbox.received().size() == 1, "duration rejection sends nothing");
        expect(IllegalArgumentException.class, () -> c.reserve("", "CAM1", day, 1), "bad ID");
        check(inbox.received().size() == 1, "invalid argument sends nothing");
        c.reserve("B2", "CAM1", day.plusDays(2), 1);
        check(inbox.received().size() == 2, "adjacent acceptance sends once");
        check(oldInbox.size() == 1, "earlier inbox snapshot stable");

        FailingSender failure = new FailingSender();
        ReservationCoordinator failed = ReservationApplication.create(failure);
        String failureResult = failed.reserve("B1", "CAM1", day, 2);
        check(failureResult.equals(first + " Notification failed; reservation retained."),
                "explicit partial-success result");
        check(failed.reservations().size() == 1, "notification failure retains accepted record");
        check(failure.calls == 1, "one attempt and no automatic retry");
        check(failed.reserve("B1", "CAM1", day, 2).equals("Rejected: item unavailable."),
                "retrying reservation is still a conflict");
        check(failure.calls == 1, "conflicting repeat does not attempt notification");

        ReservationCoordinator unexpected = ReservationApplication.create(new BrokenSender());
        expect(IllegalStateException.class, () -> unexpected.reserve("B1", "CAM1", day, 2),
                "unexpected sender failure is not swallowed");
        check(unexpected.reservations().size() == 1, "record is stored before the unexpected failure");

        Map<BorrowerCategory, BorrowingPolicy> configured = policies();
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                null, new ConfirmationFormatter(), inbox, new InMemoryReservationRepository()), "null policy map");
        expect(IllegalArgumentException.class, () -> new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules()),
                new ConfirmationFormatter(), inbox, new InMemoryReservationRepository()), "missing staff policy");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, null, inbox, new InMemoryReservationRepository()), "null formatter");
        expect(NullPointerException.class, () -> new ReservationCoordinator(
                configured, new ConfirmationFormatter(), null, new InMemoryReservationRepository()), "null sender");
        Map<BorrowerCategory, BorrowingPolicy> mutable = new EnumMap<>(configured);
        ReservationCoordinator copied = new ReservationCoordinator(mutable,
                new ConfirmationFormatter(), new RecordingNotificationSender(), new InMemoryReservationRepository());
        mutable.put(BorrowerCategory.STUDENT, (count, days) -> false);
        check(copied.reserve("B1", "CAM1", day, 1).startsWith("Confirmed:"),
                "caller map mutation cannot rewire coordinator");
        Map<BorrowerCategory, BorrowingPolicy> restrictive = new EnumMap<>(configured);
        restrictive.put(BorrowerCategory.STUDENT, (count, days) -> false);
        RecordingNotificationSender restrictedInbox = new RecordingNotificationSender();
        ReservationCoordinator restricted = new ReservationCoordinator(restrictive,
                new ConfirmationFormatter(), restrictedInbox, new InMemoryReservationRepository());
        check(restricted.reserve("B1", "CAM1", day, 1).equals(
                "Rejected: borrower limit or duration."), "injected policy is used");
        check(restrictedInbox.received().isEmpty(), "injected rejection sends nothing");

        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        ConsoleNotificationSender console = new ConsoleNotificationSender(
                new PrintStream(bytes, true, StandardCharsets.UTF_8), new ConfirmationFormatter());
        console.send(c.reservations().get(0));
        check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: " + first + System.lineSeparator()),
                "console implementation uses supplied stream");
        PrintStream brokenOutput = new PrintStream(new OutputStream() {
            @Override public void write(int value) throws IOException {
                throw new IOException("Expected output failure");
            }
        });
        ConsoleNotificationSender brokenConsole = new ConsoleNotificationSender(
                brokenOutput, new ConfirmationFormatter());
        expect(NotificationException.class, () -> brokenConsole.send(c.reservations().get(0)),
                "console reports expected output failure");
        expect(NullPointerException.class, () -> inbox.send(null), "null notice rejected");
        System.out.println("All " + checks + " Chapter 6 checks passed.");
    }

    private static Map<BorrowerCategory, BorrowingPolicy> policies() {
        return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                      BorrowerCategory.STAFF, new StaffReservationRules());
    }

    private static final class FailingSender implements NotificationSender {
        private int calls;
        @Override public void send(Reservation reservation) {
            calls++;
            throw new NotificationException("Expected teaching failure");
        }
    }

    // A programming defect, not an expected output failure: it must not be swallowed.
    private static final class BrokenSender implements NotificationSender {
        @Override public void send(Reservation reservation) {
            throw new IllegalStateException("Unexpected programming failure");
        }
    }

    private static void check(boolean condition, String label) {
        if (!condition) throw new AssertionError(label);
        checks++;
    }

    private static void expect(Class<? extends Throwable> type, Runnable action, String label) {
        try { action.run(); }
        catch (Throwable error) {
            check(type.isInstance(error), label + ": wrong exception " + error);
            return;
        }
        throw new AssertionError(label + ": expected " + type.getSimpleName());
    }
}

4.10 after/src/test/java/edu/se506/lending/regression/DirectoryAdapterTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

class DirectoryAdapterTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);
    @ParameterizedTest
    @CsvSource({"S,STUDENT", "T,STAFF"})
    void mapsKnownActiveCategories(String code, BorrowerCategory expected) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, code, "Y"));
        assertEquals(new Borrower("B1", expected), adapter.find("B1").orElseThrow());
    }
    @Test void absenceAndInactiveAreEmpty() {
        assertTrue(new LegacyDirectoryAdapter(id -> null).find("B1").isEmpty());
        assertTrue(new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, "S", "N"))
                .find("B1").isEmpty());
    }
    @ParameterizedTest
    @CsvSource({"X,Y", "S,X", "s,Y", "S,y"})
    void unknownCodesAreErrors(String kind, String status) {
        var adapter = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(id, kind, status));
        assertThrows(DirectoryException.class, () -> adapter.find("B1"));
    }
    @Test void malformedRowsAreErrors() {
        for (var row : new LegacyDirectoryClient.Person[] {
                new LegacyDirectoryClient.Person("OTHER", "S", "Y"),
                new LegacyDirectoryClient.Person(null, "S", "Y"),
                new LegacyDirectoryClient.Person("B1", null, "Y"),
                new LegacyDirectoryClient.Person("B1", "S", null),
                new LegacyDirectoryClient.Person("B1", "X", "N")}) {
            assertThrows(DirectoryException.class, () -> new LegacyDirectoryAdapter(id -> row).find("B1"));
        }
    }
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {" "})
    void invalidIdentifierDoesNotReachProvider(String id) {
        var calls = new AtomicInteger();
        var adapter = new LegacyDirectoryAdapter(key -> { calls.incrementAndGet(); return null; });
        assertThrows(IllegalArgumentException.class, () -> adapter.find(id));
        assertEquals(0, calls.get());
    }
    @Test void translatesExpectedFailureAndKeepsCause() {
        var original = new LegacyDirectoryClient.Failure("offline");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        var result = assertThrows(DirectoryException.class, () -> adapter.find("B1"));
        assertSame(original, result.getCause());
    }
    @Test void unexpectedProgrammingFailureIsNotDisguisedAsAbsence() {
        var original = new IllegalStateException("bug");
        var adapter = new LegacyDirectoryAdapter(id -> { throw original; });
        assertSame(original, assertThrows(IllegalStateException.class, () -> adapter.find("B1")));
    }
    @Test void categoryFromDirectoryControlsReservationPolicy() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var directory = new LegacyDirectoryAdapter(id -> new LegacyDirectoryClient.Person(
                id, id.equals("STAFF1") ? "T" : "S", "Y"));
        var service = new DirectoryReservationService(directory, coordinator);
        assertTrue(service.reserve("STAFF1", "CAM1", start, 10).startsWith("Confirmed: R1"));
        assertEquals("Rejected: borrower limit or duration.", service.reserve("STUDENT1", "MIC1", start, 10));
        assertEquals(1, coordinator.reservations().size());
        assertEquals("STAFF1", coordinator.reservations().get(0).borrowerId());
        assertEquals(1, notices.received().size());
    }
    @Test void absenceAndOutageDoNotStoreNotifyOrConsumeId() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var missing = new DirectoryReservationService(id -> Optional.empty(), coordinator);
        assertEquals("Rejected: borrower not found or inactive.", missing.reserve("B1", "CAM1", start, 2));
        var failing = new DirectoryReservationService(new LegacyDirectoryAdapter(id -> {
            throw new LegacyDirectoryClient.Failure("offline");
        }), coordinator);
        assertThrows(DirectoryException.class, () -> failing.reserve("B1", "CAM1", start, 2));
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(notices.received().isEmpty());
        var valid = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        assertTrue(valid.reserve("B1", "CAM1", start, 2).startsWith("Confirmed: R1"));
    }
    @Test void mismatchedDomainBorrowerIsRejectedBeforeMutation() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower("OTHER", BorrowerCategory.STAFF)), coordinator);
        assertThrows(DirectoryException.class, () -> service.reserve("B1", "CAM1", start, 10));
        assertTrue(coordinator.reservations().isEmpty());
    }
    @Test void localArgumentErrorsPrecedeLookup() {
        var calls = new AtomicInteger();
        var service = new DirectoryReservationService(id -> { calls.incrementAndGet(); return Optional.empty(); },
                ReservationApplication.create(new RecordingNotificationSender()));
        assertThrows(IllegalArgumentException.class, () -> service.reserve("B1", " ", start, 2));
        assertThrows(NullPointerException.class, () -> service.reserve("B1", "CAM1", null, 2));
        assertEquals(0, calls.get());
    }
}

4.11 after/src/test/java/edu/se506/lending/regression/LegacyRegressionTest.java

package edu.se506.lending.regression;



import org.junit.jupiter.api.Test;

class LegacyRegressionTest {
    @Test void chapter02() { Chapter02Checks.main(new String[0]); }
    @Test void chapter03() { Chapter03AcceptanceExamples.main(new String[0]); }
    @Test void chapter05() { Chapter05Checks.main(new String[0]); }
    @Test void chapter06() { Chapter06Checks.main(new String[0]); }
}

4.12 after/src/test/java/edu/se506/lending/regression/LoanServiceTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.LoanService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Loan;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class LoanServiceTest {
    private final LocalDate start = LocalDate.of(2026, 10, 20);

    private ReservationCoordinator reserved() {
        ReservationCoordinator c = ReservationApplication.create(new RecordingNotificationSender());
        c.reserve("B1", "CAM1", start, 2);
        return c;
    }

    @Test void checkoutAndReturnUseTheSuppliedClock() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        Loan active = service.checkOut("R1");
        List<Loan> snapshot = service.loans();
        assertThrows(UnsupportedOperationException.class, snapshot::clear);
        assertEquals(start, active.borrowedOn());
        assertTrue(active.active());
        clock.set(Instant.parse("2026-10-21T09:00:00Z"));
        Loan returned = service.returnLoan("R1");
        assertAll(() -> assertFalse(returned.active()),
                () -> assertEquals(start.plusDays(1), returned.returnedOn()),
                () -> assertTrue(snapshot.get(0).active(), "Old immutable snapshot stays active"),
                () -> assertEquals(List.of(returned), service.loans()));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertEquals(List.of(returned), service.loans());
    }

    @ParameterizedTest
    @ValueSource(strings = {"2026-10-19T12:00:00Z", "2026-10-22T00:00:00Z"})
    void checkoutOutsideHalfOpenPeriodIsRejected(String instant) {
        LoanService service = new LoanService(reserved(), Clock.fixed(Instant.parse(instant), ZoneOffset.UTC));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void unknownReservationAndInactiveReturnChangeNothing() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        assertThrows(IllegalArgumentException.class, () -> service.checkOut("R99"));
        assertThrows(IllegalStateException.class, () -> service.returnLoan("R1"));
        assertTrue(service.loans().isEmpty());
    }

    @Test void aReservationCannotBeCheckedOutTwiceEvenAfterReturn() {
        LoanService service = new LoanService(reserved(), Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        service.checkOut("R1");
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        service.returnLoan("R1");
        List<Loan> before = service.loans();
        assertThrows(IllegalStateException.class, () -> service.checkOut("R1"));
        assertEquals(before, service.loans());
    }

    @Test void unreturnedItemBlocksTheNextAdjacentReservationUntilReturned() {
        ReservationCoordinator c = reserved();
        c.reserve("B2", "CAM1", start.plusDays(2), 1);
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(c, clock);
        service.checkOut("R1");
        clock.set(Instant.parse("2026-10-22T09:00:00Z"));
        assertThrows(IllegalStateException.class, () -> service.checkOut("R2"));
        assertEquals(1, service.loans().size());
        service.returnLoan("R1");
        assertTrue(service.checkOut("R2").active());
    }

    @Test void clockZoneDeterminesTheCheckoutDate() {
        Clock local = Clock.fixed(Instant.parse("2026-10-19T22:30:00Z"), ZoneId.of("Asia/Baghdad"));
        LoanService service = new LoanService(reserved(), local);
        assertEquals(start, service.checkOut("R1").borrowedOn());
    }

    @Test void backwardClockCannotRecordReturnBeforeCheckout() {
        AdjustableClock clock = new AdjustableClock(Instant.parse("2026-10-20T09:00:00Z"));
        LoanService service = new LoanService(reserved(), clock);
        service.checkOut("R1");
        List<Loan> before = service.loans();
        clock.set(Instant.parse("2026-10-19T09:00:00Z"));
        assertThrows(IllegalArgumentException.class, () -> service.returnLoan("R1"));
        assertEquals(before, service.loans());
    }

    @Test void returningLoanDoesNotRewriteHistoricalReservationsOrTheirCount() {
        ReservationCoordinator c = reserved();
        LoanService service = new LoanService(c, Clock.fixed(
                Instant.parse("2026-10-20T12:00:00Z"), ZoneOffset.UTC));
        List<Reservation> before = c.reservations();
        service.checkOut("R1");
        service.returnLoan("R1");
        assertEquals(before, c.reservations());
        assertFalse(c.isAvailable("CAM1", start, start.plusDays(1)));
    }

    static final class AdjustableClock extends Clock {
        private final AtomicReference<Instant> now;
        private final ZoneId zone;
        AdjustableClock(Instant instant) { this(new AtomicReference<>(instant), ZoneOffset.UTC); }
        private AdjustableClock(AtomicReference<Instant> now, ZoneId zone) {
            this.now = now; this.zone = zone;
        }
        void set(Instant instant) { now.set(instant); }
        @Override public ZoneId getZone() { return zone; }
        @Override public Clock withZone(ZoneId zone) { return new AdjustableClock(now, zone); }
        @Override public Instant instant() { return now.get(); }
    }
}

4.13 after/src/test/java/edu/se506/lending/regression/NotificationMockitoTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Reservation;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class NotificationMockitoTest {
    private final LocalDate day = LocalDate.of(2026, 10, 20);

    @Test void acceptedReservationIsSentOnceWithItsDomainDetails() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        c.reserve("B1", "CAM1", day, 2);
        ArgumentCaptor<Reservation> captured = ArgumentCaptor.forClass(Reservation.class);
        verify(sender).send(captured.capture());
        Reservation sent = captured.getValue();
        assertAll(() -> assertEquals("R1", sent.id()),
                () -> assertEquals("B1", sent.borrowerId()),
                () -> assertEquals("CAM1", sent.itemId()),
                () -> assertEquals(day, sent.period().start()),
                () -> assertEquals(day.plusDays(2), sent.period().endExclusive()));
    }

    @Test void rejectedDurationDoesNotNotify() {
        NotificationSender sender = mock(NotificationSender.class);
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Rejected: borrower limit or duration.", c.reserve("B1", "CAM1", day, 8));
        assertTrue(c.reservations().isEmpty());
        verifyNoInteractions(sender);
    }

    @Test void expectedNotificationFailureRetainsReservationWithoutRetry() {
        NotificationSender sender = mock(NotificationSender.class);
        doThrow(new NotificationException("Expected test failure")).when(sender).send(any(Reservation.class));
        ReservationCoordinator c = ReservationApplication.create(sender);
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)."
                + " Notification failed; reservation retained.", c.reserve("B1", "CAM1", day, 2));
        assertEquals(1, c.reservations().size());
        verify(sender, times(1)).send(any(Reservation.class));
    }
}

4.14 after/src/test/java/edu/se506/lending/regression/NotificationPublisherTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.LoanPeriod;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.NotificationPublisher;

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;

class NotificationPublisherTest {
    private Reservation reservation() {
        return new Reservation("R1", "B1", "CAM1", new LoanPeriod(LocalDate.of(2026,10,20), LocalDate.of(2026,10,22)));
    }
    @Test void sendsInRegistrationOrderWithSameImmutableRecord() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var record = reservation();
        publisher.subscribe(value -> { assertSame(record, value); events.add("desk"); });
        publisher.subscribe(value -> { assertSame(record, value); events.add("audit"); });
        publisher.send(record);
        assertEquals(List.of("desk", "audit"), events);
    }
    @Test void expectedFailuresAreCollectedAndLaterObserversStillRun() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var first = new NotificationException("first");
        var second = new NotificationException("second");
        publisher.subscribe(value -> { throw first; });
        publisher.subscribe(value -> events.add("middle"));
        publisher.subscribe(value -> { throw second; });
        publisher.subscribe(value -> events.add("last"));
        var failure = assertThrows(NotificationException.class, () -> publisher.send(reservation()));
        assertEquals(List.of("middle", "last"), events);
        assertArrayEquals(new Throwable[] {first, second}, failure.getSuppressed());
    }
    @Test void coordinatorRetainsRecordAndReportsExpectedPartialFailure() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(value -> { throw new NotificationException("offline"); });
        publisher.subscribe(value -> events.add(value.id()));
        var coordinator = ReservationApplication.create(publisher);
        var result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 2);
        assertTrue(result.endsWith("Notification failed; reservation retained."));
        assertEquals(1, coordinator.reservations().size());
        assertEquals(List.of("R1"), events);
    }
    @Test void rejectedReservationPublishesNothing() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        publisher.subscribe(value -> events.add(value.id()));
        var coordinator = ReservationApplication.create(publisher);
        assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 8));
        assertTrue(events.isEmpty());
    }
    @Test void subscriptionChangesTakeEffectOnNextPublication() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var second = new AtomicReference<NotificationPublisher.Subscription>();
        var changed = new AtomicBoolean();
        publisher.subscribe(value -> {
            events.add("first");
            if (changed.compareAndSet(false, true)) {
                second.get().close();
                publisher.subscribe(later -> events.add("third"));
            }
        });
        second.set(publisher.subscribe(value -> events.add("second")));
        publisher.send(reservation());
        publisher.send(reservation());
        assertEquals(List.of("first", "second", "first", "third"), events);
    }
    @Test void duplicateIdentityAndOldSubscriptionHandleAreHandled() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        NotificationPublisher.Observer observer = value -> events.add(value.id());
        var old = publisher.subscribe(observer);
        assertThrows(IllegalArgumentException.class, () -> publisher.subscribe(observer));
        old.close();
        publisher.subscribe(observer);
        old.close();
        publisher.send(reservation());
        assertEquals(List.of("R1"), events);
    }
    @Test void unexpectedFailureStopsDispatchAfterReservationWasStored() {
        var publisher = new NotificationPublisher();
        var events = new ArrayList<String>();
        var bug = new IllegalStateException("observer bug");
        publisher.subscribe(value -> events.add("first"));
        publisher.subscribe(value -> { throw bug; });
        publisher.subscribe(value -> events.add("last"));
        var coordinator = ReservationApplication.create(publisher);
        assertSame(bug, assertThrows(IllegalStateException.class,
                () -> coordinator.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 2)));
        assertEquals(List.of("first"), events);
        assertEquals(1, coordinator.reservations().size());
    }
    @Test void guardResetsAfterUnexpectedFailure() {
        var publisher = new NotificationPublisher();
        var bad = publisher.subscribe(value -> { throw new IllegalStateException("bug"); });
        assertThrows(IllegalStateException.class, () -> publisher.send(reservation()));
        bad.close();
        assertDoesNotThrow(() -> publisher.send(reservation()));
    }
    @Test void nestedPublicationIsRejectedAndGuardResets() {
        var publisher = new NotificationPublisher();
        var nested = publisher.subscribe(publisher::send);
        assertThrows(IllegalStateException.class, () -> publisher.send(reservation()));
        nested.close();
        assertDoesNotThrow(() -> publisher.send(reservation()));
    }
    @Test void emptyPublisherAndNullArgumentsHaveExplicitOutcomes() {
        var publisher = new NotificationPublisher();
        assertDoesNotThrow(() -> publisher.send(reservation()));
        assertThrows(NullPointerException.class, () -> publisher.send(null));
        assertThrows(NullPointerException.class, () -> publisher.subscribe(null));
    }
}

4.15 after/src/test/java/edu/se506/lending/regression/ReserveEquipmentCommandTest.java

package edu.se506.lending.regression;

import edu.se506.lending.application.Command;
import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.ReserveEquipmentCommand;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class ReserveEquipmentCommandTest {
    private final LocalDate start = LocalDate.of(2026,10,20);
    @Test void constructionIsLazyAndExecutionUsesTheCapturedRequest() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var lookups = new AtomicInteger();
        var service = new DirectoryReservationService(id -> {
            lookups.incrementAndGet();
            return Optional.of(new Borrower(id, BorrowerCategory.STUDENT));
        }, coordinator);
        Command<String> command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertThrows(NullPointerException.class, () -> new ReserveEquipmentCommand(null, "B1", "CAM1", start, 2));
        assertThrows(IllegalArgumentException.class, () -> new ReserveEquipmentCommand(service, " ", "CAM1", start, 2));
        assertThrows(IllegalArgumentException.class, () -> new ReserveEquipmentCommand(service, "B1", null, start, 2));
        assertThrows(NullPointerException.class, () -> new ReserveEquipmentCommand(service, "B1", "CAM1", null, 2));
        assertEquals(0, lookups.get());
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(notices.received().isEmpty());
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", command.execute());
        assertEquals(1, lookups.get());
        assertEquals(1, notices.received().size());
    }
    @Test void replayReevaluatesAndDoesNotPromiseTheSameResult() {
        var notices = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(notices);
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertTrue(command.execute().startsWith("Confirmed:"));
        assertEquals("Rejected: item unavailable.", command.execute());
        assertEquals(1, coordinator.reservations().size());
        assertEquals(1, notices.received().size());
    }
    @Test void receiverStateAtExecutionDeterminesTheResult() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        coordinator.reserve("OTHER", "CAM1", start, 2);
        assertEquals("Rejected: item unavailable.", command.execute());
        assertEquals(1, coordinator.reservations().size());
    }
    @Test void receiverFailurePropagatesWithoutMutation() {
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var original = new DirectoryException("offline");
        var service = new DirectoryReservationService(id -> { throw original; }, coordinator);
        var command = new ReserveEquipmentCommand(service, "B1", "CAM1", start, 2);
        assertSame(original, assertThrows(DirectoryException.class, command::execute));
        assertTrue(coordinator.reservations().isEmpty());
    }
}

4.16 after/src/test/java/edu/se506/lending/structure/DependencyRulesTest.java

package edu.se506.lending.structure;

import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;

/** Checks compiled local dependency direction; not reflection or semantic correctness. */
class DependencyRulesTest {
    private static final String BASE = "edu.se506.lending.";
    private static final Pattern EDGE =
            Pattern.compile("(?m)^\\s*(edu\\.se506\\.lending\\.[\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+.*$");

    @Test void compiledProductionDependenciesFollowDeclaredDirection() throws Exception {
        String output = jdepsOutput();
        var matcher = EDGE.matcher(output);
        int observed = 0;
        List<String> violations = new ArrayList<>();
        while (matcher.find()) {
            observed++;
            String from = matcher.group(1);
            String to = matcher.group(2);
            if (from.startsWith(BASE + "bootstrap.")) continue;
            if (to.startsWith("patterns.") || to.startsWith("behaviour.") || to.startsWith("examples.")) {
                violations.add(from + " -> " + to);
            } else if (to.startsWith(BASE) && !allowed(from, to)) {
                violations.add(from + " -> " + to);
            }
        }
        assertTrue(observed > 0, "jdeps must discover production class dependencies: " + output);
        assertTrue(violations.isEmpty(), String.join(System.lineSeparator(), violations));
    }

    @Test void productionPackagesHaveNoDependencyCycles() throws Exception {
        Map<String, Set<String>> graph = new TreeMap<>();
        var matcher = EDGE.matcher(jdepsOutput());
        while (matcher.find()) {
            String from = packageOf(matcher.group(1));
            String to = matcher.group(2).startsWith(BASE) ? packageOf(matcher.group(2)) : null;
            graph.computeIfAbsent(from, key -> new TreeSet<>());
            if (to != null && !to.equals(from)) graph.get(from).add(to);
        }
        assertTrue(graph.size() > 1, "jdeps must discover more than one production package");
        List<String> cycle = findCycle(graph);
        assertTrue(cycle.isEmpty(), "Package dependency cycle: " + String.join(" -> ", cycle));
    }

    private static String jdepsOutput() throws Exception {
        String executable = System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java";
        String java = Path.of(System.getProperty("java.home"), "bin", executable).toString();
        Process process = new ProcessBuilder(java, "--module", "jdk.jdeps/com.sun.tools.jdeps.Main",
                "-verbose:class", "-filter:none", Path.of("target/classes").toAbsolutePath().toString())
                .redirectErrorStream(true).start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        assertEquals(0, process.waitFor(), output);
        return output;
    }

    private static String packageOf(String type) {
        String outer = type.contains("$") ? type.substring(0, type.indexOf('$')) : type;
        return outer.substring(0, outer.lastIndexOf('.'));
    }

    /** Depth-first search; returns the packages on one cycle, or an empty list. */
    private static List<String> findCycle(Map<String, Set<String>> graph) {
        Set<String> done = new HashSet<>();
        for (String start : graph.keySet()) {
            Deque<String> path = new ArrayDeque<>();
            List<String> found = visit(start, graph, path, done);
            if (!found.isEmpty()) return found;
        }
        return List.of();
    }

    private static List<String> visit(String node, Map<String, Set<String>> graph,
                                      Deque<String> path, Set<String> done) {
        if (path.contains(node)) {
            List<String> cycle = new ArrayList<>();
            boolean inCycle = false;
            for (String element : (Iterable<String>) path::descendingIterator) {
                if (element.equals(node)) inCycle = true;
                if (inCycle) cycle.add(element);
            }
            cycle.add(node);
            return cycle;
        }
        if (!done.add(node)) return List.of();
        path.push(node);
        for (String next : graph.getOrDefault(node, Set.of())) {
            List<String> found = visit(next, graph, path, done);
            if (!found.isEmpty()) return found;
        }
        path.pop();
        return List.of();
    }

    private boolean allowed(String from, String to) {
        if (from.startsWith(BASE + "domain.")) return to.startsWith(BASE + "domain.");
        if (from.startsWith(BASE + "application.port.")) {
            return to.startsWith(BASE + "application.port.") || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "application.")) {
            return to.startsWith(BASE + "application.") || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "infrastructure.")) {
            return to.startsWith(BASE + "infrastructure.") || to.startsWith(BASE + "application.")
                    || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "presentation.")) {
            return to.startsWith(BASE + "presentation.") || to.startsWith(BASE + "application.")
                    || to.startsWith(BASE + "domain.");
        }
        return false;
    }
}

4.17 after/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java

package edu.se506.lending.structure;

import static org.junit.jupiter.api.Assertions.*;
import edu.se506.lending.application.*;
import edu.se506.lending.application.port.*;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.*;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class RepositoryBoundaryTest {
    private final LocalDate start = LocalDate.of(2026,10,20);
    private Reservation record(String id, String item) {
        return new Reservation(id, "B1", item, new LoanPeriod(start, start.plusDays(2)));
    }
    @Test void snapshotsAreStableOrderedAndUnmodifiable() {
        var repository = new InMemoryReservationRepository();
        var empty = repository.findAll();
        var first = record("R1", "CAM1");
        var second = record("R2", "MIC1");
        repository.add(first);
        var earlier = repository.findAll();
        repository.add(second);
        assertTrue(empty.isEmpty());
        assertEquals(List.of(first), earlier);
        assertEquals(List.of(first, second), repository.findAll());
        assertThrows(UnsupportedOperationException.class, () -> earlier.clear());
    }
    @Test void duplicateIdentityDoesNotReplaceOriginal() {
        var repository = new InMemoryReservationRepository();
        var original = record("R1", "CAM1");
        repository.add(original);
        assertThrows(IllegalArgumentException.class, () -> repository.add(record("R1", "OTHER")));
        assertEquals(1, repository.findAll().size());
        assertSame(original, repository.findAll().get(0));
    }
    @Test void nullAdditionDoesNotChangeStore() {
        var repository = new InMemoryReservationRepository();
        assertThrows(NullPointerException.class, () -> repository.add(null));
        assertTrue(repository.findAll().isEmpty());
    }
    @Test void injectedStoreContainsAcceptanceBeforeNotification() {
        var repository = new InMemoryReservationRepository();
        var calls = new AtomicInteger();
        var coordinator = coordinator(repository, reservation -> {
            assertEquals(List.of(reservation), repository.findAll());
            calls.incrementAndGet();
        });
        assertTrue(coordinator.reserve("B1", "CAM1", start, 2).startsWith("Confirmed: R1"));
        assertEquals(repository.findAll(), coordinator.reservations());
        assertEquals(1, calls.get());
    }
    @Test void failingAddDoesNotReachNotification() {
        var failure = new IllegalStateException("store failed before append");
        ReservationRepository repository = new ReservationRepository() {
            public List<Reservation> findAll() { return List.of(); }
            public void add(Reservation reservation) { throw failure; }
        };
        var calls = new AtomicInteger();
        var coordinator = coordinator(repository, reservation -> calls.incrementAndGet());
        assertSame(failure, assertThrows(IllegalStateException.class, () -> coordinator.reserve("B1", "CAM1", start, 2)));
        assertTrue(coordinator.reservations().isEmpty());
        assertEquals(0, calls.get());
    }
    @Test void assemblyCreatesIndependentSessionStores() {
        var first = ReservationApplication.create(new RecordingNotificationSender());
        var second = ReservationApplication.create(new RecordingNotificationSender());
        first.reserve("B1", "CAM1", start, 2);
        assertTrue(second.reservations().isEmpty());
        assertTrue(second.reserve("B1", "CAM1", start, 2).startsWith("Confirmed: R1"));
    }
    @Test void coordinatorRequiresARepository() {
        var policies = Map.<BorrowerCategory, BorrowingPolicy>of(BorrowerCategory.STUDENT, new ReservationRules(),
                BorrowerCategory.STAFF, new StaffReservationRules());
        assertThrows(NullPointerException.class,
                () -> new ReservationCoordinator(policies, new ConfirmationFormatter(), reservation -> { }, null));
    }
    private ReservationCoordinator coordinator(ReservationRepository repository, NotificationSender sender) {
        return new ReservationCoordinator(Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                BorrowerCategory.STAFF, new StaffReservationRules()), new ConfirmationFormatter(), sender, repository);
    }
}

4.18 after/src/test/java/edu/se506/lending/structure/ReservationConsoleTest.java

package edu.se506.lending.structure;

import static org.junit.jupiter.api.Assertions.*;
import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.*;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import edu.se506.lending.presentation.ReservationConsole;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class ReservationConsoleTest {
    @Test void printsExistingSuccessAndRejectionWithoutDuplicatingPolicy() {
        var buffer = new ByteArrayOutputStream();
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT)), coordinator);
        var console = new ReservationConsole(service, new PrintStream(buffer, true, StandardCharsets.UTF_8));
        var start = LocalDate.of(2026,10,20);
        console.reserve("B1", "CAM1", start, 2);
        console.reserve("B1", "MIC1", start, 8);
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22)." + System.lineSeparator()
                + "Rejected: borrower limit or duration." + System.lineSeparator(), buffer.toString(StandardCharsets.UTF_8));
        assertEquals(1, coordinator.reservations().size());
    }
    @Test void staffRequestAboveTheStudentLimitIsDecidedByThePolicyNotTheConsole() {
        var buffer = new ByteArrayOutputStream();
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.STAFF)), coordinator);
        var console = new ReservationConsole(service, new PrintStream(buffer, true, StandardCharsets.UTF_8));
        console.reserve("T1", "CAM1", LocalDate.of(2026,10,20), 10);
        assertEquals("Confirmed: R1 for T1 on CAM1 [2026-10-20, 2026-10-30)." + System.lineSeparator(),
                buffer.toString(StandardCharsets.UTF_8));
    }
    @Test void directoryFailureDoesNotPrintSuccess() {
        var buffer = new ByteArrayOutputStream();
        var failure = new DirectoryException("offline");
        var coordinator = ReservationApplication.create(new RecordingNotificationSender());
        var service = new DirectoryReservationService(id -> { throw failure; }, coordinator);
        var console = new ReservationConsole(service, new PrintStream(buffer, true, StandardCharsets.UTF_8));
        assertSame(failure, assertThrows(DirectoryException.class,
                () -> console.reserve("B1", "CAM1", LocalDate.of(2026,10,20), 2)));
        assertEquals("", buffer.toString(StandardCharsets.UTF_8));
        assertTrue(coordinator.reservations().isEmpty());
    }
}

4.19 after/src/test/java/edu/se506/lending/structure/ValueSemanticsTest.java

package edu.se506.lending.structure;

import static org.junit.jupiter.api.Assertions.*;
import edu.se506.lending.domain.LoanPeriod;
import examples.structure.PeriodValue;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import org.junit.jupiter.api.Test;

class ValueSemanticsTest {
    @Test void separateValueExampleUsesDatesForEqualityAndHashing() {
        var start = LocalDate.of(2026,10,20);
        var first = new PeriodValue(start, start.plusDays(2));
        var same = new PeriodValue(start, start.plusDays(2));
        var different = new PeriodValue(start, start.plusDays(3));
        assertEquals(first, same);
        assertEquals(first.hashCode(), same.hashCode());
        assertEquals(2, new HashSet<>(List.of(first, same, different)).size());
        assertThrows(IllegalArgumentException.class, () -> new PeriodValue(start, start));
    }
    @Test void cumulativePeriodEqualityIsDeliberatelyUnchanged() {
        var start = LocalDate.of(2026,10,20);
        var first = new LoanPeriod(start, start.plusDays(2));
        var second = new LoanPeriod(start, start.plusDays(2));
        assertEquals(first.start(), second.start());
        assertEquals(first.endExclusive(), second.endExclusive());
        assertNotEquals(first, second);
    }
}

4.20 after/src/test/java/patterns/PatternExamplesTest.java

package patterns;

import static org.junit.jupiter.api.Assertions.*;
import static patterns.PatternExamples.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

class PatternExamplesTest {
    @Test void factoryMethodChangesProductWithoutReplacingWorkflow() {
        ExportJob plain = new PlainJob();
        ExportJob html = new HtmlJob();
        assertEquals("TEXT: A&B", plain.run("A&B"));
        assertEquals("<p>A&amp;B</p>", html.run("A&B"));
        assertThrows(NullPointerException.class, () -> html.run(null));
        assertThrows(NullPointerException.class, () -> plain.run(null));
    }
    @Test void creatorInvokesOverriddenHookOnce() {
        var calls = new AtomicInteger();
        ExportJob job = new ExportJob() {
            protected Exporter createExporter() {
                calls.incrementAndGet();
                return value -> "chosen:" + value;
            }
        };
        assertEquals("chosen:R1", job.run("R1"));
        assertEquals(1, calls.get());
    }
    @Test void simpleFactoryCanProduceSameOutputs() {
        assertEquals("TEXT: R1", simpleExporter(false).export("R1"));
        assertEquals("<p>&lt;R1&gt;</p>", simpleExporter(true).export("<R1>"));
    }
    @Test void abstractFactorySuppliesBothProductsInEachFamily() {
        assertEquals("Loan\nR1\n", receipt(new PlainFamily(), "Loan", "R1"));
        assertEquals("<h1>Loan</h1><p>&lt;R1&gt;</p>", receipt(new HtmlFamily(), "Loan", "<R1>"));
    }
    @Test void builderDefaultsAndExplicitOptions() {
        var minimal = ReportRequest.forBorrower("B1").build();
        assertFalse(minimal.includeReturned());
        assertNull(minimal.from());
        assertTrue(minimal.columns().isEmpty());
        var from = LocalDate.of(2026, 10, 20);
        var full = ReportRequest.forBorrower("B1").during(from, from.plusDays(2))
                .includeReturned(true).column("item").build();
        assertEquals("B1", full.borrowerId());
        assertEquals(from, full.from());
        assertEquals(from.plusDays(2), full.toExclusive());
        assertTrue(full.includeReturned());
        assertEquals(List.of("item"), full.columns());
    }
    @Test void builtValueIsIndependentOfReusableBuilder() {
        var builder = ReportRequest.forBorrower("B1").column("item");
        var first = builder.build();
        builder.column("date");
        assertEquals(List.of("item"), first.columns());
        assertEquals(List.of("item", "date"), builder.build().columns());
        assertThrows(UnsupportedOperationException.class, () -> first.columns().add("other"));
    }
    @Test void builderRejectsInvalidConfiguration() {
        var date = LocalDate.of(2026, 10, 20);
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower(" "));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date).build());
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").during(date, date.minusDays(1)).build());
        assertThrows(NullPointerException.class, () -> ReportRequest.forBorrower("B1").during(date, null));
        assertThrows(IllegalArgumentException.class, () -> ReportRequest.forBorrower("B1").column(" "));
    }
    @Test void auditOutsideRetryObservesOneLogicalCall() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new AuditedSender(new RetryOnceSender(flaky), events).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "sent"), events);
    }
    @Test void retryOutsideAuditObservesEachAttempt() {
        var calls = new AtomicInteger();
        Sender flaky = message -> { if (calls.incrementAndGet() == 1) throw new DeliveryFailure("first"); };
        var events = new ArrayList<String>();
        new RetryOnceSender(new AuditedSender(flaky, events)).send("R1");
        assertEquals(2, calls.get());
        assertEquals(List.of("attempt", "failed", "attempt", "sent"), events);
    }
    @Test void secondExpectedFailurePropagatesWithoutThirdAttempt() {
        var calls = new AtomicInteger();
        var original = new DeliveryFailure("still offline");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(DeliveryFailure.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(2, calls.get());
    }
    @Test void unexpectedFailureIsNotRetried() {
        var calls = new AtomicInteger();
        var original = new IllegalArgumentException("bad message");
        Sender failing = message -> { calls.incrementAndGet(); throw original; };
        assertSame(original, assertThrows(IllegalArgumentException.class, () -> new RetryOnceSender(failing).send("R1")));
        assertEquals(1, calls.get());
    }
}

5. Build, documentation and architecture decision

The architecture decision is a reviewable example, with peer review explicitly pending.

5.1 after/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>equipment-lending</artifactId>
  <version>0.10.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

5.2 after/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

mock-maker-proxy

5.3 after/scripts/check.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
"${CHAPTER_MVN:-mvn}" --batch-mode verify

5.4 after/.gitignore

/target/
/build/
*.class

5.4a after/.gitattributes

*.sh text eol=lf

This is the Chapter 4 file that keeps scripts/check.sh in LF form when Git on Windows converts line endings. Without it, a CRLF copy of the script fails under WSL or Linux bash.

5.4b after/tools/PackageMetrics.java

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Prints afferent coupling (Ca), efferent coupling (Ce) and instability
 * I = Ce / (Ca + Ce) for each package of the compiled production classes.
 *
 * Counting unit: classes. Nested classes count with their outer class. Only
 * dependencies between this project's own packages are counted; the standard
 * library is ignored. When Ca + Ce is 0 the instability is undefined.
 *
 * Run from the project root after "mvn compile":
 *   java tools/PackageMetrics.java target/classes
 */
public final class PackageMetrics {
    private static final Pattern EDGE =
            Pattern.compile("(?m)^\\s+([\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+\\S+\\s*$");

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            System.err.println("usage: java tools/PackageMetrics.java <classes directory>");
            System.exit(2);
        }
        String executable = System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java";
        String java = Path.of(System.getProperty("java.home"), "bin", executable).toString();
        Process process = new ProcessBuilder(java, "--module", "jdk.jdeps/com.sun.tools.jdeps.Main",
                "-verbose:class", "-filter:none", Path.of(args[0]).toAbsolutePath().toString())
                .redirectErrorStream(true).start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        if (process.waitFor() != 0) {
            System.err.println(output);
            System.exit(1);
        }

        Map<String, Set<String>> classesIn = new TreeMap<>();
        Map<String, Set<String>> dependersOutside = new TreeMap<>();   // classes outside P that use P
        Map<String, Set<String>> dependersInside = new TreeMap<>();    // classes in P that use outside classes
        Matcher m = EDGE.matcher(output);
        while (m.find()) {
            String from = outer(m.group(1));
            String to = outer(m.group(2));
            if (!local(from)) continue;
            classesIn.computeIfAbsent(pkg(from), k -> new TreeSet<>()).add(from);
            if (!local(to)) continue;
            classesIn.computeIfAbsent(pkg(to), k -> new TreeSet<>()).add(to);
            if (!pkg(from).equals(pkg(to))) {
                dependersOutside.computeIfAbsent(pkg(to), k -> new TreeSet<>()).add(from);
                dependersInside.computeIfAbsent(pkg(from), k -> new TreeSet<>()).add(from);
            }
        }
        System.out.printf("%-48s %7s %4s %4s %9s%n", "package", "classes", "Ca", "Ce", "I");
        for (String p : classesIn.keySet()) {
            int ca = dependersOutside.getOrDefault(p, Set.of()).size();
            int ce = dependersInside.getOrDefault(p, Set.of()).size();
            String instability = ca + ce == 0 ? "undefined" : String.format("%.2f", ce / (double) (ca + ce));
            System.out.printf("%-48s %7d %4d %4d %9s%n", p, classesIn.get(p).size(), ca, ce, instability);
        }
    }

    private static boolean local(String type) {
        return type.startsWith("edu.se506.") || type.startsWith("patterns.")
                || type.startsWith("behaviour.") || type.startsWith("examples.");
    }

    private static String outer(String type) {
        int dollar = type.indexOf('$');
        return dollar < 0 ? type : type.substring(0, dollar);
    }

    private static String pkg(String type) {
        return type.substring(0, type.lastIndexOf('.'));
    }
}

5.5 after/README.md

# Chapter 10 checkpoint

Use a full Java 17 JDK and Maven 3.9.11. The JDK must include jdk.jdeps for the compiled dependency test. Existing dependency and plugin versions are unchanged.

From this directory:

```bash
mvn --batch-mode verify
java -cp target/classes edu.se506.lending.bootstrap.Chapter10Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter09Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter08Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter07Demo
```

The full build executes 121 JUnit invocations: 107 migrated inherited cases, seven repository tests, two value-semantics tests, three console tests and two compiled dependency tests (direction and package cycles). Four inherited cases bridge older plain-Java runners. To print the package metrics used in the chapter, run `mvn --batch-mode compile` and then `java tools/PackageMetrics.java target/classes`.

Production classes now live under edu.se506.lending. Bootstrap chooses concrete implementations. Application and domain do not import infrastructure. Application ports depend only on domain and other ports. Infrastructure may depend on application, domain and infrastructure; presentation may depend on application, domain and presentation. Bootstrap may assemble all of them and launch explicitly isolated teaching examples.

ReservationCoordinator now requires a ReservationRepository as its fourth constructor argument. ReservationApplication.create(sender) retains its method shape in its new package and supplies a fresh InMemoryReservationRepository. Existing source/binary compatibility and old unqualified launcher names are not retained.

The existing result strings, policies, loan lifecycle and notification timing remain unchanged. The new console prints those existing results. Repository extraction does not introduce persistence, concurrency safety or transactionality.

The patterns and behaviour packages remain isolated examples. examples.structure.PeriodValue demonstrates record equality without replacing the cumulative LoanPeriod. ADR-010 is included in docs; its status is implemented teaching example, peer review pending.

5.6 after/docs/ADR-010-application-structure.md

# ADR-010: Application structure and reservation storage boundary

Date: 13 October 2026
Status: Implemented teaching example; peer review pending.

## Context

The Chapter 9 application has useful class boundaries but mostly unnamed-package production code. ReservationCoordinator owns a concrete list. Concrete construction, storage ownership and permitted dependencies are not sufficiently visible for the growing example.

The change must retain the existing sequential use-case outcomes, policy limits, output strings, directory errors, notification timing and loan behaviour. Java package names, imports, launcher names and direct construction may change if documented.

## Decision

Use edu.se506.lending packages for domain, application, application.port, infrastructure, presentation and bootstrap. Application-owned ports express the directory, sender and reservation store contracts. Domain code has no dependencies on the other application packages. Bootstrap selects concrete adapters.

Extract ReservationRepository with findAll and add only. Its in-memory implementation provides insertion-ordered, stable unmodifiable snapshots and rejects null or duplicate IDs without replacing records. The coordinator receives the port as a fourth constructor argument. The usual factory supplies one fresh repository per coordinator/session.

Retain existing domain equality, size-based IDs, string results and loan storage. Keep the separate pattern/lifecycle exercises isolated. Add a thin console adapter and compiled dependency checks, for direction and for package cycles, using the JDK jdeps tool.

## Alternatives considered

- Keep the unnamed package and private list: less code, but weaker visibility of ownership and dependency direction as the example grows.
- Use narrow packages and one storage port: selected because it makes the current boundaries explicit without a framework.
- Introduce a generic CRUD repository, ORM, database or DI framework: declined because those capabilities are not required by this change.

## Consequences

Existing use-case outcomes remain protected by regression tests. Callers must update imports, direct coordinator construction and Java launcher names. The dependency test requires a full Java 17 JDK with jdk.jdeps. More files and one additional storage contract must be maintained.

The application remains sequential and in memory. A repository interface does not provide transactions, durable storage or concurrency safety. The factory's fresh-store ownership is important for the existing identifier scheme. Arbitrary preloaded/shared stores are not promised by this checkpoint.

ConfirmationFormatter remains application-owned to preserve result text. The console notification adapter may use it. Future structured results require a separate reviewed API decision.

## Validation and review

Run mvn --batch-mode verify, compare the earlier demonstration transcripts under their new fully qualified names, inspect compiled dependency directions and package cycles, and review repository ordering/snapshot/duplicate-ID behaviour.

Local author checks are reported in the chapter companion. No hosted CI run, opened PR or independent peer review is claimed. A learner's review must record actual feedback and its resolution.

## Revisit triggers

Persistent storage; multiple writers; a cancellation lifecycle; a new identifier scheme; adoption of value equality in the cumulative period; or structured application results. Each requires a concrete behaviour and compatibility analysis before implementation.

6. Worked explanation and evidence

6.1 The storage extraction

The Chapter 9 starting point was a coordinator-owned list. Chapter 10 replaces that field with a ReservationRepository reference and redirects reads and append through the port. Policy evaluation, date validation, conflict meaning, identifier calculation and notification handling stay in the coordinator.

The in-memory implementation uses a LinkedHashMap so duplicate-ID detection and ordered snapshots have a clear implementation. Duplicate keys are rejected rather than replacing the original record. The adapter does not calculate borrower limits or trigger notifications.

The standard factory preserves the previous ownership model by creating a new repository for each coordinator. The independent-session test verifies that reserving in one session neither populates another nor consumes its R1. The constructor injection point does not itself define a shared or persistent storage lifecycle.

6.2 Source direction versus runtime calls

ReservationCoordinator imports the application port. InMemoryReservationRepository implements that port. Bootstrap imports both the coordinator and the concrete adapter. At runtime, interface dispatch reaches the adapter selected by bootstrap; source dependency still points towards the port.

The infrastructure notification implementation also refers to ConfirmationFormatter in application. This is permitted by the declared matrix and preserves the current text contract. A review should describe that concrete dependency, rather than assuming every infrastructure reference goes only to an interface.

The domain classes do not import application or infrastructure. The compiled dependency test checks this local direction using jdeps output. It also prevents ordinary application code from importing an isolated pattern exercise. A bootstrap demo may refer to one explicitly, which is why the exception is narrow and documented.

6.3 The new presentation adapter

ReservationConsole delegates a request to DirectoryReservationService and prints the existing result. Its tests supply a PrintStream backed by a byte buffer. They observe one confirmation and one duration rejection, then verify that only the accepted request was stored.

A separate test makes the directory fail. The same exception propagates and the output buffer remains empty. The console does not manufacture a success message or silently substitute a category.

The class is intentionally small. It does not read input, manage authentication, catch every exception or implement a graphical interface. Those would be new product requirements. Bootstrap remains responsible for selecting examples and collaborators.

6.4 Equality, identity and snapshot observations

The repository duplicate-ID test creates two distinct Reservation objects with the same identifier and different item data. The second add rejects, and the original object remains stored. This uses explicit domain identity rather than relying on Reservation.equals.

ValueSemanticsTest makes the separate PeriodValue record’s behaviour visible through equals, hashCode and a set. It also deliberately checks that running LoanPeriod objects with equal dates remain unequal under their inherited Java equality. That regression prevents the structural migration from silently adopting a new equality policy.

The repository snapshot test takes an empty snapshot, appends one record, takes another snapshot, and appends a second record. Both earlier snapshots remain unchanged, the new snapshot preserves insertion order, and mutation through the returned list is rejected. This promise relies on immutable values as well as an unmodifiable list.

6.5 New test inventory

Test classInvocationsPurpose
RepositoryBoundaryTest7Snapshot semantics, duplicate/null rejection, storage-before-notification, failed add, independent sessions and a required repository
ValueSemanticsTest2Separate record equality and preserved running period equality
ReservationConsoleTest3Existing output contract, a staff request above the student limit, and unhidden directory failure
DependencyRulesTest2Compiled production references against the local package matrix; no package cycle
Migrated earlier suite107Existing policies, directory, loan, notification, pattern and lifecycle examples
Total121Complete version gate

Three of these tests exist because the fault experiment in Section 11.2 found gaps. coordinatorRequiresARepository pins the constructor’s null check. staffRequestAboveTheStudentLimitIsDecidedByThePolicyNotTheConsole sends a ten-day staff request through the console: a console test that used only a student would let a console that quietly enforced the seven-day rule itself pass. productionPackagesHaveNoDependencyCycles closes the gap that the layer matrix leaves inside one layer.

The failing-add example throws before storing anything. It establishes that notification follows a successful add in this implementation path. It does not establish the outcome of a database write whose acknowledgement is lost. Likewise, the architecture check establishes observed static relationships, not semantic correctness or runtime transactionality.

7. Suggested practical solution

7.1 A reviewable migration sequence

Start from the tested Chapter 9 version. Identify each class’s responsibility before moving it. Introduce the package layout and update imports and launcher names. Keep the separate pattern and lifecycle exercises separate.

Extract the reservation storage port with the minimum operations needed by the coordinator. Move the concrete collection into its adapter. Update bootstrap and direct test constructions to pass that adapter explicitly. Avoid an application constructor that secretly creates infrastructure to retain the old signature.

Run the inherited tests with their expectations unchanged. Then add the repository contract tests, presentation tests and dependency gate. Compare earlier demonstration transcripts under their new launcher names. Write the decision record and ask a reviewer to inspect both behaviour preservation and dependency ownership.

The supplied files are a completed example of that organisation. They are not a claim that a particular Git history or peer review already exists. A learner’s submission should retain its own actual diffs, commands and feedback.

7.2 Suggested architecture decision

The complete ADR-010 is included among the file listings. Its status is implemented teaching example, peer review pending. It records the unnamed-package and coordinator-owned-list starting point, the chosen narrow boundaries, alternatives declined and compatibility costs.

A valid alternative could keep the list inside the coordinator for a permanently small program. Another could place a repository contract in domain if a richer domain model genuinely owns that need. Evaluate those alternatives against actual responsibilities rather than treating the selected package name as the only valid answer.

The worked decision declines a database, generic CRUD framework and dependency-injection framework. Those choices avoid making storage and deployment commitments that the requirement did not request. They do not prevent a later reviewed change.

7.3 Draft PR description for learners to adapt

Status: sample text only; no PR has been opened by this chapter.

The reservation use case has grown beyond a useful unnamed-package layout, and its coordinator directly owns concrete storage. This change introduces named responsibility packages, an application-owned reservation repository port and an in-memory adapter assembled at bootstrap.

Existing policies, result strings, directory errors, loan behaviour and notification timing remain protected by regression tests. Callers must update imports, direct coordinator construction and fully qualified launcher names. The change adds a thin console adapter and a compiled dependency check.

Validation includes the full Maven suite, repository contract checks, existing demonstration comparisons and inspection of the declared dependency directions. The example remains sequential and in memory; persistence, cancellation, identifier redesign and structured result objects are outside this change.

Review should focus on ownership of the port, preservation of storage-before-notification ordering, snapshot semantics and whether each allowed dependency has a concrete reason.

Replace validation statements with the results for the actual submitted revision. Do not present this draft as an independent review or a hosted CI run.

7.4 Model answers: the book’s project steps B1–B3

B1. Classify.

ClassPackageMay refer toMust not refer to
StaffReservationRulesdomaindomainAny application, infrastructure or presentation class, for example NotificationSender
LegacyDirectoryAdapterinfrastructure.directorydomain, application (including its ports) and infrastructurepresentation, bootstrap or an isolated example
DirectoryReservationServiceapplicationdomain, application.port and applicationAny infrastructure class, for example the adapter it receives through its constructor
InMemoryReservationRepositoryinfrastructure.persistencedomain, application.port and infrastructurepresentation or bootstrap
ReservationApplicationbootstrapEvery application package, and explicitly demonstrated examplesNothing in the production matrix is forbidden, but it should hold no business rule

B2. Check the references. A permitted reference in the compiled report is infrastructure.directory.LegacyDirectoryAdapter -> application.port.BorrowerDirectory: infrastructure may refer to the application’s ports, and the adapter implements that contract. The reference that runs from infrastructure.notification to application is ConsoleNotificationSender -> application.ConfirmationFormatter. It exists because the console sender builds its notice text with the shared formatter, which preserves the existing wording.

B3. Diagnose the package design.

(a) Two possible change requests. The console should print the borrower’s name: presentation only, if the service result already carries it, otherwise application too, which shows the string-result limitation. Add an email notifier: a new class in infrastructure.notification and one construction change in bootstrap; the coordinator and the domain are untouched. Accept any request for which the packages are named and the reason is given.

(b) The arrow is infrastructure.notification (I = 0.30) to application (I = 0.31). The difference is too small to act on by itself. What the arrow points at is the concrete dependency on ConfirmationFormatter. Reasonable options: leave it, because the formatter’s output is part of the observable result; or introduce a small formatting port in application.port so that the sender depends only on a contract, at the cost of another interface. Either answer is acceptable if it names the trade-off.

(c) ConfirmationFormatter should stay in application. The coordinator needs it to build the returned text, so moving it into infrastructure.notification would make application depend on infrastructure, which is the same failure as moving NotificationException (Section 10.5 of the chapter). Moving it into domain would put wording in the domain. What would change the answer: a second notification format that needs its own formatter, or the dependency report showing more concrete application classes referenced from infrastructure.

Experiment behind Option B in the chapter. NotificationException was moved to infrastructure.notification in a copy and its imports changed. The direction test failed with application.ReservationCoordinator -> infrastructure.notification.NotificationException, and the cycle test failed with application -> infrastructure.notification -> application. Both tests together reject the placement.

7.5 Guidance for your team’s project steps T1–T5

These are not model answers, because the content is your team’s. They describe what a good answer shows.

  • T1 diagram. Boxes are groups of packages, arrows are permitted references, and every arrow can be justified by one sentence. A good diagram marks at least one forbidden reference, and the dependency check or import review confirms that it does not occur. A weak diagram copies Figure 10.1 without checking it against the team’s code.
  • T2 decision record. The context names a problem someone could observe (for example, “the checkout class creates the payment client and cannot be tested”). The alternatives include doing nothing and say why it was rejected or accepted. The revisit trigger is an event, such as “when a second storage mechanism is needed”.
  • T3 restructuring. Package moves and logic changes are separate commits or clearly separate parts of the diff, and the existing checks pass before and after.
  • T4 dependency evidence. It says whether the check was compiled or manual, lists forbidden references found or states none were, and states whether a cycle search was made.
  • T5 review. It records a real reviewer’s comments and the response, and the Milestone 3 checklist status is honest about gaps.

8. AI activity solution

Fictional claimFindingEvidence to inspect
A runtime call to the in-memory store proves an application import of infrastructureIncorrect: interface dispatch and source references are different relationshipsCoordinator’s port field, bootstrap construction, jdeps edge
Put the repository interface in infrastructureNot justified by this use case; it would make the consuming application depend on an infrastructure-owned contractPort ownership and the declared dependency matrix
Every entity needs repository and service classesNo such requirement follows from identityActual access and orchestration needs; LoanPeriod has neither
An unmodifiable list makes every element immutableIncorrect for mutable elementsList contract plus Reservation/LoanPeriod field semantics
Bootstrap must not import adaptersIncorrect for the selected composition roleExplicit assembly and absence of concrete construction in application
Passing tests establishes peer reviewIncorrect evidence substitutionActual reviewer comments and their resolution

A dependency review should name the referencing type and referenced type. “Infrastructure coupling” without that trace is a claim to investigate. Conversely, legal imports do not prove appropriate responsibilities: a console can duplicate a policy using only standard Java types and still violate the intended design.

The offline example is author-written fictional AI output. If a live assistant is used, retain relevant actual output, independently inspect references and record accepted/rejected claims. No account or paid service is needed for the supplied exercise.

9. Intentionally flawed alternatives

These fragments are diagnostic alternatives, not additional files to compile alongside the final version. Each of them corresponds to a fault that was run in the experiment in Section 11.2.

9.1 Construct infrastructure inside the coordinator

// WRONG for this chapter's declared application dependency rule:
private final ReservationRepository repository = new InMemoryReservationRepository();

Even if reservation behaviour appears unchanged, the application now names a concrete infrastructure type. The dependency check is designed to reject that compiled edge. Bootstrap should construct the implementation and pass the application-owned interface.

9.2 Expose mutable repository storage

// WRONG for the stable, unmodifiable snapshot contract:
public List<Reservation> findAll() {
    return backingList;
}

Callers could change the store without its add checks, and later additions would appear through previously returned views. The final snapshot test checks both non-mutation and historical independence. An unmodifiable view of a live mutable list would still fail the latter promise.

9.3 Replace instead of reject duplicate identity

// WRONG for append-only duplicate-ID rejection if used without the preceding check:
records.put(reservation.id(), reservation);

A second object with the same ID would replace the first. The final implementation checks for the existing key and rejects without mutation. If updates become a requirement, define their meaning and concurrency contract explicitly rather than changing this line under the name of refactoring.

9.4 Enforce the policy in the console

// WRONG: the console now owns a policy that the domain already decides.
if (days > 7) { output.println("Rejected: borrower limit or duration."); return; }

A student request over seven days prints the right text, so the original console test cannot tell this from the correct console. A staff request of ten days, which the staff policy allows, is wrongly rejected. The console test in the final suite sends exactly that request.

10. Review answers and transfer solution

10.1 Concept answers

1. Runtime and source direction: the coordinator refers to ReservationRepository. Bootstrap supplies an object implementing that interface. At runtime a call reaches the concrete method through dispatch; the coordinator need not import the implementation. Static and runtime diagrams answer different questions.

2. Service, policy and repository: an application service coordinates a use case across collaborators. A domain policy expresses a rule such as count/duration eligibility. A repository provides access to stored domain records through a stated contract. None of these labels requires a remote service or a generic framework.

3. Identity and equality: R1 identifies the intended reservation independently of its current data. Immutable fields do not remove that identity. Java equality is a separate implementation choice; this Reservation class retains object equality, so storage keys by its explicit identifier. Value equality must likewise be implemented and tested, as the separate PeriodValue example demonstrates.

4. Dependency evidence: jdeps reports compiled class relationships, and the tests compare local edges with the selected matrix and search the package graph for cycles. They do not prove responsibilities are well chosen, inspect reflection, class names held in strings or configuration, or establish transactionality. Behaviour tests and review remain necessary.

10.2 Applied diagnosis answers

5. Duplicated presentation policy: the console and API can disagree if only one contains the copied seven-day check. Put the rule in the domain policy and delegate both entry points to the same application operation. A test can send the same student request through both entry points and compare outcomes and stored state. Also test an allowed staff duration to expose an over-general console rule that rejects every request above seven days.

6. Duplicate IDs do not prevent concurrent booking conflicts: two requests can both observe a free interval and then append different IDs. Even unique identifiers do not make the preceding eligibility/conflict checks and insertion atomic. Define a transaction, locking or constraint strategy appropriate to the real store and test the relevant concurrent outcome before claiming safety. That implementation is beyond this sequential version.

7. Stability numbers at the extremes: bootstrap has I = 1.00 because nothing depends on it, so it can change freely; that is appropriate for the place that chooses concrete classes. Changing it disturbs nobody else, but it must not accumulate business rules, which would then be hard to reuse or test. domain has I = 0.00 because sixteen classes depend on it and it depends on nothing else in the application. A change to it can ripple into every other package, so it is kept small and free of mechanisms. Neither number is a defect.

8. Cycle between two infrastructure packages: the cycle test catches it, because it searches the package graph. The direction test does not: the matrix lets infrastructure packages refer to each other, so each reference is individually permitted. The fault experiment in Section 11.2 confirms this: the direction test alone let the cycle pass.

10.3 Clinic appointment transfer

Keep appointment times, patient identity and eligibility rules in the domain vocabulary. Put the booking operation in application, with a patient-directory port and a focused appointment-store port as needed. Infrastructure adapts the provider and implements in-memory storage. Presentation delegates booking and presents its result; bootstrap selects concrete collaborators.

An appointment has an explicit identity, while its scheduled interval is a value. Do not assume those concepts automatically provide Java equals/hashCode. Specify the interval’s validation and equality and the store’s duplicate-ID behaviour.

The store contract belongs with the layer that defines its required capabilities. An application-owned port is a reasonable starting point for this small booking use case. Avoid generic operations that imply unagreed cancellation or modification rules.

One preserved behaviour is that a conflicting interval is rejected without storing another appointment or notifying anyone. Compare that outcome before and after restructuring. Provider failure versus missing patient is another useful distinction to preserve. No deployment services or real patient records are needed to demonstrate the design.

11. Instructor notes

11.1 Suggested demonstration and review

Show the coordinator’s imports before drawing the runtime sequence. Ask students to identify where the concrete repository is selected. Then inspect the repository contract and the snapshot test. Use the duplicate-ID example to distinguish identity from Java object equality.

Run the thin console demonstration and the complete build. Ask students which test would detect a misplaced concrete dependency and which would detect altered overlap behaviour. Neither answer should rely only on a green summary line.

For Milestone 3, use the module leader’s approved requirements. The chapter supplies a sample decision and PR description, but learners must obtain and record actual peer review. The sample is an example: it has no real reviewer and no hosted CI run behind it.

**The book’s project and your team’s project. Keep the two apart in class. The supplied version, ADR-010 and the sample PR description are the book’s project: students read, trace and diagnose them (steps B1–B3) and are not asked to rebuild them. Milestone 3 evidence comes from each team’s own repository (steps T1–T5). A team that copies ADR-010’s decision has not shown its own architectural judgement; ask what problem in their code the decision solves. A team on another provided domain applies the same steps to that domain’s starter codebase and uses the transfer challenge for orientation.

Teaching the package-design material. Start from the five change requests in Section 10.5 of the chapter and ask students to predict the packages that change before showing the answers. Then show the metrics table and ask which arrow is the odd one out. Resist the urge to turn the numbers into a score: the point is to decide what to examine. Show the NotificationException experiment only after students have argued for Option A or Option B.

11.2 Do the tests detect faults?

Twenty-five faults were introduced one at a time into copies of the project, and each copy was run through mvn test. They were first run before three tests were added: twenty were caught and five survived. The survivors were then run against the final tests.

AreaFaults triedResult before the three added testsResult with the final tests
Repository contractDuplicate id replaces the original; modifiable snapshot copy; live snapshot view; HashMap instead of insertion orderAll four caught by RepositoryBoundaryTest or the inherited checksUnchanged
Repository contractExplicit null check removedSurvived: removing it still throws NullPointerException, from reservation.id(), so only the message differsSurvives: equivalent behaviour, deliberately not pinned by a message test
CoordinatorStores after notification; reservations() returns a modifiable copy; identifier skips a number; conflict check ignores stored recordsAll four caughtUnchanged
CoordinatorNull repository acceptedSurvived: no test constructed a coordinator without a repositoryCaught by coordinatorRequiresARepository
AssemblyOne repository shared by every sessionCaughtUnchanged
ConsolePrints without a newline; swallows directory failuresBoth caughtUnchanged
ConsoleDuplicates the seven-day ruleSurvived: the console test used only a student, for whom the duplicated rule gives the same outputCaught by staffRequestAboveTheStudentLimitIsDecidedByThePolicyNotTheConsole
Value objectsPeriodValue validation removed; LoanPeriod gains value equalityBoth caughtUnchanged
Dependency directionApplication builds the concrete repository; domain refers to application; presentation refers to infrastructure; port refers to application; application refers to an isolated example; persistence refers to presentation; application refers to bootstrapAll seven caught by the direction testUnchanged
Dependency cycleinfrastructure.directory and infrastructure.notification refer to each otherSurvived: each reference is individually permitted by the matrixCaught by productionPackagesHaveNoDependencyCycles
Dependency evasionA class name held in a stringSurvived: jdeps reads compiled references, not stringsSurvives: a documented limit; review and behaviour tests must cover it

Of the 25 faults, 20 were caught before the three tests were added, and 23 are caught by the final tests. The two that still survive are an equivalent mutant and a known limit of any bytecode-based dependency check. Different chapters used different fault sets, so these counts say nothing about how this suite compares with earlier ones. The seven-day-rule survivor and the cycle survivor are the most instructive: both tests were green, and both gaps were visible only when a plausible wrong implementation was tried.

Four inherited JUnit cases bridge to earlier plain-Java runners, and their counts remain distinct from JUnit invocations. Do not add them as if every unit were the same kind of test.

The results establish the supplied local scenarios and dependency rules. They do not establish persistence, authentication, concurrent consistency or universal behavioural equivalence for unseen consumers.

References

Online references 1–5 checked 12 October 2026 and 7–8 on 13 October 2026; see the student chapter for source context.

  1. Fowler, M., Presentation Domain Data Layering.
  2. Stafford, R., Service Layer.
  3. Hieatt, E. and Mee, R., Repository.
  4. Cockburn, A., Hexagonal Architecture.
  5. Oracle, Java 17 jdeps and Java SE 17 List.
  6. Martin, R. C., Agile Software Development: Principles, Patterns, and Practices. Prentice Hall, 2002. The Week 9 reading is its principles of package design. This book treats package design in Section 10.4 of Chapter 10.
  7. Evans, E., Domain-Driven Design Reference: Definitions and Pattern Summaries. Domain Language, 2015 (CC BY 4.0). Layered Architecture p. 10, Entities p. 11, Value Objects p. 12; opened 13 October 2026.
  8. Wikipedia, Software package metrics. Definitions of afferent coupling, efferent coupling and instability; opened 13 October 2026.

Chapter 11 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 11 Companion: Code, Explanations and Solutions.

Refactoring and Legacy Code
OO Development and Agile Practices

1. Scope and execution

This file accompanies Chapter 11, Refactoring and Legacy Code. It contains the legacy overdue report, every stage of its refactoring, a probe that checks the enabling edit, the characterisation and differential tests, a separate functional change, an optional bridge that feeds the report from the live loans, two model records (a decision record and a suspected-defect register), the build files that changed, model solutions and teaching notes.

Input version. The starting point is the Chapter 10 companion: its 64 listings, its tests and the assertions carried forward from Chapters 3–9 (its Section 2.5). To keep those older, verified listings from being copied a second time, this file lists only the files that are new or changed. Every other file is exactly as in the Chapter 10 companion. The sections below say which is which.

The chapter adds one isolated package, legacy. Production code under edu.se506.lending does not use it, and the dependency test now checks that. Only bootstrap launches it or feeds it rows. No lending behaviour changes.

1.1 Reconstruct the version

  1. Create the directory after and save every listing of the Chapter 10 companion at its path.
  2. Save every listing in Sections 3–5 of this file at its path. Where a path already exists (pom.xml, README.md, DependencyRulesTest.java, PackageMetrics.java), the listing here replaces it.
  3. Save the stage listings in Section 6 under after/stages/ and the probe listings in Section 6.7 under after/probe/. They are not compiled by Maven.

Use a full Java 17 JDK, including the jdk.jdeps module, and Maven 3.9.11. The pinned versions are unchanged (JUnit Jupiter 5.13.4, Mockito 5.20.0, Compiler Plugin 3.14.1, Surefire 3.5.4); the Maven project version becomes 0.11.0. Initial builds need dependency access. Then, from inside after:

mvn --batch-mode verify
java -cp target/classes edu.se506.lending.bootstrap.Chapter11Demo

1.2 Expected output

OVERDUE REPORT 2026-10-20
CAM1 B1 10 day(s) late, fine 4.00
MIC1 B2 8 day(s) late, fine 0.75
LAP1 B4 2 day(s) late, fine 0.00
3 overdue, total fines 4.75

Chapter10Demo and the other earlier demonstrations print what they printed before.

1.3 Run one stage of the refactoring

Each file in Section 6 is the whole OverdueReport.java at one step. To run a stage, copy it over src/main/java/legacy/OverdueReport.java and run only the report’s tests:

cp stages/stage2/OverdueReport.java src/main/java/legacy/OverdueReport.java
mvn --batch-mode -Dtest='OverdueReport*Test' test

Stages 1 to 4 each pass the same 28 invocations. Stage 0 is the code as found and cannot be tested without a change: it reads the system clock, so the tests cannot give it a fixed date. FinePolicy.java can stay in place for every stage; it is first used by stage 3. Stage C is different: it replaces three files (Section 6.6).

1.4 Run the probe

The probe checks the one untested edit. It needs only the compiler, and it is run once, with stage 1 (or the final report) in src/main/java/legacy:

mkdir -p probe/out
javac --release 17 -d probe/out src/main/java/legacy/*.java probe/OriginalUnseamed.java probe/OriginalReportProbe.java
java -cp probe/out legacy.OriginalReportProbe

Expected: Original and seamed reports agree on 5 rows and on an empty list. It uses today’s date, so it refuses to run if midnight passes during the run; rerun it if that happens. It examines five rows and an empty list: it is evidence about the enabling edit, not a proof for every input.

2. Design map

2.1 What is new or changed

PathStatusPurpose
src/main/java/legacy/LoanRow.javaNewThe old system’s export format, unchanged by the refactoring
src/main/java/legacy/FinePolicy.javaNewExtracted class: free days, daily rate, cap
src/main/java/legacy/OverdueReport.javaNewThe refactored report (stage 4)
src/main/java/edu/se506/lending/bootstrap/Chapter11Demo.javaNewPrints the report for a fixed date
src/main/java/edu/se506/lending/bootstrap/OverdueRowSource.javaNew (optional bridge)One complete snapshot of report rows; failure must propagate
src/main/java/edu/se506/lending/bootstrap/OverdueReportService.javaNew (optional bridge)Reads one date from a Clock and one snapshot per report
src/main/java/edu/se506/lending/bootstrap/LoanSnapshotRows.javaNew (optional bridge)Joins loan and reservation snapshots into report rows
src/test/java/legacy/OverdueReportCharacterisationTest.javaNewRecords what the report does today
src/test/java/legacy/OriginalOverdueReport.javaNewTest-only oracle: the original code plus the date parameter
src/test/java/legacy/OverdueReportDifferentialTest.javaNewCompares the report with the oracle on generated inputs
src/test/java/edu/se506/lending/bootstrap/OverdueBridgeTest.javaNew (optional bridge)Clock zone, one snapshot per report, failure, and the join
src/test/java/edu/se506/lending/structure/DependencyRulesTest.javaChangedProduction code must not refer to legacy.
tools/PackageMetrics.javaChangedCounts legacy as one of the project’s own packages
pom.xml, README.mdChangedVersion 0.11.0; description of the version
stages/…NewThe report at each step, and the separate functional change
probe/…NewRuns the original beside the seamed report
docs/ADR-011-legacy-overdue-report.md, docs/SUSPECTED-DEFECTS-legacy-overdue-report.mdNewModel decision record and suspected-defect register

2.2 The pinned behaviour, classified

Every expectation in the characterisation suite was run on the original code first, read, and classified before it was recorded. This table is the review the chapter asks for. The last column is the question to put to the records office; none of these is answered by the code. The register in docs/SUSPECTED-DEFECTS-legacy-overdue-report.md carries the four numbered items.

Pinned behaviourClassRegisterQuestion for the owner
A loan within the grace period is listed with fine 0.00 and counted as overdueSurprisingSD-11-2Should a loan within grace appear in the report at all?
Student fines start on day 3, grow by 0.50 a day and stop at 10.00ExpectedNone
Staff fines start on day 6, grow by 0.25 a day and stop at 5.00ExpectedNone
Any other kind (X, lower-case s, blank) pays 1.00 a day, with no grace and no capSuspected defectSD-11-1Do other kinds exist? If not, should they be rejected?
A loan due today, due later, or returned is not listedExpectedNone
A null kind throws NullPointerException, but only for a loan that is overdue and unreturnedUnknownSD-11-3Can the export contain a null kind?
Amounts use the default locale: 0,50 under Locale.GERMANY, 0.50 otherwiseSurprisingSD-11-4Should the separator follow the machine or the office?
The report uses whichever date the caller passes, so the zone is the caller’s choiceKnown design consequence of the seamNone
build(rows) uses the system date; print(rows) writes to standard outputKnown hidden dependenciesNone; isolated by the seam and the test setups

2.3 Why the refactorings preserve behaviour

Stage 1 (the seam). build(rows) now returns build(rows, LocalDate.now()), and the new two-argument method contains the old body without the line LocalDate today = LocalDate.now();. One expression moved from a statement to an argument. The only observable difference is that a test can now choose the date. The probe in Section 6.7 runs the original beside the seamed version on five rows and an empty list and compares their text, so this claim is also checked by running the code as found.

Stage 2 (Extract Method). fineFor(kind, late) holds the three branches exactly as they were, and money(amount) holds the String.format call. The code moved without being rewritten, so the argument is a reading of the moved text.

Stage 3 (Extract Class and Move Method). The branches become three FinePolicy values. The equivalence is a short algebraic argument, checked by the tests:

Old codeFinePolicy.fineFor(daysLate) with the same numbers
if (late > 2) not taken: fine stays 0daysLate <= graceDays returns 0
fine = (late - 2) * 0.50; if (fine > 10) fine = 10;Math.min((daysLate - graceDays) * ratePerDay, cap)
Staff: the same with 5, 0.25 and 5The same with those values
Other kinds: fine = 1.00 * lateGrace 0, rate 1.00, cap Double.MAX_VALUE: Math.min(late * 1.00, MAX_VALUE)

The comparison fine > 10 followed by an assignment gives the same number as Math.min(fine, 10) for every value, including exactly 10. Likewise the grace comparison could be written < or <= without changing any output, because at exactly the grace day the fine is (grace - grace) * rate = 0; the mutant that makes that change survives for this reason (Section 12).

Stage 4 (rename and tidy). Renames do not change behaviour. String += and StringBuilder.append give the same text, including null printed as the text null for a missing item or borrower. The for-each loop replaces an index loop over the same list, and a null list or a null element still throws NullPointerException. The policy is looked up only for a loan that is unreturned and overdue, as before.

2.4 Carry-forward changes to Chapter 10 files

DependencyRulesTest adds legacy. to the packages that production code may not refer to (it already forbade patterns., behaviour. and examples.). tools/PackageMetrics.java adds legacy. to the packages it counts. The report appears in the metrics output with three classes and Ce = 0, so I = 0.00. Its Ca is 4: the demonstration and the three bridge classes of Section 7.6 refer to it, all in bootstrap. The assertions carried forward from Chapters 3–9 are untouched; see Section 2.5 of the Chapter 10 companion.

3. New and changed production listings

3.1 after/src/main/java/legacy/LoanRow.java

package legacy;

import java.time.LocalDate;

/**
 * One row exported by the old records system. Public mutable fields are the
 * old system's format and other programs read them, so this class stays as it is.
 */
public class LoanRow {
    public String item;
    public String borrower;
    /** "S" for a student, "T" for a staff member, as in the old directory. */
    public String kind;
    public LocalDate due;
    public boolean returned;

    public LoanRow(String item, String borrower, String kind, LocalDate due, boolean returned) {
        this.item = item;
        this.borrower = borrower;
        this.kind = kind;
        this.due = due;
        this.returned = returned;
    }
}

3.2 after/src/main/java/legacy/FinePolicy.java

package legacy;

/** How a fine grows: free days, a daily rate, and the most one loan can cost. */
record FinePolicy(int graceDays, double ratePerDay, double cap) {

    double fineFor(long daysLate) {
        if (daysLate <= graceDays) {
            return 0;
        }
        return Math.min((daysLate - graceDays) * ratePerDay, cap);
    }
}

3.3 after/src/main/java/legacy/OverdueReport.java

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Lists the unreturned loans that are past their due date, with the fine for each. */
public class OverdueReport {
    private static final FinePolicy STUDENT = new FinePolicy(2, 0.50, 10.00);
    private static final FinePolicy STAFF = new FinePolicy(5, 0.25, 5.00);
    /**
     * Suspected defect, preserved on purpose: any other kind pays 1.00 a day with no grace and no cap.
     * Change it only in a separate, agreed functional change (see the chapter, Section 11.7).
     */
    private static final FinePolicy ANY_OTHER_KIND = new FinePolicy(0, 1.00, Double.MAX_VALUE);

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        StringBuilder report = new StringBuilder("OVERDUE REPORT " + today + "\n");
        double totalFines = 0;
        int overdueCount = 0;
        for (LoanRow row : rows) {
            if (row.returned) {
                continue;
            }
            long daysLate = ChronoUnit.DAYS.between(row.due, today);
            if (daysLate > 0) {
                double fine = policyFor(row.kind).fineFor(daysLate);
                report.append(row.item).append(' ').append(row.borrower).append(' ')
                        .append(daysLate).append(" day(s) late, fine ").append(money(fine)).append('\n');
                totalFines += fine;
                overdueCount++;
            }
        }
        report.append(overdueCount).append(" overdue, total fines ").append(money(totalFines)).append('\n');
        return report.toString();
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }

    private static FinePolicy policyFor(String kind) {
        if (kind.equals("S")) {
            return STUDENT;
        }
        if (kind.equals("T")) {
            return STAFF;
        }
        return ANY_OTHER_KIND;
    }

    private static String money(double amount) {
        return String.format("%.2f", amount);
    }
}

3.4 after/src/main/java/edu/se506/lending/bootstrap/Chapter11Demo.java

package edu.se506.lending.bootstrap;

import java.time.LocalDate;
import java.util.List;
import legacy.LoanRow;
import legacy.OverdueReport;

/** Prints the legacy overdue report for a fixed date. The legacy package stays outside the lending layers. */
public final class Chapter11Demo {
    public static void main(String[] args) {
        LocalDate today = LocalDate.of(2026, 10, 20);
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", "S", today.minusDays(10), false),
                new LoanRow("MIC1", "B2", "T", today.minusDays(8), false),
                new LoanRow("TRI1", "B3", "S", today.minusDays(1), true),
                new LoanRow("LAP1", "B4", "S", today.minusDays(2), false));
        System.out.print(OverdueReport.build(rows, today));
    }
}

3.5 after/src/main/java/edu/se506/lending/bootstrap/OverdueRowSource.java

package edu.se506.lending.bootstrap;

import java.util.List;
import legacy.LoanRow;

/** One complete, non-null snapshot of rows for the legacy report. A failure must propagate. */
@FunctionalInterface
public interface OverdueRowSource {
    List<LoanRow> rows();
}

3.6 after/src/main/java/edu/se506/lending/bootstrap/OverdueReportService.java

package edu.se506.lending.bootstrap;

import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import legacy.LoanRow;
import legacy.OverdueReport;

/**
 * Runs the legacy report against the live lending data. It reads one date and one snapshot per
 * call and changes nothing in the lending state. It lives in bootstrap, the only code that may
 * know both the lending layers and the isolated legacy package.
 */
public final class OverdueReportService {
    private final OverdueRowSource source;
    private final Clock clock;

    public OverdueReportService(OverdueRowSource source, Clock clock) {
        this.source = Objects.requireNonNull(source, "source");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public String report() {
        LocalDate today = LocalDate.now(clock);
        List<LoanRow> rows = Objects.requireNonNull(source.rows(), "rows");
        return OverdueReport.build(rows, today);
    }
}

3.7 after/src/main/java/edu/se506/lending/bootstrap/LoanSnapshotRows.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.domain.Loan;
import edu.se506.lending.domain.Reservation;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Supplier;
import legacy.LoanRow;

/**
 * Feeds the legacy report from the lending snapshots. A reservation covers a half-open period, so
 * the last permitted day is the day before its exclusive end. Reads two snapshots one after the
 * other (sequential use only, not an atomic read).
 */
public final class LoanSnapshotRows implements OverdueRowSource {
    private final Supplier<List<Loan>> loans;
    private final Supplier<List<Reservation>> reservations;
    private final Function<String, String> kindOf;

    public LoanSnapshotRows(Supplier<List<Loan>> loans, Supplier<List<Reservation>> reservations,
                            Function<String, String> kindOf) {
        this.loans = Objects.requireNonNull(loans, "loans");
        this.reservations = Objects.requireNonNull(reservations, "reservations");
        this.kindOf = Objects.requireNonNull(kindOf, "kindOf");
    }

    @Override public List<LoanRow> rows() {
        List<Loan> loanSnapshot = List.copyOf(loans.get());
        List<Reservation> reservationSnapshot = List.copyOf(reservations.get());
        List<LoanRow> rows = new ArrayList<>();
        for (Loan loan : loanSnapshot) {
            Reservation reservation = reservationSnapshot.stream()
                    .filter(r -> r.id().equals(loan.reservationId())).findFirst()
                    .orElseThrow(() -> new IllegalStateException(
                            "Missing reservation for loan: " + loan.reservationId()));
            if (!reservation.borrowerId().equals(loan.borrowerId())
                    || !reservation.itemId().equals(loan.itemId())) {
                throw new IllegalStateException("Mismatched loan: " + loan.reservationId());
            }
            String kind = kindOf.apply(loan.borrowerId());
            if (kind == null) {
                throw new IllegalStateException("No kind for borrower: " + loan.borrowerId());
            }
            rows.add(new LoanRow(loan.itemId(), loan.borrowerId(), kind,
                    reservation.period().endExclusive().minusDays(1), !loan.active()));
        }
        return List.copyOf(rows);
    }
}

These three files are the optional bridge of Section 7.6. They live in bootstrap because that is the only code allowed to know both the lending layers and the isolated legacy package.

3.8 after/tools/PackageMetrics.java

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Prints afferent coupling (Ca), efferent coupling (Ce) and instability
 * I = Ce / (Ca + Ce) for each package of the compiled production classes.
 *
 * Counting unit: classes. Nested classes count with their outer class. Only
 * dependencies between this project's own packages are counted; the standard
 * library is ignored. When Ca + Ce is 0 the instability is undefined.
 *
 * Run from the project root after "mvn compile":
 *   java tools/PackageMetrics.java target/classes
 */
public final class PackageMetrics {
    private static final Pattern EDGE =
            Pattern.compile("(?m)^\\s+([\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+\\S+\\s*$");

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            System.err.println("usage: java tools/PackageMetrics.java <classes directory>");
            System.exit(2);
        }
        String executable = System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java";
        String java = Path.of(System.getProperty("java.home"), "bin", executable).toString();
        Process process = new ProcessBuilder(java, "--module", "jdk.jdeps/com.sun.tools.jdeps.Main",
                "-verbose:class", "-filter:none", Path.of(args[0]).toAbsolutePath().toString())
                .redirectErrorStream(true).start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        if (process.waitFor() != 0) {
            System.err.println(output);
            System.exit(1);
        }

        Map<String, Set<String>> classesIn = new TreeMap<>();
        Map<String, Set<String>> dependersOutside = new TreeMap<>();   // classes outside P that use P
        Map<String, Set<String>> dependersInside = new TreeMap<>();    // classes in P that use outside classes
        Matcher m = EDGE.matcher(output);
        while (m.find()) {
            String from = outer(m.group(1));
            String to = outer(m.group(2));
            if (!local(from)) continue;
            classesIn.computeIfAbsent(pkg(from), k -> new TreeSet<>()).add(from);
            if (!local(to)) continue;
            classesIn.computeIfAbsent(pkg(to), k -> new TreeSet<>()).add(to);
            if (!pkg(from).equals(pkg(to))) {
                dependersOutside.computeIfAbsent(pkg(to), k -> new TreeSet<>()).add(from);
                dependersInside.computeIfAbsent(pkg(from), k -> new TreeSet<>()).add(from);
            }
        }
        System.out.printf("%-48s %7s %4s %4s %9s%n", "package", "classes", "Ca", "Ce", "I");
        for (String p : classesIn.keySet()) {
            int ca = dependersOutside.getOrDefault(p, Set.of()).size();
            int ce = dependersInside.getOrDefault(p, Set.of()).size();
            String instability = ca + ce == 0 ? "undefined" : String.format("%.2f", ce / (double) (ca + ce));
            System.out.printf("%-48s %7d %4d %4d %9s%n", p, classesIn.get(p).size(), ca, ce, instability);
        }
    }

    private static boolean local(String type) {
        return type.startsWith("edu.se506.") || type.startsWith("patterns.")
                || type.startsWith("behaviour.") || type.startsWith("examples.")
                || type.startsWith("legacy.");
    }

    private static String outer(String type) {
        int dollar = type.indexOf('$');
        return dollar < 0 ? type : type.substring(0, dollar);
    }

    private static String pkg(String type) {
        return type.substring(0, type.lastIndexOf('.'));
    }
}

4. New and changed test listings

4.1 after/src/test/java/legacy/OverdueReportCharacterisationTest.java

package legacy;

import static org.junit.jupiter.api.Assertions.*;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

/**
 * Characterisation tests: they record what the report does today, including behaviour
 * that looks wrong. A passing test here means "unchanged", not "correct".
 */
class OverdueReportCharacterisationTest {
    private static final LocalDate TODAY = LocalDate.of(2026, 10, 20);
    private static final String HEADER = "OVERDUE REPORT 2026-10-20\n";

    private Locale savedLocale;

    @BeforeEach void pinTheLocale() {
        savedLocale = Locale.getDefault();
        Locale.setDefault(Locale.ROOT);
    }

    @AfterEach void restoreTheLocale() {
        Locale.setDefault(savedLocale);
    }

    private static LoanRow open(String kind, int daysLate) {
        return new LoanRow("CAM1", "B1", kind, TODAY.minusDays(daysLate), false);
    }

    private static String oneLoan(int daysLate, String fine) {
        return HEADER + "CAM1 B1 " + daysLate + " day(s) late, fine " + fine + "\n"
                + "1 overdue, total fines " + fine + "\n";
    }

    // Observed fines for students (2 days of grace, 0.50 a day, capped at 10.00)
    // and staff (5 days of grace, 0.25 a day, capped at 5.00).
    @ParameterizedTest
    @CsvSource({
            "S,1,0.00", "S,2,0.00", "S,3,0.50", "S,10,4.00", "S,22,10.00", "S,23,10.00", "S,100,10.00",
            "T,1,0.00", "T,5,0.00", "T,6,0.25", "T,25,5.00", "T,26,5.00", "T,100,5.00"})
    void finesForStudentsAndStaff(String kind, int daysLate, String fine) {
        assertEquals(oneLoan(daysLate, fine), OverdueReport.build(List.of(open(kind, daysLate)), TODAY));
    }

    // SUSPECTED DEFECT, pinned on purpose: any other kind pays 1.00 a day with no grace and no cap.
    @ParameterizedTest
    @CsvSource({"X,1,1.00", "X,100,100.00", "s,3,3.00"})
    void otherKindsPayTheFullRateWithNoGraceOrCap(String kind, int daysLate, String fine) {
        assertEquals(oneLoan(daysLate, fine), OverdueReport.build(List.of(open(kind, daysLate)), TODAY));
    }

    @Test void aBlankKindIsTreatedLikeAnyOtherKind() {
        assertEquals(oneLoan(2, "2.00"), OverdueReport.build(List.of(open("", 2)), TODAY));
    }

    @Test void loansWithinGraceAreStillListedAndCounted() {
        String report = OverdueReport.build(List.of(open("S", 1), open("T", 4)), TODAY);
        assertEquals(HEADER + "CAM1 B1 1 day(s) late, fine 0.00\n" + "CAM1 B1 4 day(s) late, fine 0.00\n"
                + "2 overdue, total fines 0.00\n", report);
    }

    @Test void loansDueTodayDueLaterOrReturnedAreNotListed() {
        List<LoanRow> rows = List.of(
                open("S", 0),
                new LoanRow("CAM1", "B1", "S", TODAY.plusDays(3), false),
                new LoanRow("CAM1", "B1", "S", TODAY.minusDays(30), true));
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(rows, TODAY));
    }

    @Test void aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored() {
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", null, TODAY.minusDays(30), true),
                new LoanRow("CAM1", "B1", null, TODAY, false));
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(rows, TODAY));
    }

    @Test void anEmptyListGivesTheHeaderAndAZeroTotal() {
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(List.of(), TODAY));
    }

    @Test void rowsAreListedInInputOrderAndTheTotalAddsTheFines() {
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", "S", TODAY.minusDays(10), false),
                new LoanRow("MIC1", "B2", "T", TODAY.minusDays(8), false),
                new LoanRow("TRI1", "B3", "S", TODAY.minusDays(1), true),
                new LoanRow("LAP1", "B4", "S", TODAY.minusDays(2), false));
        assertEquals(HEADER
                + "CAM1 B1 10 day(s) late, fine 4.00\n"
                + "MIC1 B2 8 day(s) late, fine 0.75\n"
                + "LAP1 B4 2 day(s) late, fine 0.00\n"
                + "3 overdue, total fines 4.75\n", OverdueReport.build(rows, TODAY));
    }

    // Hidden dependency: the amounts are formatted in the JVM's default locale.
    // Whether a comma is wanted is a business question; today the report follows the locale.
    @Test void amountsFollowTheDefaultLocale() {
        Locale.setDefault(Locale.GERMANY);
        assertEquals(oneLoan(3, "0,50"), OverdueReport.build(List.of(open("S", 3)), TODAY));
    }

    @Test void aNullKindOrANullListFailsWithNullPointerException() {
        assertThrows(NullPointerException.class, () -> OverdueReport.build(List.of(open(null, 3)), TODAY));
        assertThrows(NullPointerException.class, () -> OverdueReport.build(null, TODAY));
    }

    // Hidden dependency: the entry point without a date reads the system clock.
    @Test void theShortEntryPointUsesTheSystemDate() {
        LocalDate before = LocalDate.now();
        String report = OverdueReport.build(List.of());
        LocalDate after = LocalDate.now();
        assertTrue(report.equals("OVERDUE REPORT " + before + "\n0 overdue, total fines 0.00\n")
                || report.equals("OVERDUE REPORT " + after + "\n0 overdue, total fines 0.00\n"), report);
    }

    // The seam moves the zone decision to the caller: one instant is two different calendar dates.
    @Test void theCallerChoosesTheZoneByChoosingTheDate() {
        Instant instant = Instant.parse("2026-10-21T22:30:00Z");
        LocalDate utcDate = LocalDate.now(Clock.fixed(instant, ZoneOffset.UTC));
        LocalDate baghdadDate = LocalDate.now(Clock.fixed(instant, ZoneId.of("Asia/Baghdad")));
        List<LoanRow> rows = List.of(new LoanRow("CAM1", "B1", "S", LocalDate.of(2026, 10, 21), false));
        assertEquals("OVERDUE REPORT 2026-10-21\n0 overdue, total fines 0.00\n", OverdueReport.build(rows, utcDate));
        assertEquals("OVERDUE REPORT 2026-10-22\nCAM1 B1 1 day(s) late, fine 0.00\n1 overdue, total fines 0.00\n",
                OverdueReport.build(rows, baghdadDate));
    }

    // Hidden dependency: print writes to System.out, which is global state.
    @Test void printWritesTheReportToStandardOutput() {
        PrintStream saved = System.out;
        ByteArrayOutputStream captured = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8));
        try {
            OverdueReport.print(List.of());
        } finally {
            System.setOut(saved);
        }
        String text = captured.toString(StandardCharsets.UTF_8);
        assertTrue(text.startsWith("OVERDUE REPORT ") && text.endsWith("0 overdue, total fines 0.00\n"), text);
    }
}

The example pinTheLocale and restoreTheLocale is part of the test design: the default locale is process-wide state, so the suite sets it before every test and restores it after. The suite therefore cannot safely run in parallel with other tests that format numbers.

4.2 after/src/test/java/legacy/OriginalOverdueReport.java

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/**
 * Test-only oracle: the legacy report exactly as it was when the characterisation tests were written
 * (stage 1: the original plus the date parameter). Never edit this file; it is the reference answer.
 */
final class OriginalOverdueReport {

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        String out = "";
        double total = 0;
        int count = 0;
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = 0;
                if (r.kind.equals("S")) {
                    if (late > 2) {
                        fine = (late - 2) * 0.50;
                        if (fine > 10) {
                            fine = 10;
                        }
                    }
                } else if (r.kind.equals("T")) {
                    if (late > 5) {
                        fine = (late - 5) * 0.25;
                        if (fine > 5) {
                            fine = 5;
                        }
                    }
                } else {
                    fine = 1.00 * late;
                }
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + String.format("%.2f", fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + String.format("%.2f", total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }
}

This is the original code with the date parameter added (stage 1). It lives in the test tree, is package-private, and is the oracle for the differential test. It must never be edited.

4.3 after/src/test/java/legacy/OverdueReportDifferentialTest.java

package legacy;

import static org.junit.jupiter.api.Assertions.*;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import org.junit.jupiter.api.Test;

/**
 * A second safety net: the current report must give the same text as the original code for
 * thousands of generated inputs. It is stronger than a handful of examples, but it can only
 * compare behaviour for the kinds of input the generator produces.
 */
class OverdueReportDifferentialTest {
    private static final LocalDate TODAY = LocalDate.of(2026, 10, 20);
    private static final String[] KINDS = {"S", "T", "X", "s", ""};

    @Test void currentReportMatchesTheOriginalOnGeneratedInputs() {
        Locale saved = Locale.getDefault();
        Locale.setDefault(Locale.ROOT);
        try {
            Random random = new Random(11);
            for (int run = 0; run < 3000; run++) {
                List<LoanRow> rows = new ArrayList<>();
                int size = random.nextInt(7);
                for (int i = 0; i < size; i++) {
                    String kind = KINDS[random.nextInt(KINDS.length)];
                    LocalDate due = TODAY.minusDays(random.nextInt(70) - 5);
                    rows.add(new LoanRow("I" + random.nextInt(9), "B" + random.nextInt(9), kind, due, random.nextInt(5) == 0));
                }
                assertEquals(OriginalOverdueReport.build(rows, TODAY), OverdueReport.build(rows, TODAY),
                        "run " + run + " with " + size + " rows");
            }
        } finally {
            Locale.setDefault(saved);
        }
    }
}

The generator uses a fixed seed so that a failure is repeatable. It produces lists of 0 to 6 rows with five kinds (S, T, X, s, blank), due dates from five days in the future to sixty-four days in the past, and about one row in five returned. It does not produce nulls, empty-string items, or very large lists; the characterisation tests cover the nulls.

4.4 after/src/test/java/edu/se506/lending/structure/DependencyRulesTest.java

package edu.se506.lending.structure;

import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;

/** Checks compiled local dependency direction; not reflection or semantic correctness. */
class DependencyRulesTest {
    private static final String BASE = "edu.se506.lending.";
    private static final Pattern EDGE =
            Pattern.compile("(?m)^\\s*(edu\\.se506\\.lending\\.[\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+.*$");

    @Test void compiledProductionDependenciesFollowDeclaredDirection() throws Exception {
        String output = jdepsOutput();
        var matcher = EDGE.matcher(output);
        int observed = 0;
        List<String> violations = new ArrayList<>();
        while (matcher.find()) {
            observed++;
            String from = matcher.group(1);
            String to = matcher.group(2);
            if (from.startsWith(BASE + "bootstrap.")) continue;
            if (to.startsWith("patterns.") || to.startsWith("behaviour.") || to.startsWith("examples.")
                    || to.startsWith("legacy.")) {
                violations.add(from + " -> " + to);
            } else if (to.startsWith(BASE) && !allowed(from, to)) {
                violations.add(from + " -> " + to);
            }
        }
        assertTrue(observed > 0, "jdeps must discover production class dependencies: " + output);
        assertTrue(violations.isEmpty(), String.join(System.lineSeparator(), violations));
    }

    @Test void productionPackagesHaveNoDependencyCycles() throws Exception {
        Map<String, Set<String>> graph = new TreeMap<>();
        var matcher = EDGE.matcher(jdepsOutput());
        while (matcher.find()) {
            String from = packageOf(matcher.group(1));
            String to = matcher.group(2).startsWith(BASE) ? packageOf(matcher.group(2)) : null;
            graph.computeIfAbsent(from, key -> new TreeSet<>());
            if (to != null && !to.equals(from)) graph.get(from).add(to);
        }
        assertTrue(graph.size() > 1, "jdeps must discover more than one production package");
        List<String> cycle = findCycle(graph);
        assertTrue(cycle.isEmpty(), "Package dependency cycle: " + String.join(" -> ", cycle));
    }

    private static String jdepsOutput() throws Exception {
        String executable = System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java";
        String java = Path.of(System.getProperty("java.home"), "bin", executable).toString();
        Process process = new ProcessBuilder(java, "--module", "jdk.jdeps/com.sun.tools.jdeps.Main",
                "-verbose:class", "-filter:none", Path.of("target/classes").toAbsolutePath().toString())
                .redirectErrorStream(true).start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        assertEquals(0, process.waitFor(), output);
        return output;
    }

    private static String packageOf(String type) {
        String outer = type.contains("$") ? type.substring(0, type.indexOf('$')) : type;
        return outer.substring(0, outer.lastIndexOf('.'));
    }

    /** Depth-first search; returns the packages on one cycle, or an empty list. */
    private static List<String> findCycle(Map<String, Set<String>> graph) {
        Set<String> done = new HashSet<>();
        for (String start : graph.keySet()) {
            Deque<String> path = new ArrayDeque<>();
            List<String> found = visit(start, graph, path, done);
            if (!found.isEmpty()) return found;
        }
        return List.of();
    }

    private static List<String> visit(String node, Map<String, Set<String>> graph,
                                      Deque<String> path, Set<String> done) {
        if (path.contains(node)) {
            List<String> cycle = new ArrayList<>();
            boolean inCycle = false;
            for (String element : (Iterable<String>) path::descendingIterator) {
                if (element.equals(node)) inCycle = true;
                if (inCycle) cycle.add(element);
            }
            cycle.add(node);
            return cycle;
        }
        if (!done.add(node)) return List.of();
        path.push(node);
        for (String next : graph.getOrDefault(node, Set.of())) {
            List<String> found = visit(next, graph, path, done);
            if (!found.isEmpty()) return found;
        }
        path.pop();
        return List.of();
    }

    private boolean allowed(String from, String to) {
        if (from.startsWith(BASE + "domain.")) return to.startsWith(BASE + "domain.");
        if (from.startsWith(BASE + "application.port.")) {
            return to.startsWith(BASE + "application.port.") || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "application.")) {
            return to.startsWith(BASE + "application.") || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "infrastructure.")) {
            return to.startsWith(BASE + "infrastructure.") || to.startsWith(BASE + "application.")
                    || to.startsWith(BASE + "domain.");
        }
        if (from.startsWith(BASE + "presentation.")) {
            return to.startsWith(BASE + "presentation.") || to.startsWith(BASE + "application.")
                    || to.startsWith(BASE + "domain.");
        }
        return false;
    }
}

4.5 after/src/test/java/edu/se506/lending/bootstrap/OverdueBridgeTest.java

package edu.se506.lending.bootstrap;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.LoanService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.domain.Loan;
import edu.se506.lending.domain.LoanPeriod;
import edu.se506.lending.domain.Reservation;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import legacy.LoanRow;

/** Tests the optional bridge that feeds the legacy report from the live lending data. */
class OverdueBridgeTest {
    private static final Clock UTC_EVENING = Clock.fixed(Instant.parse("2026-10-21T22:30:00Z"), ZoneOffset.UTC);
    private static final LoanRow DUE_ON_THE_21ST =
            new LoanRow("CAM1", "B1", "S", LocalDate.of(2026, 10, 21), false);

    private Locale savedLocale;

    @BeforeEach void pinTheLocale() {
        savedLocale = Locale.getDefault();
        Locale.setDefault(Locale.ROOT);
    }

    @AfterEach void restoreTheLocale() {
        Locale.setDefault(savedLocale);
    }

    @Test void theClockZoneDecidesWhichDateTheReportUses() {
        var utc = new OverdueReportService(() -> List.of(DUE_ON_THE_21ST), UTC_EVENING);
        var baghdad = new OverdueReportService(() -> List.of(DUE_ON_THE_21ST),
                UTC_EVENING.withZone(ZoneId.of("Asia/Baghdad")));
        assertEquals("OVERDUE REPORT 2026-10-21\n0 overdue, total fines 0.00\n", utc.report());
        assertEquals("OVERDUE REPORT 2026-10-22\nCAM1 B1 1 day(s) late, fine 0.00\n"
                + "1 overdue, total fines 0.00\n", baghdad.report());
    }

    @Test void eachReportReadsOneFreshSnapshot() {
        AtomicInteger reads = new AtomicInteger();
        OverdueRowSource source = () -> reads.incrementAndGet() == 1 ? List.of(DUE_ON_THE_21ST) : List.of();
        var service = new OverdueReportService(source, UTC_EVENING.withZone(ZoneId.of("Asia/Baghdad")));
        assertTrue(service.report().contains("1 overdue"));
        assertEquals(1, reads.get());
        assertTrue(service.report().contains("0 overdue"));
        assertEquals(2, reads.get());
    }

    @Test void aSourceFailureIsNotAnEmptySuccessfulReport() {
        IllegalStateException failure = new IllegalStateException("Snapshot unavailable");
        var service = new OverdueReportService(() -> { throw failure; }, UTC_EVENING);
        assertSame(failure, assertThrows(IllegalStateException.class, service::report));
    }

    @Test void aNullSnapshotIsNotTreatedAsEmpty() {
        var service = new OverdueReportService(() -> null, UTC_EVENING);
        assertThrows(NullPointerException.class, service::report);
    }

    @Test void dependenciesMustBeSupplied() {
        assertThrows(NullPointerException.class, () -> new OverdueReportService(null, UTC_EVENING));
        assertThrows(NullPointerException.class, () -> new OverdueReportService(List::of, null));
        assertThrows(NullPointerException.class, () -> new LoanSnapshotRows(null, List::of, id -> "S"));
        assertThrows(NullPointerException.class, () -> new LoanSnapshotRows(List::of, null, id -> "S"));
        assertThrows(NullPointerException.class, () -> new LoanSnapshotRows(List::of, List::of, null));
    }

    @Test void theLastPermittedDayIsTheDayBeforeTheExclusiveEnd() {
        var start = LocalDate.of(2026, 10, 20);
        var reservation = new Reservation("R1", "B1", "CAM1", new LoanPeriod(start, start.plusDays(2)));
        var active = new Loan("R1", "B1", "CAM1", start, null);
        var returned = new Loan("R1", "B1", "CAM1", start, start.plusDays(1));
        var openRow = new LoanSnapshotRows(() -> List.of(active), () -> List.of(reservation), id -> "S").rows().get(0);
        var closedRow = new LoanSnapshotRows(() -> List.of(returned), () -> List.of(reservation), id -> "S").rows().get(0);
        assertEquals(LocalDate.of(2026, 10, 21), openRow.due);
        assertFalse(openRow.returned);
        assertTrue(closedRow.returned);
        assertEquals("CAM1", openRow.item);
        assertEquals("B1", openRow.borrower);
        assertEquals("S", openRow.kind);
    }

    @Test void aLoanWithoutAMatchingReservationIsAnError() {
        var start = LocalDate.of(2026, 10, 20);
        var loan = new Loan("R9", "B1", "CAM1", start, null);
        var rows = new LoanSnapshotRows(() -> List.of(loan), List::of, id -> "S");
        assertThrows(IllegalStateException.class, rows::rows);
    }

    @Test void aLoanThatDisagreesWithItsReservationIsAnError() {
        var start = LocalDate.of(2026, 10, 20);
        var reservation = new Reservation("R1", "B1", "CAM1", new LoanPeriod(start, start.plusDays(2)));
        var otherBorrower = new Loan("R1", "B2", "CAM1", start, null);
        var otherItem = new Loan("R1", "B1", "MIC1", start, null);
        assertThrows(IllegalStateException.class,
                () -> new LoanSnapshotRows(() -> List.of(otherBorrower), () -> List.of(reservation), id -> "S").rows());
        assertThrows(IllegalStateException.class,
                () -> new LoanSnapshotRows(() -> List.of(otherItem), () -> List.of(reservation), id -> "S").rows());
    }

    @Test void aBorrowerWithoutAKindIsAnErrorNotARowWithANullKind() {
        var start = LocalDate.of(2026, 10, 20);
        var reservation = new Reservation("R1", "B1", "CAM1", new LoanPeriod(start, start.plusDays(2)));
        var loan = new Loan("R1", "B1", "CAM1", start, null);
        var rows = new LoanSnapshotRows(() -> List.of(loan), () -> List.of(reservation), id -> null);
        assertThrows(IllegalStateException.class, rows::rows);
    }

    @Test void theReportFollowsARealCheckoutAndReturn() {
        Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
        Clock reportDay = Clock.fixed(Instant.parse("2026-10-24T09:00:00Z"), ZoneOffset.UTC);
        ReservationCoordinator coordinator = ReservationApplication.create(new RecordingNotificationSender());
        LoanService loans = new LoanService(coordinator, checkoutDay);
        assertTrue(coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2).startsWith("Confirmed: R1"));
        loans.checkOut("R1");
        var rows = new LoanSnapshotRows(loans::loans, coordinator::reservations, id -> "S");
        var service = new OverdueReportService(rows, reportDay);
        assertEquals("OVERDUE REPORT 2026-10-24\nCAM1 B1 3 day(s) late, fine 0.50\n"
                + "1 overdue, total fines 0.50\n", service.report());
        loans.returnLoan("R1");
        assertEquals("OVERDUE REPORT 2026-10-24\n0 overdue, total fines 0.00\n", service.report());
        assertEquals(1, coordinator.reservations().size());
    }
}

5. Build and documentation

5.1 after/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>equipment-lending</artifactId>
  <version>0.11.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

5.2 after/README.md

# Chapter 11 checkpoint

Use a full Java 17 JDK and Maven 3.9.11. The JDK must include jdk.jdeps for the compiled dependency tests. Existing dependency and plugin versions are unchanged.

From this directory:

```bash
mvn --batch-mode verify
java -cp target/classes edu.se506.lending.bootstrap.Chapter11Demo
java -cp target/classes edu.se506.lending.bootstrap.Chapter10Demo
```

The full build executes 159 JUnit invocations: 121 inherited from Chapter 10 (107 migrated, seven repository, two value-semantics, three console and two compiled dependency tests), 28 for the legacy overdue report (27 characterisation invocations and one differential test) and 10 for the optional bridge to the live loans. Four inherited cases bridge older plain-Java runners.

The package `legacy` stands for the old records system's overdue report. It is isolated, like `patterns`, `behaviour` and `examples`: production code under `edu.se506.lending` must not refer to it, and DependencyRulesTest checks that. Only bootstrap launches it or feeds it rows. LoanRow is the old system's export format and is deliberately unchanged.

The optional bridge (OverdueRowSource, OverdueReportService and LoanSnapshotRows in bootstrap) feeds the report from the live loan and reservation snapshots. It reads one date and one snapshot per report, and a failure to read is never reported as zero overdue loans.

The directory `stages` holds the report at each step of the refactoring. It is not part of the Maven build. To run a stage, copy its OverdueReport.java over src/main/java/legacy/OverdueReport.java and run:

```bash
mvn --batch-mode -Dtest='OverdueReport*Test' test
```

Stages 1 to 4 each pass the same 28 invocations. Stage 0 is the code as found and cannot be tested without a change, because it reads the system clock. stageC is a separate functional change (unknown kinds are rejected) with its own replacement tests; to run it, copy all three of its files to their src locations.

The directory `probe` holds a program that runs the original, unseamed report beside the seamed one and compares their output. It is not part of the Maven build. From this directory, with stage 1 or the final report in place:

```bash
mkdir -p probe/out
javac --release 17 -d probe/out src/main/java/legacy/*.java probe/OriginalUnseamed.java probe/OriginalReportProbe.java
java -cp probe/out legacy.OriginalReportProbe
```

The characterisation tests record what the report does today. A passing characterisation test means unchanged, not correct. Four behaviours are recorded as suspected or surprising in docs/SUSPECTED-DEFECTS-legacy-overdue-report.md and are not fixed here. ADR-011 records the decision to pin, isolate and refactor the report.

To print the package metrics, run `mvn --batch-mode compile` and then `java tools/PackageMetrics.java target/classes`.

ADR-010 from Chapter 10 is still in docs. No hosted CI run and no peer review is claimed.

5.3 after/docs/ADR-011-legacy-overdue-report.md

# ADR-011: Pin, isolate and refactor the legacy overdue report

Date: 13 October 2026
Status: Implemented teaching example; peer review pending.

## Context

The old records system's overdue report is one long static method. It reads the system clock and formats amounts in the default locale. It has three near-identical fine branches and no tests. The records office wants the staff grace period changed, and nobody can say what the report does today.

The report must keep producing the same text for the inputs it accepts. Other programs read its input format (`LoanRow`) and call `build` and `print`.

## Decision

Add a date parameter to `build` as the only edit made before tests exist, and keep the old signature as a wrapper. Pin the current behaviour with characterisation tests, including behaviour that looks wrong, and add a differential test against a frozen copy of the original. Refactor in small steps (Extract Method, Extract Class, Rename) with the tests run after each. Keep the `legacy` package isolated from the lending layers; only bootstrap may launch it.

Do not fix suspected defects during the refactoring. Record them in the suspected-defect register and ask the owner. Apply any agreed correction as a separate functional change with replaced tests.

## Alternatives considered

- Leave the report alone: cheapest now, but the requested change would be an untested edit in several places.
- Rewrite it from the specification: there is no specification, and a rewrite would silently change behaviour that other programs rely on.
- Inject a `Clock` into a new non-static class first: a better final design, but an untested rewrite as a first step.

## Consequences

The report gains a seam, a pinned suite and a small policy class. Locale and standard output remain hidden dependencies handled by test fixtures, not by code. The three suspected defects remain until the owner decides.

## Validation and review

Run the full Maven verification and the stage commands in the chapter companion. No hosted CI run or independent peer review is claimed.

## Revisit triggers

A decision on any suspected defect; a requirement to change fines; a requirement to run the report concurrently or against a persistent source; or a decision to move the report into the application layer.

5.4 after/docs/SUSPECTED-DEFECTS-legacy-overdue-report.md

# Suspected defects: legacy overdue report

Status date: 13 October 2026. Every entry is pinned by a characterisation test and has **not** been fixed. Only the owner of the requirement can change an entry's status.

| ID | Behaviour today | Status | Evidence | Question for the owner |
|---|---|---|---|---|
| SD-11-1 | A borrower kind other than `S` or `T` (including lower-case `s` and a blank) pays 1.00 a day, with no grace and no cap | Suspected defect | `otherKindsPayTheFullRateWithNoGraceOrCap`, `aBlankKindIsTreatedLikeAnyOtherKind` | Do other kinds exist? If not, should they be rejected? |
| SD-11-2 | A loan inside its grace period is listed with fine 0.00 and counted as overdue | Surprising, clarification pending | `loansWithinGraceAreStillListedAndCounted` | Should a loan inside its grace period appear in the report? |
| SD-11-3 | A null kind throws `NullPointerException`, but only for a loan that is unreturned and past its due date | Unknown | `aNullKindOrANullListFailsWithNullPointerException`, `aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored` | Can the export contain a null kind? |
| SD-11-4 | Amounts follow the default locale (`0,50` under `Locale.GERMANY`) | Surprising, clarification pending | `amountsFollowTheDefaultLocale` | Should the separator follow the machine or the office? |

Accepted behaviour (confirmed as intended) is recorded here when the owner confirms it. None has been confirmed yet.

These two records are models of the form. A real project’s records describe its own code, and only the owner of a requirement can change the status of an entry.

6. Stage listings

These files are not part of the Maven build. They show the report at each step. Stage 3 uses FinePolicy from Section 3.2, and stage 4 is the file in Section 3.3.

6.1 after/stages/stage0/OverdueReport.java

The code as found.

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */
public class OverdueReport {

    public static String build(List<LoanRow> rows) {
        String out = "";
        double total = 0;
        int count = 0;
        LocalDate today = LocalDate.now();
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = 0;
                if (r.kind.equals("S")) {
                    if (late > 2) {
                        fine = (late - 2) * 0.50;
                        if (fine > 10) {
                            fine = 10;
                        }
                    }
                } else if (r.kind.equals("T")) {
                    if (late > 5) {
                        fine = (late - 5) * 0.25;
                        if (fine > 5) {
                            fine = 5;
                        }
                    }
                } else {
                    fine = 1.00 * late;
                }
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + String.format("%.2f", fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + String.format("%.2f", total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }
}

6.2 after/stages/stage1/OverdueReport.java

The seam: the only edit made before there were tests.

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */
public class OverdueReport {

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        String out = "";
        double total = 0;
        int count = 0;
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = 0;
                if (r.kind.equals("S")) {
                    if (late > 2) {
                        fine = (late - 2) * 0.50;
                        if (fine > 10) {
                            fine = 10;
                        }
                    }
                } else if (r.kind.equals("T")) {
                    if (late > 5) {
                        fine = (late - 5) * 0.25;
                        if (fine > 5) {
                            fine = 5;
                        }
                    }
                } else {
                    fine = 1.00 * late;
                }
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + String.format("%.2f", fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + String.format("%.2f", total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }
}

6.3 after/stages/stage2/OverdueReport.java

Extract Method: fineFor and money.

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */
public class OverdueReport {

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        String out = "";
        double total = 0;
        int count = 0;
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = fineFor(r.kind, late);
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + money(fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + money(total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }

    private static double fineFor(String kind, long late) {
        double fine = 0;
        if (kind.equals("S")) {
            if (late > 2) {
                fine = (late - 2) * 0.50;
                if (fine > 10) {
                    fine = 10;
                }
            }
        } else if (kind.equals("T")) {
            if (late > 5) {
                fine = (late - 5) * 0.25;
                if (fine > 5) {
                    fine = 5;
                }
            }
        } else {
            fine = 1.00 * late;
        }
        return fine;
    }

    private static String money(double amount) {
        return String.format("%.2f", amount);
    }
}

6.4 after/stages/stage3/OverdueReport.java

Extract Class and Move Method: FinePolicy.

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */
public class OverdueReport {
    private static final FinePolicy STUDENT = new FinePolicy(2, 0.50, 10.00);
    private static final FinePolicy STAFF = new FinePolicy(5, 0.25, 5.00);
    /** Suspected defect, preserved on purpose: any other kind pays 1.00 a day with no grace and no cap. */
    private static final FinePolicy ANY_OTHER_KIND = new FinePolicy(0, 1.00, Double.MAX_VALUE);

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        String out = "";
        double total = 0;
        int count = 0;
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = policyFor(r.kind).fineFor(late);
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + money(fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + money(total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }

    private static FinePolicy policyFor(String kind) {
        if (kind.equals("S")) {
            return STUDENT;
        }
        if (kind.equals("T")) {
            return STAFF;
        }
        return ANY_OTHER_KIND;
    }

    private static String money(double amount) {
        return String.format("%.2f", amount);
    }
}

6.5 Stage 4

Stage 4 is the file in Section 3.3: names, for-each loop and StringBuilder.

6.6 Stage C: the separate functional change

Stage C is not a refactoring. It applies the correction of Section 11.6 of the chapter: an unknown kind is rejected. It replaces three files, and the tests that pinned the suspected defect are replaced on purpose. Treat the rule as illustrative: in a real project the records office decides it.

6.6.1 after/stages/stageC/OverdueReport.java

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** Lists the unreturned loans that are past their due date, with the fine for each. */
public class OverdueReport {
    private static final FinePolicy STUDENT = new FinePolicy(2, 0.50, 10.00);
    private static final FinePolicy STAFF = new FinePolicy(5, 0.25, 5.00);

    public static String build(List<LoanRow> rows) {
        return build(rows, LocalDate.now());
    }

    public static String build(List<LoanRow> rows, LocalDate today) {
        StringBuilder report = new StringBuilder("OVERDUE REPORT " + today + "\n");
        double totalFines = 0;
        int overdueCount = 0;
        for (LoanRow row : rows) {
            if (row.returned) {
                continue;
            }
            long daysLate = ChronoUnit.DAYS.between(row.due, today);
            if (daysLate > 0) {
                double fine = policyFor(row.kind).fineFor(daysLate);
                report.append(row.item).append(' ').append(row.borrower).append(' ')
                        .append(daysLate).append(" day(s) late, fine ").append(money(fine)).append('\n');
                totalFines += fine;
                overdueCount++;
            }
        }
        report.append(overdueCount).append(" overdue, total fines ").append(money(totalFines)).append('\n');
        return report.toString();
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }

    private static FinePolicy policyFor(String kind) {
        if (kind.equals("S")) {
            return STUDENT;
        }
        if (kind.equals("T")) {
            return STAFF;
        }
        throw new IllegalArgumentException("Unknown borrower kind: " + kind);
    }

    private static String money(double amount) {
        return String.format("%.2f", amount);
    }
}

6.6.2 after/stages/stageC/OverdueReportCharacterisationTest.java

Only the part that differs from Section 4.1 is described here: the three parameterised rows for unknown kinds and the blank-kind test are replaced by unknownKindsAreRejected, and one test (anUnknownKindOnAReturnedLoanIsStillIgnored) records that a returned loan never reaches the fine rules. The full file is below.

package legacy;

import static org.junit.jupiter.api.Assertions.*;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

/**
 * Characterisation tests: they record what the report does today, including behaviour
 * that looks wrong. A passing test here means "unchanged", not "correct".
 */
class OverdueReportCharacterisationTest {
    private static final LocalDate TODAY = LocalDate.of(2026, 10, 20);
    private static final String HEADER = "OVERDUE REPORT 2026-10-20\n";

    private Locale savedLocale;

    @BeforeEach void pinTheLocale() {
        savedLocale = Locale.getDefault();
        Locale.setDefault(Locale.ROOT);
    }

    @AfterEach void restoreTheLocale() {
        Locale.setDefault(savedLocale);
    }

    private static LoanRow open(String kind, int daysLate) {
        return new LoanRow("CAM1", "B1", kind, TODAY.minusDays(daysLate), false);
    }

    private static String oneLoan(int daysLate, String fine) {
        return HEADER + "CAM1 B1 " + daysLate + " day(s) late, fine " + fine + "\n"
                + "1 overdue, total fines " + fine + "\n";
    }

    // Observed fines for students (2 days of grace, 0.50 a day, capped at 10.00)
    // and staff (5 days of grace, 0.25 a day, capped at 5.00).
    @ParameterizedTest
    @CsvSource({
            "S,1,0.00", "S,2,0.00", "S,3,0.50", "S,10,4.00", "S,22,10.00", "S,23,10.00", "S,100,10.00",
            "T,1,0.00", "T,5,0.00", "T,6,0.25", "T,25,5.00", "T,26,5.00", "T,100,5.00"})
    void finesForStudentsAndStaff(String kind, int daysLate, String fine) {
        assertEquals(oneLoan(daysLate, fine), OverdueReport.build(List.of(open(kind, daysLate)), TODAY));
    }

    // CHANGED ON PURPOSE (functional change C): an unknown kind used to pay 1.00 a day with no
    // grace and no cap. The records office confirmed that only "S" and "T" exist, so it is now rejected.
    @ParameterizedTest
    @ValueSource(strings = {"X", "s", ""})
    void unknownKindsAreRejected(String kind) {
        IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
                () -> OverdueReport.build(List.of(open(kind, 3)), TODAY));
        assertEquals("Unknown borrower kind: " + kind, failure.getMessage());
    }

    // Unchanged: a loan that is returned or not yet overdue never reaches the fine rules.
    @Test void anUnknownKindOnAReturnedLoanIsStillIgnored() {
        List<LoanRow> rows = List.of(new LoanRow("CAM1", "B1", "X", TODAY.minusDays(30), true));
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(rows, TODAY));
    }

    @Test void loansWithinGraceAreStillListedAndCounted() {
        String report = OverdueReport.build(List.of(open("S", 1), open("T", 4)), TODAY);
        assertEquals(HEADER + "CAM1 B1 1 day(s) late, fine 0.00\n" + "CAM1 B1 4 day(s) late, fine 0.00\n"
                + "2 overdue, total fines 0.00\n", report);
    }

    @Test void loansDueTodayDueLaterOrReturnedAreNotListed() {
        List<LoanRow> rows = List.of(
                open("S", 0),
                new LoanRow("CAM1", "B1", "S", TODAY.plusDays(3), false),
                new LoanRow("CAM1", "B1", "S", TODAY.minusDays(30), true));
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(rows, TODAY));
    }

    @Test void aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored() {
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", null, TODAY.minusDays(30), true),
                new LoanRow("CAM1", "B1", null, TODAY, false));
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(rows, TODAY));
    }

    @Test void anEmptyListGivesTheHeaderAndAZeroTotal() {
        assertEquals(HEADER + "0 overdue, total fines 0.00\n", OverdueReport.build(List.of(), TODAY));
    }

    @Test void rowsAreListedInInputOrderAndTheTotalAddsTheFines() {
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", "S", TODAY.minusDays(10), false),
                new LoanRow("MIC1", "B2", "T", TODAY.minusDays(8), false),
                new LoanRow("TRI1", "B3", "S", TODAY.minusDays(1), true),
                new LoanRow("LAP1", "B4", "S", TODAY.minusDays(2), false));
        assertEquals(HEADER
                + "CAM1 B1 10 day(s) late, fine 4.00\n"
                + "MIC1 B2 8 day(s) late, fine 0.75\n"
                + "LAP1 B4 2 day(s) late, fine 0.00\n"
                + "3 overdue, total fines 4.75\n", OverdueReport.build(rows, TODAY));
    }

    // Hidden dependency: the amounts are formatted in the JVM's default locale.
    // Whether a comma is wanted is a business question; today the report follows the locale.
    @Test void amountsFollowTheDefaultLocale() {
        Locale.setDefault(Locale.GERMANY);
        assertEquals(oneLoan(3, "0,50"), OverdueReport.build(List.of(open("S", 3)), TODAY));
    }

    @Test void aNullKindOrANullListFailsWithNullPointerException() {
        assertThrows(NullPointerException.class, () -> OverdueReport.build(List.of(open(null, 3)), TODAY));
        assertThrows(NullPointerException.class, () -> OverdueReport.build(null, TODAY));
    }

    // Hidden dependency: the entry point without a date reads the system clock.
    @Test void theShortEntryPointUsesTheSystemDate() {
        LocalDate before = LocalDate.now();
        String report = OverdueReport.build(List.of());
        LocalDate after = LocalDate.now();
        assertTrue(report.equals("OVERDUE REPORT " + before + "\n0 overdue, total fines 0.00\n")
                || report.equals("OVERDUE REPORT " + after + "\n0 overdue, total fines 0.00\n"), report);
    }

    // The seam moves the zone decision to the caller: one instant is two different calendar dates.
    @Test void theCallerChoosesTheZoneByChoosingTheDate() {
        Instant instant = Instant.parse("2026-10-21T22:30:00Z");
        LocalDate utcDate = LocalDate.now(Clock.fixed(instant, ZoneOffset.UTC));
        LocalDate baghdadDate = LocalDate.now(Clock.fixed(instant, ZoneId.of("Asia/Baghdad")));
        List<LoanRow> rows = List.of(new LoanRow("CAM1", "B1", "S", LocalDate.of(2026, 10, 21), false));
        assertEquals("OVERDUE REPORT 2026-10-21\n0 overdue, total fines 0.00\n", OverdueReport.build(rows, utcDate));
        assertEquals("OVERDUE REPORT 2026-10-22\nCAM1 B1 1 day(s) late, fine 0.00\n1 overdue, total fines 0.00\n",
                OverdueReport.build(rows, baghdadDate));
    }

    // Hidden dependency: print writes to System.out, which is global state.
    @Test void printWritesTheReportToStandardOutput() {
        PrintStream saved = System.out;
        ByteArrayOutputStream captured = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8));
        try {
            OverdueReport.print(List.of());
        } finally {
            System.setOut(saved);
        }
        String text = captured.toString(StandardCharsets.UTF_8);
        assertTrue(text.startsWith("OVERDUE REPORT ") && text.endsWith("0 overdue, total fines 0.00\n"), text);
    }
}

6.6.3 after/stages/stageC/OverdueReportDifferentialTest.java

The only change from Section 4.3 is KINDS = {"S", "T"} and the comment: the unknown kinds changed on purpose, so the oracle can no longer be the reference for them.

package legacy;

import static org.junit.jupiter.api.Assertions.*;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import org.junit.jupiter.api.Test;

/**
 * A second safety net: the current report must give the same text as the original code for
 * thousands of generated inputs with a known kind (the unknown kinds changed on purpose). It is stronger than a handful of examples, but it can only
 * compare behaviour for the kinds of input the generator produces.
 */
class OverdueReportDifferentialTest {
    private static final LocalDate TODAY = LocalDate.of(2026, 10, 20);
    private static final String[] KINDS = {"S", "T"};

    @Test void currentReportMatchesTheOriginalOnGeneratedInputs() {
        Locale saved = Locale.getDefault();
        Locale.setDefault(Locale.ROOT);
        try {
            Random random = new Random(11);
            for (int run = 0; run < 3000; run++) {
                List<LoanRow> rows = new ArrayList<>();
                int size = random.nextInt(7);
                for (int i = 0; i < size; i++) {
                    String kind = KINDS[random.nextInt(KINDS.length)];
                    LocalDate due = TODAY.minusDays(random.nextInt(70) - 5);
                    rows.add(new LoanRow("I" + random.nextInt(9), "B" + random.nextInt(9), kind, due, random.nextInt(5) == 0));
                }
                assertEquals(OriginalOverdueReport.build(rows, TODAY), OverdueReport.build(rows, TODAY),
                        "run " + run + " with " + size + " rows");
            }
        } finally {
            Locale.setDefault(saved);
        }
    }
}

To try stage C, copy these three files over their counterparts in src and run the full build. It passes 159 invocations.

6.7 The probe

The probe is not part of the Maven build. It runs the original code beside the seamed version through their public entry points.

6.7.1 after/probe/OriginalUnseamed.java

The code as found (stage 0), with only the class name changed so that both versions can be loaded together.

package legacy;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;

/** The report exactly as found (stage 0), with only the class name changed so that it can be run beside the seamed version. */
public final class OriginalUnseamed {

    public static String build(List<LoanRow> rows) {
        String out = "";
        double total = 0;
        int count = 0;
        LocalDate today = LocalDate.now();
        out += "OVERDUE REPORT " + today + "\n";
        for (int i = 0; i < rows.size(); i++) {
            LoanRow r = rows.get(i);
            if (r.returned) {
                continue;
            }
            long late = ChronoUnit.DAYS.between(r.due, today);
            if (late > 0) {
                double fine = 0;
                if (r.kind.equals("S")) {
                    if (late > 2) {
                        fine = (late - 2) * 0.50;
                        if (fine > 10) {
                            fine = 10;
                        }
                    }
                } else if (r.kind.equals("T")) {
                    if (late > 5) {
                        fine = (late - 5) * 0.25;
                        if (fine > 5) {
                            fine = 5;
                        }
                    }
                } else {
                    fine = 1.00 * late;
                }
                out += r.item + " " + r.borrower + " " + late + " day(s) late, fine "
                        + String.format("%.2f", fine) + "\n";
                total += fine;
                count++;
            }
        }
        out += count + " overdue, total fines " + String.format("%.2f", total) + "\n";
        return out;
    }

    public static void print(List<LoanRow> rows) {
        System.out.print(build(rows));
    }
}

6.7.2 after/probe/OriginalReportProbe.java

package legacy;

import java.time.LocalDate;
import java.util.List;
import java.util.Locale;

/**
 * Compares the original, unseamed report with the seamed one through their public entry points.
 * It examines a bounded set of rows and rejects a run that crosses midnight. It is evidence about
 * the enabling edit, not a proof for arbitrary inputs.
 */
public final class OriginalReportProbe {
    public static void main(String[] args) {
        Locale.setDefault(Locale.ROOT);
        LocalDate before = LocalDate.now();
        List<LoanRow> rows = List.of(
                new LoanRow("CAM1", "B1", "S", before.minusDays(10), false),
                new LoanRow("MIC1", "B2", "T", before.minusDays(8), false),
                new LoanRow("TRI1", "B3", "S", before, false),
                new LoanRow("LAP1", "B4", "X", before.minusDays(1), false),
                new LoanRow("PRJ1", "B5", "S", before.minusDays(40), true));
        String original = OriginalUnseamed.build(rows);
        String seamed = OverdueReport.build(rows);
        String empty = OriginalUnseamed.build(List.of());
        String emptySeamed = OverdueReport.build(List.of());
        if (!LocalDate.now().equals(before)) {
            throw new IllegalStateException("The date changed during the probe; run it again.");
        }
        if (!original.equals(seamed) || !empty.equals(emptySeamed)) {
            throw new AssertionError("The seamed report differs from the original:\n" + original + "---\n" + seamed);
        }
        String expectedFirstLine = "OVERDUE REPORT " + before + "\n";
        if (!original.startsWith(expectedFirstLine) || !original.contains("CAM1 B1 10 day(s) late, fine 4.00\n")
                || !original.contains("LAP1 B4 1 day(s) late, fine 1.00\n") || original.contains("TRI1")
                || original.contains("PRJ1") || !original.endsWith("3 overdue, total fines 5.75\n")) {
            throw new AssertionError("Unexpected original output:\n" + original);
        }
        System.out.println("Original and seamed reports agree on " + rows.size() + " rows and on an empty list.");
    }
}

7. Worked explanation and evidence

7.1 The seam, and why it comes first

Without the date parameter nothing can be pinned, because the output changes every day. The edit has three properties that make it safe to make before there are tests. It is mechanical: one expression moves into an argument, and one line is deleted. It is checked by the compiler: if anything else had changed, the code would not compile. And it is visible to a reviewer: the diff is a few lines and can be read in a minute. A larger seam, such as an injected Clock and a non-static class, would be better design, but it would be an untested edit of a method that nobody understands, which is exactly the risk the chapter is about.

The probe of Section 6.7 adds a direct check. It compares the code as found with the seamed version on five rows (a late student, a late staff member, a row due today, an unknown kind and a returned loan) and on an empty list, and it checks one literal result. It examines a bounded set, and it is not a proof, but it is the only check that runs the original. The test theShortEntryPointUsesTheSystemDate covers the delegation itself. It reads the system date before and after the call, because a test that runs across midnight could otherwise fail by chance, and accepts either.

7.2 Pinning, in the order it was done

  1. Run the stage 1 code on a small table of inputs (the table of observed results in Section 11.3 of the chapter) and read every result.
  2. Classify each result (Section 2.2) and write the test with a comment for each surprising or suspected case.
  3. Add the cases that test boundaries (grace and cap on both sides), kinds that are not S or T, the rows that must be ignored, ordering and totals, the locale, nulls, and the two entry points.
  4. Add the differential test as a second net.

The suite then passed on stage 1, which is the point: every expectation describes behaviour the original code really has.

7.3 What the two nets cover

The characterisation tests cover the inputs someone thought of, with exact expected text. The differential test covers 3,000 generated lists, each compared with the oracle. A mistake in a rate or a cap is found by both. A change in how an item name prints is found by the characterisation tests only when an example has that item; the differential test finds it for every generated item. A change that only affects an input the generator cannot produce, such as a null item, is found by neither unless a specific example covers it. This is why the chapter says that the suite shows selected behaviour, not equivalence.

7.4 The commit sequence

#MessageFilesCheck run
1Add a date parameter to OverdueReport.build (seam)OverdueReport.javaCompiles; the probe agrees with the original
2Add characterisation and differential tests for OverdueReportThree test filesAll pass on commit 1
3Extract fineFor and money from OverdueReport.buildOverdueReport.java28 invocations pass
4Extract FinePolicy; keep the unknown-kind policy visibleOverdueReport.java, FinePolicy.java28 invocations pass
5Rename variables and use StringBuilder in OverdueReportOverdueReport.java28 invocations pass
6Reject unknown borrower kinds (functional change)Stage C files159 invocations pass; tests changed on purpose

The first five commits are behaviour-preserving. The sixth is a different kind of commit and is reviewed as one: its message says what behaviour changed and why, and its test diff shows the old expectations being replaced.

7.5 A change request after the refactoring

The staff grace period becomes seven days: new FinePolicy(7, 0.25, 5.00). Running the suite fails five of the 28 report invocations, exactly the ones that describe the staff rule: the rows T,6 (0.25 becomes 0.00), T,25 (5.00 becomes 4.50) and T,26 (5.00 becomes 4.75), the mixed-list test (the staff loan’s fine drops from 0.75 to 0.25), and the differential test. The rows for 1 and 5 days, the 100-day cap and every student row still pass. Those failures are the expected evidence of the intended change, and the next step is to update the expectations in the same commit as the rule. The change is small because the structure was fixed first; it would have been two coordinated edits in one branch of the original method.

7.6 The optional bridge to the live loans

The report was written for the old records system, and it knows nothing about the lending application. The bridge lets the lending application feed it rows, without letting the old code reach into the new.

  • LoanSnapshotRows turns the loan and reservation snapshots into LoanRows. A loan does not carry its due date, so the row’s due date comes from its reservation: the last permitted day is the day before the exclusive end of the reserved period. A reservation for [20 October, 22 October) is due back on 21 October, and the report counts a loan late from 22 October. Taking the exclusive end itself would shift every loan’s lateness by a day. A loan with no matching reservation, a loan that disagrees with its reservation, and a borrower with no kind are each an error, not a row.
  • OverdueReportService reads one date from the supplied Clock and one snapshot per call, and passes both to the report. Because the clock carries a zone, the same instant can give different dates (the test uses 22:30 UTC, which is already 22 October in Baghdad). A source failure propagates: it is never reported as “0 overdue”, which would tell the officer that there is nothing to chase.
  • Where it lives. Both classes are in bootstrap, because only bootstrap may know the lending layers and the isolated legacy package. A real system would move the report into the application layer, which is the natural next refactoring, and would give it a BorrowerDirectory-style port for the kind instead of the function kindOf.
  • Limits. The two snapshots are read one after the other, so the read is not atomic; the application is sequential and in memory. The mapping from a borrower to a kind ("S" or "T") is supplied by the caller, and the tests use a stub.

8. Suggested practical solution

8.1 the book’s project, steps B1–B3

B1. Probe and three further characterisation tests. A good answer runs the probe first and reports its single line of output, then adds three inputs that the suite does not cover, each with a recorded actual result and a classification. Examples with answers on stage 1:

InputObservedClass
Two rows for the same item, both overdueTwo lines, listed in input order, fines addedExpected
A student exactly 22 days late, then 2310.00 for both; the cap is reached at 22Expected (a boundary worth pinning)
A row with a blank item nameA line that starts with a spaceSurprising: ask whether blank names can occur

B2. Two refactoring commits. A good answer shows stage 2 and stage 3 as two separate commits, the suite run after each, and no edit to the expectations. If a step failed, it shows the rollback and the retry. The result should be recognisable as stage 3 even if the names differ.

B3. Rationale and register entry. A good rationale says what changed structurally (the grace number and the rates now appear once each, in a named policy), what was checked after each step, what was deliberately not changed (the odd third policy, the locale-dependent formatting, LoanRow, the public signatures), and which questions remain (Section 2.2). The register entry names evidence, cost and decision as in Section 11.7 of the chapter.

8.1a the book’s project, optional steps B4–B7

B4. Break the boundary on purpose. Changing daysLate > 0 to daysLate >= 0 includes a loan due today, with 0 days late. The tests that fail are loansDueTodayDueLaterOrReturnedAreNotListed, the differential test and the bridge test that follows a real checkout; the failing messages show an extra line and a count that is one higher. Restore the original and rerun. A good answer predicts the failures before running them.

B5. A separate functional change. See Section 6.6 and the commit in Section 7.4. The commit message should say that unknown kinds are now rejected, that the three pinned expectations were replaced on purpose, and that the differential test is restricted to known kinds.

B6. AI activity. See Section 9.

B7. Connect the report to the live loans. See Section 7.6. A good answer traces [20 October, 22 October) to a due date of 21 October, explains why source.rows() is not wrapped in a try that returns an empty list, and adds a test (for example a loan whose borrower kind is "T", checked at the staff grace boundary).

8.2 your team’s project, steps T1–T4

These are not model answers, because the code is your team’s. A good answer shows:

  • T1: a reading note with three columns (observation, how you know, status). It names at least one thing that was known only by running the code.
  • T2: at least six characterisation tests, including a boundary and an odd case; each result classified; the seam edit, if any, small, described and compared with the original on a few inputs; global state restored.
  • T3: at least three commits, each one refactoring, tests run before each; no behaviour change among them; a rollback if a step failed.
  • T4: a rationale that separates structure from behaviour, lists suspected defects and questions found but not fixed, and a reviewer’s feedback with the response.

A weak answer lists smells without a change that they block, pins only the happy path, or fixes a suspected defect inside a refactoring commit.

9. AI activity solution

The example makes four statements: the new overdue test is equivalent to daysLate > 0; the policy can be chosen before the check; unknown kinds can fall back to the student policy; and behaviour is preserved because formatting is unchanged. The first three are claims about behaviour, and each was applied to its own copy of the final code and run against the suite, then all three together.

ClaimFindingEvidence (report and bridge tests that fail)
“!row.due.isAfter(today) is equivalent to daysLate > 0”False on the due date. For a row due today, daysLate is 0, so the original skips it, and isAfter is false, so the patch includes it5 invocations: loansDueTodayDueLaterOrReturnedAreNotListed, theCallerChoosesTheZoneByChoosingTheDate, aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored (a null-kind row due today is now included, so it throws), the differential test, and the bridge test theClockZoneDecidesWhichDateTheReportUses
“Choose the policy before the check”A second behaviour change, easy to miss. A row that is not yet due, or due today, now has its kind inspected, so a null kind throws NullPointerException where it used to be ignored1 invocation: aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored. It is the only test that notices, and it was added after the first fault experiment found that nothing did
“Unknown kinds fall back to the student policy”A functional change presented as a tidy-up. X, s and blank paid 1.00 a day; they now follow the student rules. The suspected defect is changed without a decision5 invocations: the three rows of otherKindsPayTheFullRateWithNoGraceOrCap, aBlankKindIsTreatedLikeAnyOtherKind and the differential test
“Formatting is unchanged, so behaviour is preserved”The premise is true and the conclusion is false: formatting is not the only behaviour9 of the 38 report and bridge invocations fail: the union of the three sets above (the differential test appears in two of them, and the null-kind test in two)

What to do with the patch: reject it as a refactoring. The first two claims can be repaired, keeping the neater structure while restoring the original overdue test and the original lookup inside it. The third needs a decision from the records office (entries SD-11-1 in the register), and its own commit with replaced tests (stage C does it one way).

Notice the difference between tracing and running. The equality case can be settled by tracing the due date before any test runs. The null-kind change is not visible from the patch text at all unless you ask what each line now does for a row that is not overdue, and the suite finds it only because a test was added after the first fault experiment (Section 12). Applying the claims one at a time is what lets each failing test point at one claim.

If a live assistant is used, keep its actual output, apply any patch to a scratch copy, and record which tests fail. Do not use a generated test to approve a generated change.

10. Intentionally flawed alternatives

These fragments are diagnostic alternatives, not files to compile. Each corresponds to a fault that was run in the experiment in Section 12 or to a mistake in Section 11.9 of the chapter.

10.1 Fix the defect while refactoring

// WRONG for a refactoring commit: this changes behaviour for unknown kinds.
private static FinePolicy policyFor(String kind) {
    if (kind.equals("S")) return STUDENT;
    if (kind.equals("T")) return STAFF;
    throw new IllegalArgumentException("Unknown borrower kind: " + kind);
}

The code may be better. The commit is no longer a refactoring: three pinned tests fail and the reviewer cannot tell which failures were intended. Do it in its own commit (stage C).

10.2 Blind pasting of results

@Test void x() { assertEquals("", OverdueReport.build(rows, TODAY)); }  // run, then paste whatever comes out

A suite built this way would bless every oddity, including defects. Each recorded result must be read and classified, and the odd ones commented.

10.3 A seam that rewrites the method

// WRONG as a first step: an untested rewrite to make the code testable.
public final class OverdueReport {
    private final Clock clock;
    public OverdueReport(Clock clock) { this.clock = clock; }
    public String build(List<LoanRow> rows) { /* reorganised body */ }
}

This is a better design and the wrong first move. The method changed shape, and every caller must change, while there is still no test. Add the parameter first, pin, and consider the object design afterwards.

10.4 Locale left set after a test

@Test void amountsFollowTheDefaultLocale() {
    Locale.setDefault(Locale.GERMANY);   // never restored
    ...
}

Every later test in the same process now formats with a comma. The chapter’s example restores the locale in @AfterEach.

10.5 A boundary rewrite taken as equivalent

// WRONG as a refactoring: this includes a loan due today, which the original skipped.
if (!row.due.isAfter(today)) { ... }

isAfter and a day count differ exactly on the due date. Reading the line as “not later than today” hides the difference; tracing a row due today shows it. Section 9 runs it.

11. Review answers and transfer solution

11.1 Concept answers

1. Characterisation versus regression. A regression test protects behaviour that is agreed to be correct; a characterisation test records whatever the code does today. A passing characterisation test tells you the behaviour is unchanged, not that it is right.

2. Seam. A seam is a place where behaviour can be changed without editing the code there; here the second parameter of build is the enabling point. It is a better first edit than a rewrite because it is mechanical, compiler-checked and small enough to review, so it can be made safely before any test exists.

3. Pin, not fix. A refactoring is defined by unchanged behaviour. A fix changes behaviour, so combining them makes a failing test ambiguous and a review impossible. Pinning keeps the suspect behaviour visible and protected until the owner decides.

4. Differential test. It can compare thousands of generated inputs with the original code, so it finds differences nobody thought to write an example for. It cannot test inputs the generator does not produce, and it is only as good as the oracle; it shows selected equivalence, not proof.

11.2 Applied diagnosis answers

5. “All tests pass, so nothing changed.” The suite does not cover, for example, the report for dates in other time zones or at midnight (the short entry point reads the clock), items or borrowers that are null (a null name prints as null, which the differential test does not generate), or very large lists. Handle each by adding a test for the case you are about to touch, or by recording it as a known gap in the rationale.

6. Mixed commit. Changing the staff cap in a refactoring commit makes the commit unreviewable as “no behaviour change”. The suite would fail on the staff rows and the differential test, which would be mistaken for a refactoring error, or the developer would edit the expectations to make them pass and hide the change. Revert the cap change, refactor, and then change the cap in its own commit with its tests.

7. The zone question. At 22:30 UTC it is 01:30 on 22 October in Baghdad (UTC+3). A report run with a Baghdad clock uses 22 October, so the loan due on 21 October is one day late and prints with fine 0.00 (inside the grace period, but listed). A report run with a UTC clock uses 21 October and does not list it. The object that chooses the zone is the Clock supplied to OverdueReportService, through LocalDate.now(clock); the report itself only receives a date.

11.3 ML-serving transfer

An analysis script that formats prediction scores has the same three kinds of hidden dependency: the current time (a date in the summary), the locale (number formatting) and standard output. Add a date parameter to the entry point as the seam; pin the locale in the example data; capture standard output once. Pin six or seven inputs: a score at each boundary of a model type’s rule, one at the maximum, an unknown model type, an empty list, a null, and the locale case. Apply Extract Method for the formatting, Extract Class for the three near-identical branches (a small value object holding what differs), and Rename. Record as a question, not a fix, whatever an unknown model type does today.

11.4 Quiz 2 revision pointers

Section 11.11 of the chapter lists the revision focus for Chapters 6–10. This file adds nothing to the quiz.

12. Instructor notes

12.1 Suggested demonstration and review

Run the original report with two different dates (through the seam) to show why it cannot be tested before the seam exists. Run the probe once. Then pin one surprising result live, with its comment, and trace a row due today through daysLate > 0 and through !row.due.isAfter(today) before showing the AI patch. Let students predict, before running, what an unknown kind pays, and discuss why “it looks wrong” is not yet a reason to change it.

Review the commits as a reviewer would: for each refactoring commit, ask for the test run that preceded it and look for any edit that is not a move or a rename. Ask students which of the pinned behaviours they would raise with the records office first.

Keep the legacy lab outside Quiz 2. The chapter lists the quiz scope (Weeks 5–9) and the revision pointers.

**The book’s project and your team’s project. The supplied report is the book’s project: students extend its tests and take the first refactoring steps. your team’s project is the team’s own method from the provided starter codebase, which contains intentional smells. A team on another provided domain applies the same steps to its starter codebase; the transfer challenge gives an orientation for an ML-serving tool. The bridge of Section 7.6 is optional: it is useful for a team that wants to see the report fed from live data, and a second-week task for most groups.

12.2 Do the tests detect faults?

The experiment ran in two rounds, always one fault at a time in a copy of the project and always through mvn test.

First round. Twenty-four faults of the report itself (stage 4) were run against the suite as first written. Twenty-two were caught and two survived: an equivalent mutant (below) and a policy lookup made before the overdue check, because no test had a null kind on a loan that is returned or not yet due. The test aNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored was added to close that gap.

Second round. All 36 faults, which add twelve for the three AI claims, the bridge and the service, were run against the final suite (159 invocations). Thirty-five were caught and one survives, the equivalent mutant.

AreaFaults triedResult with the final suite
Student rulesGrace 2 to 3; rate 0.50 to 0.60; cap 10 to 9All three caught
Staff rulesGrace 5 to 4; rate 0.25 to 0.30; cap 5 to 6All three caught
Other kindsOne grace day; a cap of 10; lower-case s accepted as a studentAll three caught
Policy arithmeticMath.max instead of Math.min; grace test < instead of <=max caught. The < change survives: it is an equivalent mutant, because at exactly the grace day the fine is (grace - grace) * rate = 0 either way
Listing rulesDue today counted as overdue; returned loans not skipped; overdue count off by two; total fines not accumulatedAll four caught
Zero-fine patchesZero-fine loans skipped; fine computed first and zero-fine loans skippedBoth caught
The AI claimsThe overdue test written as !isAfter; the policy looked up before the overdue check; unknown kinds treated as students; all three togetherAll four caught (Section 9 lists the failing tests)
Hidden dependenciesAmounts always formatted with a point; header shows the system date; print writes to standard error; short entry point uses yesterdayAll four caught
Date arithmeticDates subtracted the wrong way round; days late from Period.getDays()Both caught
BridgeThe due day is the exclusive end; the returned flag inverted; a mismatched loan not detected; a null kind passed throughAll four caught
ServiceA source failure becomes an empty report; a null snapshot treated as empty; the system date instead of the clock; the clock zone ignored; the snapshot cached between reportsAll five caught

The Period fault matters because it is a realistic suggestion from a code assistant: Period.getDays() returns the days field only, stored separately from months and years, so a loan 100 days late would not be counted as 100 days. The ChronoUnit.DAYS.between call in the code returns whole days, with the end exclusive. These counts come from one fault set on one small class and its bridge, and they say nothing about how this suite compares with earlier chapters.

The results establish the selected behaviours on the supplied inputs. They do not establish equivalence with the original for every input, or the behaviour of any real record system.

References

The online references of the student chapter (sources 1–12) were opened on 13 October 2026; see the chapter for the source context.

  1. Fowler, M., Definition of Refactoring.
  2. Fowler, M., Refactoring catalogue.
  3. Feathers, M., Characterization Testing.
  4. Fowler, M., Technical Debt.
  5. Fowler, M., Code Smell; Legacy Seam. Feathers, M., Testing Effectively With Legacy Code.
  6. Oracle, Formatter (Java SE 17); ChronoUnit; Period; Clock; LocalDate.
  7. JUnit, User Guide 5.13.4.

Chapter 12 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 12 Companion: Code, Explanations and Solutions.

Measuring Progress, Flow and Technical Health
OO Development and Agile Practices: Campus Equipment Lending

This companion supplies a small executable metrics exercise, worked calculations, the required repository-analysis method, a precise change specification and model answers. It follows the Week 11 syllabus. The application remains at Chapter 11; the Java calculator is a separate teaching tool, not a new lending feature or an analytics service.

The issue history is synthetic. The repository-lab template is blank by design: no real student repository has been inspected for this chapter, and no sample numbers are represented as real results. Complete that part with actual team evidence or an instructor-provided real export.

1. Files, toolchain and execution

The complete files appear under File: lab/... headings in the appendix. Create a directory named lab and copy each following fenced block into its named file. The TSV data uses actual tab characters and preserves empty fields, including trailing empty fields. Do not replace tabs with spaces or insert Markdown fences into the files.

Tool or methodPinned choice
CalculatorFlowMetrics v1.0, complete source in this file
Repository methodRM-12 v1.0, defined in Section 4 below
JavaFull JDK 17; locally checked with 17.0.20
Build/testPlain JDK compilation and explicit Java checks; no new dependencies
Optional Git provenance commandGit 2.51.1 syntax and locally inspected version
Running lending projectChapter 11’s Java 17 / Maven 3.9.11 version, unchanged

From lab:

mkdir -p out
javac --release 17 -d out FlowMetrics.java FlowMetricsChecks.java
java -cp out FlowMetricsChecks
java -cp out FlowMetrics issues.tsv 2026-10-01T00:00:00Z 2026-10-11T00:00:00Z --scope

These commands use a POSIX-style terminal, including Git Bash on Windows. In another terminal, create out using its normal directory command; the javac and java arguments remain the same. A JRE alone is insufficient. The assertions throw explicitly, so the -ea option is not required.

Expected checks: FlowMetricsChecks: 17 checks passed.

Expected report:

Completions in [from, until): 5
Completed points: 16
Mean lead days: 4.60
Mean cycle days: 2.80
Median cycle days: 3.00
WIP at until (after events at that instant): 2
Age E: 4.00 days
Age H: 3.00 days
at,scope_points,done_points,remaining_points,wip
2026-10-01T00:00:00Z,20,0,20,1
2026-10-02T00:00:00Z,20,0,20,2
2026-10-03T00:00:00Z,20,3,17,2
2026-10-04T00:00:00Z,23,5,18,1
2026-10-05T00:00:00Z,23,5,18,2
2026-10-06T00:00:00Z,25,10,15,2
2026-10-07T00:00:00Z,25,10,15,3
2026-10-08T00:00:00Z,23,13,10,3
2026-10-09T00:00:00Z,23,13,10,3
2026-10-10T00:00:00Z,23,16,7,2
2026-10-11T00:00:00Z,23,16,7,2

The --scope output samples every 24 hours from from through until when aligned. It is suitable for this midnight-UTC example. It is not a business-calendar or local-midnight plotting engine. Real PR data normally uses the summary without --scope because point estimates are unavailable or inappropriate.

2. Data contract and code explanation

Schema

Each TSV line has exactly six fields. There is no quoting or escaping syntax; use stable IDs without tabs or newlines. The file must have the exact header shown in the appendix.

FieldMeaningMissing value
idUnique work-item ID within this inputInvalid
pointsNonnegative integer estimate held fixed in this exampleBlank means unknown, not zero
acceptedTimestamp admitted to tracked scopeInvalid
startedTimestamp at the chosen start boundaryBlank means not started
doneTimestamp at the chosen finish boundaryBlank means not completed
removedExplicit cancellation/removal timestampBlank means not removed

Use ISO-8601 instants such as 2026-10-06T00:00:00Z, with a timezone offset if converting an original timestamp. Do not submit a bare local time. Preserve original timestamps and their timezone in the evidence record as well as any normalised UTC value.

Item rejects start-before-acceptance, completion without a start, completion before start, and contradictory done/removed states. read rejects duplicate IDs and the wrong number of fields. Unknown points produce NA for completed points if any completed item lacks an estimate; they do not prevent duration calculations. Scope-point output rejects missing estimates rather than inventing them.

The model permits a removed item to have been started; that item leaves WIP at removal but never counts as Done. It does not model reopening, multiple blocked intervals, changing estimates, parent/child aggregation or completed work later removed from the product. Use an event-history model for those cases rather than forcing them into these six columns. Input validity does not prove that the source population is complete or the timestamps reflect the claimed workflow.

Calculation responsibilities

completed selects Done instants in [from, until). summarize uses that cohort for count, points, lead and cycle statistics, and separately inspects all supplied rows for WIP at until. This deliberately permits carried-in work: an item can start before the window and finish within it.

inProgress treats an item as open once its Started event has occurred and until Done or Removed occurs. Events exactly at the snapshot instant are already applied. A completion at until is therefore excluded from that interval’s throughput and also excluded from WIP at that snapshot. There is no contradiction: one is a count over a half-open period, the other a state after boundary events.

days uses elapsed seconds and nanoseconds divided by 86,400 seconds. It does not call Duration.toDays, which would truncate partial days. This matters for real PR timestamps: a one-hour interval is approximately 0.041667 days, not zero. Output is rounded to two decimals for reading; calculations retain unrounded doubles.

An empty completion cohort yields zero completions and NA durations, because an average over no observations is not a measured zero-day average. Completed points for that empty cohort are zero as a sum. The median sorts completed cycle times and uses the middle value, or the average of the two central values for an even-sized cohort.

scopePoints and donePointsAt support this example’s fixed estimates and explicit removals. The first includes accepted items not removed as of the snapshot; the second includes completion events at or before it. They do not infer scope from Git commits.

Meaningful checks

The 17 checks protect independently useful properties: correct example totals and durations; scope addition/removal; completion-window boundaries; WIP at a completion instant; fractional elapsed days; empty-cohort handling; unknown estimates; and invalid event order. They are plain-Java teaching checks, not additions to the running application’s JUnit count.

For an optional fault experiment, use a separate copy. In completed, replace:

i.done().isBefore(until)

with:

!i.done().isAfter(until)

Recompile and run FlowMetricsChecks. The check named Exclusive upper bound must fail: B at 6 October is incorrectly counted in the first window. Restore the expression and rerun. This experiment concerns a reporting interval, not a change to LoanPeriod or the overdue policy.

3. Worked calculations and interpretation

Finished cohort

IDLead calculationLead daysCycle calculationCycle days
A03 − 01203 − 012
B06 − 01506 − 024
C04 − 01304 − 031
D08 − 01708 − 053
G10 − 04610 − 064
Total2314

Mean lead = 23 / 5 = 4.6 elapsed days. Mean cycle = 14 / 5 = 2.8 elapsed days. Sorted cycles are 1, 2, 3, 4, 4, so the median is three. Waiting before start totals nine days and averages 1.8, consistent with 4.6 − 2.8 for this same cohort.

B’s blocked interval [03,05) contributes two elapsed days within its four-day cycle. Subtracting those two days would make the cohort total 12 and mean 2.4, not two. Even 2.4 is not the defined cycle-time statistic and cannot be called coding time without further evidence.

Scope and completion reconciliation

Initial scope = 3 + 5 + 2 + 3 + 5 + 2 = 20 points. Added scope = G(3) + H(2) = 5. Removed scope = F(2). Final scope = 20 + 5 − 2 = 23. Completed points = A(3) + B(5) + C(2) + D(3) + G(3) = 16. Remaining = 23 − 16 = 7, exactly E(5) + H(2).

Throughput is five items in ten elapsed days, or 0.5 item per elapsed day as a rate over this interval. It is not 16 items and not six items. A and C finish in [01,06); B, D and G finish in [06,11). There is no double counting at the shared boundary.

At 11 October, E has age 11 − 07 = 4 days and H has age 11 − 08 = 3 days. Neither has a known completed cycle time. F was removed before starting, so it is neither WIP nor Done. A daily WIP count is a snapshot; it is not an average WIP over all instants.

Cautious interpretation example

“The synthetic iteration finished five items and 16 points. Scope grew by five points and later lost two, leaving seven points across two open items. The remaining-work rise on day 04 reflects a scope addition despite a completion. Completed cycle time averages 2.8 days, while the current open items have ages four and three days. B has a recorded two-day wait for clarification, but this small history cannot establish the causes of all delays. Before forecasting the approval change, inspect E’s unresolved issue and agree the approval data contract.”

This paragraph distinguishes facts, limitations and a next action. It does not claim real team performance or assign responsibility to a person.

4. RM-12 v1.0: required real repository lab

This is the pinned repository-metrics method for this chapter. It uses genuine issue/PR events and the same Java 17 calculator; no paid dashboard, API account or repository-analysis library is necessary. A hosting provider’s UI or an authorised export can supply the data. The source must be real even when an instructor prepares the export.

Route A: PR open-to-merge duration: minimum route

  1. Choose a source and rule. Record repository URL, target branch, fixed UTC interval, extraction timestamp and inclusion rule. For a small exercise, select the first five qualifying merged PRs ordered by merge time and then PR number within the interval; if fewer exist, use all and report the actual count. State whether bots, draft PRs and documentation changes are included before selection. This is a bounded duration sample, not repository throughput.
  2. Keep provenance. For each selected PR retain its URL/ID, original opened timestamp, merged timestamp and target branch. Inspect its event history. Exclude or separately document reopened PRs because the supplied one-interval model cannot express multiple open/closed episodes. Use actual precise timestamps; if the UI only gives relative dates, obtain an export with exact timestamps or report that precise calculation is blocked. Do not guess midnight.
  3. Map the data. Set accepted and started both to the PR opened instant; set done to its actual merged instant. Leave points and removed blank. Give it a stable ID such as PR-42. These boundaries intentionally make printed lead and cycle equal; report the result as PR open-to-merge duration, not issue lead time. Do not infer acceptance into a backlog from PR opening.
  4. Calculate. Save the normalised six-column file as repository.tsv. Run the calculator with the chosen interval and without --scope. Manually verify the longest selected duration using its two timestamps. Record count, median and mean for the sample, plus the observed range if helpful.
  5. Interpret and review. State that coding may begin before a PR opens; draft/review waiting remains included; a merge need not be deployed or accepted by a user; the sample may differ from the full population. Ask a peer to trace one row back to the source, or label a solo provenance check accurately.

Example command shape, using your actual interval:

java -cp out FlowMetrics repository.tsv 2026-10-01T00:00:00Z 2026-10-11T00:00:00Z

Those dates are the book example’s window, not a required window for a real repository. Replace them if they do not match your selection. If the input contains only merged sample PRs, do not interpret the displayed WIP as repository WIP: open PRs were not supplied.

Route B: complete-window throughput and open work

To report repository PR throughput, enumerate all qualifying merged PRs in the chosen branch and [from, until) interval. Check pagination and filters; record the number of results and any exclusions. Do not filter by opened date, because that would lose carried-in work. The completion count then measures merges of the stated PR unit, not finished user stories or deployments.

To report WIP at until, also include every qualifying PR opened at or before that instant that had neither merged nor closed at that instant. Historical snapshots require event history: a PR merged after until was still open at until. The calculator can retain the later Done event and correctly treat it as open at the snapshot. A PR closed without merging maps to removed, not done. Capture such rows where needed to reconstruct historical state. Record any reopened cases separately or use a richer event model.

Only a complete population supports full-window throughput/WIP claims. A small verified duration sample is sufficient for Route A and fulfils the required repository-duration exercise; do not mislabel it as Route B. Issue exports are an alternative when they include genuine Started/Done transition history and the same explicit completeness checks. In that route, define what “accepted” actually means before using it.

Optional Git cross-check

For a local repository, Git 2.51.1 can provide a compact provenance inventory without collecting names or emails:

git --version
git rev-parse HEAD
git log --first-parent -20 --format='%H%x09%cI'

%H is the commit hash and %cI the strict ISO committer timestamp. First-parent traversal changes the history being inspected. These records may help match a PR to a revision, but they do not supply PR-open times or prove deployment. The last 20 commits are not a complete interval export, and commit counts are not substituted for the required PR/issue measure.

Evidence template: fill with observed data

FieldYour record
Repository and target branch
Source interface/export and extraction timestamp
Interval and timezone convention
Unit; accepted/start/finish meanings
Inclusion rule; excluded event histories
Route A sample rule or Route B completeness evidence
Raw IDs/URLs and exact timestamps
Normalised file and command
Number analysed; mean; median; longest manual check
Throughput/WIP, only if supported by complete input
One uncertainty; one defensible next question
Reviewer and actual feedback, or solo review

A zero-completion interval is a valid result; report it and retain NA durations. If the team has no usable history, use an instructor-provided real repository export or inspect a suitable public repository. Do not manufacture commits or PRs to claim historical evidence. Synthetic data may be used to practise the commands while arranging the real input, but the real-data lab remains unfinished until that input is examined.

5. CR-12: worked change specification for Chapter 13

Status: specified teaching change; implementation pending. It is not institutional policy and does not substitute for the team’s separately released assessment variant. These choices make the example concrete enough to implement and verify without reopening its basic scope in Chapter 13.

Behaviour and data decisions

IDBook contract
CR12-1Add a recognised VISITOR borrower category. Legacy directory code V maps to it. Existing S/T, inactive and unknown-code behaviour stays as documented; unknown codes never default to STUDENT.
CR12-2Visitors may request only CAM1 or CAM2, for one, two or three calendar days. Use the existing half-open loan period. Zero/negative duration and four or more days are rejected.
CR12-3Reuse the student count limit: fewer than three retained reservations for that borrower before adding one. This is the existing repository-count convention, not an active-loan count.
CR12-4A visitor needs one matching trusted approval record for the exact tuple (borrowerId, itemId, start, endExclusive). The record must have a nonblank sponsor ID and status APPROVED. Approval for another borrower, item or period is insufficient.
CR12-5Capture one request-time instant from an injected Clock. A record is valid when validFrom <= now < validUntil. Equality at expiry is invalid. The loan period need not lie inside the approval-validity window; that window governs when the reservation may be requested.
CR12-6A missing match, DENIED status or an out-of-window record is an expected rejection. It creates no reservation and sends no confirmation.
CR12-7An unreadable register, malformed row, duplicate request key, unknown status or invalid validity interval is an operational failure. It also creates no reservation and sends no confirmation, but is distinguishable from sponsor rejection. No fallback approval or empty-success response.
CR12-8Once approval and existing checks succeed, create the reservation and use existing confirmation behaviour. Retain the current rule that an expected notification failure leaves an already-created reservation in place.
CR12-9The initial release has one visitingResearchersEnabled flag, default false. Disabled visitor requests are explicitly rejected without reading the approval register. Student/staff requests use their existing path under either setting and never require sponsor approval.
CR12-10Approval is checked at reservation time only. Expiry or disabling the flag later does not cancel an existing reservation or block its normal checkout/return. Revocation after reservation is outside this exercise.

The current direct category-taking entry point and the older overload that defaults to STUDENT are trusted teaching APIs, not authentication boundaries. Directory-backed requests obtain their category from the directory; arbitrary caller-supplied categories do not establish real-world identity. Do not claim this exercise prevents hostile impersonation. A production authorisation system would need separate requirements and evidence.

SD-12: count semantics: ReservationCoordinator calls its variable activeCount but counts all stored reservations. Returning a loan does not remove its reservation. Preserve this behaviour for the worked change and record the question: should a future authorised rule count active loans, future reservations, or all retained reservations? Renaming the local variable for clarity is possible as a separate structural edit; changing the count is a functional decision. SD-11’s due-today report behaviour also remains unresolved and unchanged.

Planned approval-register boundary

Choose a local UTF-8 TSV file with exactly eight columns:

borrowerId	itemId	start	endExclusive	sponsorId	status	validFrom	validUntil

Each column is separated by a tab; the example data below contains actual tabs. IDs cannot contain tabs or newlines. Dates are ISO local dates; validity timestamps are ISO instants. Status is exactly APPROVED or DENIED. Require start < endExclusive and validFrom < validUntil. Reject a malformed register as a whole, including an invalid unrelated row; do not silently skip bad records. Require unique request keys within one read. The file is treated as a trusted, sequential example, not an atomic concurrent approval database.

Synthetic valid example for the future implementation:

borrowerId	itemId	start	endExclusive	sponsorId	status	validFrom	validUntil
V1	CAM1	2026-10-20	2026-10-22	S1	APPROVED	2026-10-18T00:00:00Z	2026-10-20T00:00:00Z

At request instant 2026-10-19T12:00:00Z, this approves V1’s two-day CAM1 request starting 20 October. At 2026-10-20T00:00:00Z, the same approval has expired. The row is a specification example, not input to FlowMetrics, and there is no approval parser implemented in Chapter 12.

A candidate application port can obtain the approval decision for a request; its file adapter performs actual I/O and validation. Keep deterministic membership/duration rules apart from source access and time. Do not force identity/approval information into BorrowingPolicy.mayRequest(count, days) through a global flag. Chapter 13 should decide the smallest interface shape that satisfies these examples and fits the existing orchestration.

Acceptance matrix

In visitor success/rejection rows assume a recognised active V1, flag enabled, fewer than three retained reservations, available equipment, valid identifiers and an ordinary working notification sender, except where the row changes one condition. Construct every example independently so one failure does not mask another.

ExampleInput differenceExpected observation
AC1CAM1, two days, matching approved record valid at request timeOne new reservation; existing confirmation format; one notice
AC2CAM2, three days with its own matching recordAllowed; upper duration boundary preserved
AC3Four days, otherwise matching approvalReject; no state or notice change
AC4Zero or negative daysReject; no state or notice change
AC5Item MIC1 with matching approvalReject because item is outside visitor allowlist
AC6Missing approval or DENIED recordExpected rejection; no state or notice change
AC7Approval belongs to V2, CAM2 or a different end dateDoes not authorise the V1/CAM1/requested-period tuple
AC8now == validFromValid if all other conditions hold
AC9now == validUntil, or before validFromRejected as invalid at request time
AC10Unreadable file, malformed row or duplicate matching keyDistinct operational failure; no state or notice change
AC11Three retained reservations already existReject count limit even if loans have been returned
AC12Visitor feature disabledExplicit disabled result; approval source not called
AC13Existing student/staff request, either flag settingExisting result and notification behaviour; approval source not called
AC14Visitor’s approved item overlaps an existing reservationExisting availability rejection; no extra reservation or notice
AC15Valid visitor succeeds but expected notification failure occursReservation retained; existing notification-failure outcome
AC16Visitor reservation created, then approval expires or flag turns offExisting checkout/return flow remains available
AC17Unknown directory code or inactive visitorExisting unknown/inactive semantics; never default to student
AC18Report generated after a visitor loan existsExisting report mapping and SD-11 boundary retained

These are planned examples, not claims that new tests have run. Existing policy tests, directory tests, command tests and the 159-test Chapter 11 suite remain relevant. Some test setups that enumerate every category will require explicit updates because adding an enum value changes their setup; document that necessary example change without weakening old assertions.

Ordered backlog and impact note

IDOrdered workDependencyExit evidence
C12-1Preserve baseline and confirm CR12/SD12 examplesChapter 11 sourcePassing baseline result; retained revision/copy; reviewed contract
C12-2Add recognised visitor path, policies and explicit wiringC12-1Valid visitor with controlled approval source; old categories still behave correctly
C12-3Implement and test actual approval-file adapterC12-1 data contract; connect after C12-2Temporary-file success, malformed/missing/duplicate failures; no mock replacing the reader under test
C12-4Complete matching/time/failure and no-mutation protectionC12-2, C12-3AC boundary and operational-failure evidence
C12-5Introduce one flag and integrate CLI/demoC12-2–4Both settings; old path; existing reservations usable after disable
C12-6Run full regression, real boundary checks and delivery workflowC12-2–5Observed pipeline/local gate, repeatable demonstration and retrospective for Chapter 13

Status of C12-2–6 in this chapter: planned, not implemented. C12-1’s source preservation is supplied here; the learner records their actual baseline result and review. Do not assign owners, dates or points as if a real team had agreed them. Add those during the team planning activity, using its capacity and dependencies.

Impact rationale: category recognition reaches the directory and wiring; the coordinator enforces every-category policy availability; sponsor approval requires request identity/time that the existing count/duration interface lacks; the file reader needs integration checks; failures must be distinguished before a new reservation is added. The existing repository, loan lifecycle and report do not need a broad redesign for this narrow requirement. Concurrent approval updates and revocation remain outside scope.

Flag plan: default off; enable only for the controlled demonstration after both paths pass. Disabling prevents new visitor reservations, but does not erase existing data or roll back arbitrary side effects. Keep the pre-change revision for code comparison. Record a removal decision during Chapter 14 once the intended release path is accepted; do not let an unnamed temporary flag become permanent by default. No public production deployment is required.

6. Preserve the pre-change version

The supplied Chapter 11 code is the baseline; this chapter makes no edits to its production classes, tests or build file. Its last delivered full run passed 159 JUnit invocations. The metrics tool lives outside that Maven project and has its own checks; do not add its check count to the baseline’s count.

For your working project, first run its documented gate from its project root:

mvn --batch-mode verify

Retain the command, actual result, Java/Maven versions and any failure explanation. In a Git project inspect:

git status --short
git rev-parse HEAD

A commit ID is a sufficient source reference only when the intended baseline files are committed. If the working tree includes changes or untracked files, retain them in a separate complete source copy or make a deliberate scoped commit before recording the baseline. A tag alone does not capture uncommitted work. Avoid resetting the project or overwriting unrelated work to make this record look clean.

For learners assembling source from Markdown without Git, copy the complete Chapter 11 project directory as pre-change-ch12 before making Chapter 13 changes. Keep src, build configuration, resources and scripts. Generated target files are reproducible and need not be preserved as source. Keep a record of the copied file set and the successful command.

Suggested record:

Baseline: Chapter 11 cumulative project
Source revision or complete-copy location:
Uncommitted/untracked source accounted for:
Java and Maven versions:
Command and actual result:
SD-11: due-today inclusion preserved; policy unresolved
SD-12: retained-reservation count preserved; policy unresolved
CR-12: specified; implementation pending
Reviewer or explicitly labelled solo review:

7. Model answers and lab reasoning

Chapter 12.8: AI review

Candidate claimJudgement and correction
Six items / 18 points deliveredContradicted: F is removed, not Done. Five items and 16 points completed.
Mean cycle is two after subtracting blockingContradicted: defined mean is 2.8. Subtracting B’s two days would give 2.4, and would change the measure.
Day 04 rise proves productivity declinedNot established: G adds three points while C completes two. The remaining line rises by one.
Day 08 fall proves five points deliveredContradicted: D completes three; F removes two.
Legacy architecture caused waitingNot established: one mapping clarification is recorded; broad causality needs more evidence.
Remaining work definitely finishes in 0.44 iterationsUnsupported forecast: the division is approximately right, but comparability, capacity and uncertainty are missing.
Rank members by commitsReject the recommendation: commits do not measure individual value or identify the cause of delay.

A corrected response can use the cautious interpretation in Section 3. A useful next step is to inspect E’s unresolved problem and settle the approval data contract. That is actionable without inventing a productivity diagnosis.

Chapter 12.11: concept questions

1. Lead versus cycle. Accepted-to-Done includes the pre-start queue; Started-to-Done does not. B has lead five and cycle four. Another team’s “lead time” might begin at a customer request rather than admission to scope, so the number cannot travel without its event definition and elapsed/business-time convention.

2. Falling burn-down. Removing F reduces remaining scope by two while delivering nothing. Re-estimation could also move a line under another data model. Keep a scope event record and distinguish the completed and scope lines in a burn-up.

3. Equal throughput. Two periods can each finish two items whose earlier start dates differ. A carried-in seven-day item and a newly completed one-day item can contribute equally to the period’s count. Recipients experience different waiting even when completion totals match.

4. Old versus new behaviour. The existing tests describe selected existing contracts. They do not contain sponsor-approval requirements and cannot establish those requirements by passing. Keep them, add new acceptance/unit/integration checks for CR-12, and examine any changes to test setups required by the new category.

Chapter 12.11: applied diagnosis

5. Biased cohort. Filtering on both start and finish being within the week excludes long-running carried-in work that finished this week. Define the completion cohort by the finish event and compute durations from actual start timestamps, even before the window. Report unfinished-item age separately. A start-cohort study is possible, but it answers another question and includes censored unfinished observations.

6. Global approval Boolean. Approval belongs to a specific borrower/item/period at a specific time. A static flag loses that identity and can leak across requests and tests. The current BorrowingPolicy contract deliberately handles count/duration only. Obtain a typed approval decision through an application boundary; validate its match and time using explicit inputs before reservation creation. Retain a pure count/duration policy. A global “approved” state is not an adequate record or security mechanism.

Chapter 12.11: ML serving transfer

Choose a unit such as one requested release of a named model version to a stated serving environment. Define Done as the approved version being demonstrably served at that boundary with required checks, not merely a successful build. Keep build, approval and activation timestamps separate. An unavailable approval registry must not be treated as permission to serve.

Plan a narrow valid activation path, mismatched/expired/denied approval outcomes if those concepts are required, and registry failure evidence. Test the genuine registry boundary separately from orchestration doubles. Preserve the old serving path and record how disabling the new release path affects already active versions. Model accuracy concerns predictive behaviour under an evaluation design; build success concerns construction checks; approval is a governance decision. None substitutes for the others.

Chapter 12.10: lab completion reasoning

the book’s project produces the calculations, corrected interpretation, CR-12 plan and baseline reference. your team’s project contributes real repository evidence and an impact note for its actual released change. Accept different task splits and interfaces when they preserve the observation boundaries and have defensible dependencies. Do not assess by number of commits, charts or AI prompts.

A good impact note identifies the concrete touchpoints, uncertainty and first experiment. A weak note merely lists class names or repeats “add tests.” A good repository result includes traceable timestamps and honest population scope; an attractive chart with guessed timestamps is not an acceptable replacement.

8. Verification record and limits

The supplied calculator was compiled at Java release 17 and its 17 checks passed. The exact synthetic report above was executed. A separate boundary-mutant copy was checked to confirm the exclusive-window assertion detects the deliberately inclusive upper bound; restoration returned the checks to green. The Markdown file listings were reconstructed and run before delivery.

An independent calculation also reconciled the eight source rows, five completions, 16 completed points, 23 final scope points, seven remaining points, the five-item duration means and the daily chart table. Chapter 11’s source was retained without modification. Its prior 159-test result is a baseline reference, not a newly implemented CR-12 result.

No student repository or actual PR history has been analysed in this authoring run. RM-12 supplies the procedure and blank evidence record for that required learner activity. No sponsor approval, stakeholder acceptance, hosted pipeline or peer review is claimed. CR-12’s adapter, tests and feature flag are specifications for Chapter 13. The calculator does not handle reopened histories, revised estimates or business calendars.

Sources and assigned reading are listed in the main chapter. All sample records, contracts, code and answers here are original teaching material.

Appendix: Complete isolated Java exercise

Keep these files outside the running lending application’s source roots. The two Java classes have no package declaration so the plain-JDK commands above work as written.

File: lab/issues.tsv

id	points	accepted	started	done	removed
A	3	2026-10-01T00:00:00Z	2026-10-01T00:00:00Z	2026-10-03T00:00:00Z	
B	5	2026-10-01T00:00:00Z	2026-10-02T00:00:00Z	2026-10-06T00:00:00Z	
C	2	2026-10-01T00:00:00Z	2026-10-03T00:00:00Z	2026-10-04T00:00:00Z	
D	3	2026-10-01T00:00:00Z	2026-10-05T00:00:00Z	2026-10-08T00:00:00Z	
E	5	2026-10-01T00:00:00Z	2026-10-07T00:00:00Z		
F	2	2026-10-01T00:00:00Z			2026-10-08T00:00:00Z
G	3	2026-10-04T00:00:00Z	2026-10-06T00:00:00Z	2026-10-10T00:00:00Z	
H	2	2026-10-06T00:00:00Z	2026-10-08T00:00:00Z		

File: lab/FlowMetrics.java

import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/** Chapter 12 teaching tool v1.0. TSV input, UTC instants, elapsed 24-hour days. */
public final class FlowMetrics {
    public record Item(String id, Integer points, Instant accepted, Instant started,
                       Instant done, Instant removed) {
        public Item {
            if (id == null || id.isBlank()) throw new IllegalArgumentException("Blank id");
            if (points != null && points < 0) throw new IllegalArgumentException("Negative points");
            Objects.requireNonNull(accepted, "accepted");
            if (started != null && started.isBefore(accepted))
                throw new IllegalArgumentException("Start before acceptance: " + id);
            if (done != null && (started == null || done.isBefore(started)))
                throw new IllegalArgumentException("Invalid completion: " + id);
            if (removed != null && (done != null || removed.isBefore(accepted)
                    || (started != null && removed.isBefore(started))))
                throw new IllegalArgumentException("Invalid removal: " + id);
        }
    }

    public record Summary(int completed, String completedPoints, double meanLeadDays,
                          double meanCycleDays, double medianCycleDays, int wip) { }

    private FlowMetrics() { }

    public static List<Item> read(Path path) throws Exception {
        var lines = Files.readAllLines(path);
        String header = "id\tpoints\taccepted\tstarted\tdone\tremoved";
        if (lines.isEmpty() || !lines.get(0).equals(header))
            throw new IllegalArgumentException("Expected exact TSV header");
        List<Item> result = new ArrayList<>();
        var ids = new HashSet<String>();
        for (int line = 1; line < lines.size(); line++) {
            if (lines.get(line).isBlank()) continue;
            String[] f = lines.get(line).split("\t", -1);
            if (f.length != 6) throw new IllegalArgumentException("Six fields required at line " + (line + 1));
            if (!ids.add(f[0])) throw new IllegalArgumentException("Duplicate id: " + f[0]);
            result.add(new Item(f[0], f[1].isEmpty() ? null : Integer.valueOf(f[1]),
                    parse(f[2]), parse(f[3]), parse(f[4]), parse(f[5])));
        }
        return List.copyOf(result);
    }

    private static Instant parse(String text) {
        return text.isEmpty() ? null : Instant.parse(text);
    }

    public static double days(Instant start, Instant end) {
        Duration d = Duration.between(start, end);
        return d.getSeconds() / 86400.0 + d.getNano() / 86400_000_000_000.0;
    }

    public static List<Item> completed(List<Item> items, Instant from, Instant until) {
        if (!from.isBefore(until)) throw new IllegalArgumentException("Empty or reversed window");
        return items.stream().filter(i -> i.done() != null && !i.done().isBefore(from)
                && i.done().isBefore(until)).toList();
    }

    public static boolean inProgress(Item i, Instant at) {
        return i.started() != null && !i.started().isAfter(at)
                && (i.done() == null || i.done().isAfter(at))
                && (i.removed() == null || i.removed().isAfter(at));
    }

    public static Summary summarize(List<Item> items, Instant from, Instant until) {
        List<Item> cohort = completed(items, from, until);
        double[] cycles = cohort.stream().mapToDouble(i -> days(i.started(), i.done())).sorted().toArray();
        double lead = cohort.stream().mapToDouble(i -> days(i.accepted(), i.done())).average().orElse(Double.NaN);
        double cycle = java.util.Arrays.stream(cycles).average().orElse(Double.NaN);
        double median = cycles.length == 0 ? Double.NaN : cycles.length % 2 == 1
                ? cycles[cycles.length / 2] : (cycles[cycles.length / 2 - 1] + cycles[cycles.length / 2]) / 2;
        String points = cohort.stream().anyMatch(i -> i.points() == null) ? "NA"
                : Long.toString(cohort.stream().mapToLong(i -> i.points()).sum());
        return new Summary(cohort.size(), points, lead, cycle, median,
                (int) items.stream().filter(i -> inProgress(i, until)).count());
    }

    public static long scopePoints(List<Item> items, Instant at) {
        return items.stream().filter(i -> !i.accepted().isAfter(at)
                && (i.removed() == null || i.removed().isAfter(at))).mapToLong(i -> {
                    if (i.points() == null) throw new IllegalArgumentException("Scope point missing: " + i.id());
                    return i.points();
                }).sum();
    }

    public static long donePointsAt(List<Item> items, Instant at) {
        return items.stream().filter(i -> i.done() != null && !i.done().isAfter(at)).mapToLong(i -> {
            if (i.points() == null) throw new IllegalArgumentException("Done point missing: " + i.id());
            return i.points();
        }).sum();
    }

    private static String number(double n) {
        return Double.isNaN(n) ? "NA" : String.format(Locale.ROOT, "%.2f", n);
    }

    public static void main(String[] args) throws Exception {
        if (args.length != 3 && args.length != 4)
            throw new IllegalArgumentException("Usage: FlowMetrics file.tsv fromInstant untilInstant [--scope]");
        if (args.length == 4 && !args[3].equals("--scope")) throw new IllegalArgumentException("Unknown option");
        List<Item> items = read(Path.of(args[0]));
        Instant from = Instant.parse(args[1]);
        Instant until = Instant.parse(args[2]);
        Summary s = summarize(items, from, until);
        System.out.println("Completions in [from, until): " + s.completed());
        System.out.println("Completed points: " + s.completedPoints());
        System.out.println("Mean lead days: " + number(s.meanLeadDays()));
        System.out.println("Mean cycle days: " + number(s.meanCycleDays()));
        System.out.println("Median cycle days: " + number(s.medianCycleDays()));
        System.out.println("WIP at until (after events at that instant): " + s.wip());
        for (Item i : items) if (inProgress(i, until))
            System.out.println("Age " + i.id() + ": " + number(days(i.started(), until)) + " days");
        if (args.length == 4) {
            System.out.println("at,scope_points,done_points,remaining_points,wip");
            for (Instant at = from; !at.isAfter(until); at = at.plus(Duration.ofDays(1))) {
                long scope = scopePoints(items, at);
                long done = donePointsAt(items, at);
                Instant sample = at;
                long wip = items.stream().filter(i -> inProgress(i, sample)).count();
                System.out.println(at + "," + scope + "," + done + "," + (scope - done) + "," + wip);
            }
        }
    }
}

File: lab/FlowMetricsChecks.java

import java.nio.file.Path;
import java.time.Instant;
import java.util.List;

/** Plain Java checks throw explicitly; -ea is not required. */
public final class FlowMetricsChecks {
    private static int checks;
    private static Instant day(int n) { return Instant.parse("2026-10-%02dT00:00:00Z".formatted(n)); }
    private static void check(boolean value, String description) {
        checks++;
        if (!value) throw new AssertionError(description);
    }
    private static void invalid(Runnable operation, String description) {
        checks++;
        try { operation.run(); } catch (IllegalArgumentException expected) { return; }
        throw new AssertionError(description);
    }
    public static void main(String[] args) throws Exception {
        var data = FlowMetrics.read(Path.of("issues.tsv"));
        var s = FlowMetrics.summarize(data, day(1), day(11));
        check(s.completed() == 5 && s.completedPoints().equals("16"), "Count and points differ");
        check(Math.abs(s.meanCycleDays() - 2.8) < 1e-9, "Mean cycle");
        check(Math.abs(s.meanLeadDays() - 4.6) < 1e-9, "Mean lead");
        check(s.medianCycleDays() == 3, "Median cycle");
        check(s.wip() == 2, "Open items remain WIP");
        check(FlowMetrics.scopePoints(data, day(4)) == 23, "Added scope counted");
        check(FlowMetrics.scopePoints(data, day(8)) == 23, "Removed scope excluded");
        check(FlowMetrics.donePointsAt(data, day(8)) == 13, "Removal is not completion");
        check(FlowMetrics.completed(data, day(1), day(6)).size() == 2, "Exclusive upper bound");
        check(FlowMetrics.completed(data, day(6), day(11)).size() == 3, "Inclusive lower bound");
        check(!FlowMetrics.inProgress(data.get(1), day(6)), "Done at snapshot is not WIP");
        check(Math.abs(FlowMetrics.days(day(1), day(1).plusSeconds(3600)) - 1.0 / 24) < 1e-9,
                "Do not truncate fractional days");
        var empty = FlowMetrics.summarize(List.of(), day(1), day(2));
        check(empty.completed() == 0 && Double.isNaN(empty.meanCycleDays()), "No completions is not zero duration");
        var unknown = new FlowMetrics.Item("PR1", null, day(1), day(1), day(2), null);
        check(FlowMetrics.summarize(List.of(unknown), day(1), day(3)).completedPoints().equals("NA"), "Unknown points");
        invalid(() -> new FlowMetrics.Item("X", 1, day(2), day(1), null, null), "Invalid ordering rejected");
        invalid(() -> new FlowMetrics.Item("X", 1, day(1), day(1), day(2), day(3)), "Done plus removed rejected");
        invalid(() -> FlowMetrics.summarize(data, day(3), day(3)), "Invalid window rejected");
        System.out.println("FlowMetricsChecks: " + checks + " checks passed");
    }
}

Chapter 13 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 13 Companion: Code, Explanations and Solutions.

Integration, Delivery and Feedback
OO Development and Agile Practices

1. Scope and execution

This file accompanies Chapter 13, Integration, Delivery and Feedback. It contains the visiting-researcher change that Chapter 12 specified (CR-12), the two real boundaries it adds (an approval file and an HTTP directory client), the tests at three scopes, the pipeline and release scripts, a hosted-runner workflow, a decision record, the two tests of the AI activity, model solutions and teaching notes.

Input version. The starting point is the Chapter 11 version: the Chapter 10 companion plus the new and changed files of the Chapter 11 companion. To keep verified listings from being copied a third time, this file lists only the files that are new or changed. Every other file is exactly as in those two companions. Chapter 12 changes no code: its companion is not part of the version.

What the change does. A visiting researcher (borrower category VISITOR) may reserve CAM1 or CAM2 for one to three days when a sponsor approval, read from a tab-separated register, matches the exact borrower, item and period and is valid at the request instant. The behaviour is behind one flag that defaults to off. A missing approval is an expected rejection; an unreadable register or an unreachable directory is an operational failure that stops the request. Student and staff behaviour is unchanged. The decision record is docs/ADR-013-visiting-researchers.md.

The two boundaries. The chapter’s two real boundaries are a local file and a local HTTP stub service built on the JDK. If a different boundary were chosen (an embedded database, for example), the policy table of Section 2.3 and the tests would stay, and the adapters would change.

1.1 Reconstruct the version

  1. Create the directory after. Save every listing of the Chapter 10 companion at its path.
  2. Save the new and changed listings of the Chapter 11 companion (its Sections 3 to 6) at their paths. Where a path already exists, that listing replaces it.
  3. Save every listing in Sections 3 to 6 of this file at its path. Where a path already exists (pom.xml and the three regression tests are edited in place by the hunks shown; the other files are replaced), this file wins. Apply the hunks of Sections 4.7 and 5.1 with patch -p1 from inside after.
  4. The files under ai-activity/ are not compiled by Maven. To run them, copy them into src/test/java/edu/se506/lending/aifixture/.

Use a full Java 17 JDK, including the jdk.jdeps module, and Maven 3.9.11. The pinned versions are unchanged (JUnit Jupiter 5.13.4, Mockito 5.20.0, Compiler Plugin 3.14.1, Surefire 3.5.4); only the project version changes. Initial builds need dependency access. Then, from inside after, run the whole pipeline or the single command:

bash scripts/pipeline.sh
mvn --batch-mode verify

1.2 Expected output

The pipeline prints a banner at the start and the end of each stage and stops at the first failing one. A passing run looks like this:

=== STAGE: 1 compile
=== PASSED: 1 compile (4 s)
=== STAGE: 2 unit tests and architecture checks
[INFO] Tests run: 203, Failures: 0, Errors: 0, Skipped: 0
=== PASSED: 2 unit tests and architecture checks (13 s)
=== STAGE: 3 integration tests (real file, real HTTP client, local stub service)
[INFO] Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
=== PASSED: 3 integration tests (real file, real HTTP client, local stub service) (6 s)
=== STAGE: 4 end-to-end journeys (separate process)
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
=== PASSED: 4 end-to-end journeys (separate process) (6 s)
=== STAGE: 5 package
=== PASSED: 5 package (4 s)
PIPELINE PASSED

The times depend on the machine and on what Maven has cached; read the counts, not the seconds. The three end-to-end journeys check these lines from the separate process (flag off, then flag on):

Visitors enabled: false
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Rejected: visitor reservations are not enabled.
Visitor V2: Rejected: visitor reservations are not enabled.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 1
Notices: 1
Visitors enabled: true
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22).
Visitor V2: Rejected: no valid sponsor approval.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 2
Notices: 2

Chapter13Demo needs a directory service and an approval file, so it is started by the journeys, which supply both. The demonstrations of earlier chapters print what they printed before.

1.3 Run one stage, or one test

mvn --batch-mode test -DexcludedGroups=integration,e2e     # stage 2: unit tests and architecture checks
mvn --batch-mode test -Dgroups=integration                 # stage 3
mvn --batch-mode test -Dgroups=e2e                         # stage 4
mvn --batch-mode test -Dtest=VisitorAcceptanceTest         # one class

A tag that selects nothing fails the stage. mvn test -Dgroups=nosuchtag ends with No tests were executed! and BUILD FAILURE, because the POM sets failIfNoTests to true.

1.4 Run the simulated delivery

RELEASE_LABEL=v1 bash scripts/simulate-release.sh
RELEASE_LABEL=v2 bash scripts/simulate-release.sh
bash scripts/simulate-release.sh rollback

This is a simulation: staging is the folder build/staging, delivering is copying the jar, and the smoke test is the three end-to-end journeys run against the copied jar.

2. Design map

2.1 What is new or changed

PathStatusPurpose
src/main/java/edu/se506/lending/domain/BorrowerCategory.javaChangedAdds VISITOR
src/main/java/edu/se506/lending/domain/VisitorReservationRules.javaNewCount and duration policy only (up to three reservations, one to three days)
src/main/java/edu/se506/lending/domain/ApprovalStatus.javaNewAPPROVED or DENIED
src/main/java/edu/se506/lending/domain/ApprovalRecord.javaNewOne approval: exact request, validity window, matches, validAt, approvesAt
src/main/java/edu/se506/lending/application/port/ApprovalSource.javaNewPort: the record for one exact request, or empty
src/main/java/edu/se506/lending/application/port/ApprovalSourceException.javaNewOperational failure of the register
src/main/java/edu/se506/lending/application/VisitorGate.javaNewFlag, item allowlist and approval check; one clock read per request
src/main/java/edu/se506/lending/application/DirectoryReservationService.javaChangedThree-argument constructor; visitors pass through the gate first
src/main/java/edu/se506/lending/infrastructure/approvals/FileApprovalSource.javaNewReal file adapter: whole-file validation, read afresh on every call
src/main/java/edu/se506/lending/infrastructure/directory/HttpLegacyDirectoryClient.javaNewReal HTTP client: bounded wait, one attempt, no retry, no fallback
src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryClient.javaChangedDocuments the failure contract; adds Failure
src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryAdapter.javaChangedMaps V to VISITOR; turns a client failure into DirectoryException
src/main/java/edu/se506/lending/bootstrap/ReservationApplication.javaChangedWires the visitor policy
src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.javaNewThe runnable system; settings from system properties
src/test/java/edu/se506/lending/visitor/*NewRules, gate, the eighteen acceptance examples and a precedence test (41 invocations)
src/test/java/edu/se506/lending/integration/*NewStub service, interface contract, directory outage (including a stalled body) and file tests (29 invocations)
src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.javaNewThree journeys against a separate process
src/test/java/edu/se506/lending/regression/BehaviourStrategyTest.java, Chapter06Checks.java, structure/RepositoryBoundaryTest.javaChangedThe policy map gains the visitor entry; no assertion changed
pom.xml, README.mdChangedVersion 0.13.1; description of the version
scripts/pipeline.sh, scripts/simulate-release.shNewFive-stage pipeline; simulated delivery and rollback
.github/workflows/pipeline.ymlNewThe same stages as a hosted-runner workflow (not run)
docs/ADR-013-visiting-researchers.mdNewDecision, alternatives, flag removal plan, rollback
ai-activity/*NewThe proposed and the repaired test of Section 13.9

2.2 Which test runs in which stage

StageSelectionInvocationsContents
2 Unit tests and architecture checksEverything not tagged integration or e2e203The 159 inherited invocations, the 41 visitor tests and the 3 example contract invocations
3 Integration@Tag("integration")26FileApprovalSourceIntegrationTest (13), DirectoryOutageIntegrationTest (10), HttpDirectoryClientContractTest (3)
4 End-to-end@Tag("e2e")3Chapter13JourneyE2ETest

The total is 232 invocations: 159 inherited and 73 new (41 + 3 + 26 + 3).

2.3 The directory failure policy

This table is the policy the HTTP client implements. Each row has a test.

What the client seesWhat the client doesTest
200 with one line, kind and statusReturns the rowanOrdinaryStudentRequestWorksThroughTheRealHttpClient
404Returns no row; the adapter reports the borrower as not foundaMissingRowIsAnOrdinaryRejection
500 or any other statusFails; no retryaServerErrorIsAnOutageNotANotFoundAndIsNotRetried
No answer at all within the bounded waitFails with a timeoutaSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest
Headers and the first byte promptly, then the body stallsFails with a timeout after the same overall wait; never returns the late bodyaStalledBodyIsAFailureWithinTheOverallWait
Connection refusedFailsaRefusedConnectionIsAnOutage
A body that is not exactly two fieldsFailsaMalformedAnswerIsAnOutageNotAGuess
An unknown kind codeFails at the adapter; never defaulted to student or staffanUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent

Policy: one attempt, a bounded wait, no retry and no fallback answer. The wait bounds the whole lookup, including the time to receive the body. It is a constructor argument; the demonstration passes its own lending.directory.timeout.millis.

2.4 The outcomes of a visitor request

SituationResultKind
Flag offRejected: visitor reservations are not enabled.; the register is not readExpected rejection
Item not CAM1 or CAM2Rejected; the register is not readExpected rejection
Fewer than one or more than three days, or three reservations already retainedRejected by the existing policy and count rulesExpected rejection
No record, a denied record, or a record for a different borrower, item or periodRejected: no valid sponsor approval.Expected rejection
Record valid at the request instant (validFrom inclusive, validUntil exclusive)Continues to the existing availability, reservation and notification stepsSuccess
Register missing, unreadable, malformed or with a duplicate keyApprovalSourceException; nothing is reserved or sentOperational failure
Directory unreachable, slow or nonsensicalDirectoryException; nothing is reserved or sentOperational failure

2.5 Regression carry-forward

Adding VISITOR to the enum has one deliberate consequence: the coordinator requires a policy for every category, so a construction that lists every category needs one more entry. This happened in four constructions in three test files (Section 4.7). No assertion changed. The inherited 159 invocations passed unchanged after those four edits. The assertions carried forward from Chapters 3–9 (Section 2.5 of the Chapter 10 companion) are untouched.

3. New and changed production listings

3.1 after/src/main/java/edu/se506/lending/domain/BorrowerCategory.java

package edu.se506.lending.domain;



public enum BorrowerCategory {
    STUDENT, STAFF, VISITOR
}

3.2 after/src/main/java/edu/se506/lending/domain/VisitorReservationRules.java

package edu.se506.lending.domain;

/**
 * Illustrative teaching rule for visiting researchers (change request CR-12): fewer than three
 * retained reservations, and one to three calendar days. It decides count and duration only;
 * sponsor approval is checked elsewhere, because this contract has no identity, item or clock.
 */
public final class VisitorReservationRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int activeReservations, int requestedDays) {
        return activeReservations >= 0
                && activeReservations < 3
                && requestedDays >= 1
                && requestedDays <= 3;
    }
}

3.3 after/src/main/java/edu/se506/lending/domain/ApprovalStatus.java

package edu.se506.lending.domain;

/** A sponsor's decision on one request. Any other word in the register is a data error. */
public enum ApprovalStatus {
    APPROVED, DENIED
}

3.4 after/src/main/java/edu/se506/lending/domain/ApprovalRecord.java

package edu.se506.lending.domain;

import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;

/**
 * A trusted sponsor decision for one exact request: borrower, item and half-open loan period.
 * The record is valid for requests made at or after {@code validFrom} and strictly before
 * {@code validUntil}. The loan period itself need not lie inside that window.
 */
public record ApprovalRecord(String borrowerId, String itemId, LocalDate start, LocalDate endExclusive,
                             String sponsorId, ApprovalStatus status, Instant validFrom, Instant validUntil) {

    public ApprovalRecord {
        requireText(borrowerId, "borrowerId");
        requireText(itemId, "itemId");
        requireText(sponsorId, "sponsorId");
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(endExclusive, "endExclusive");
        Objects.requireNonNull(status, "status");
        Objects.requireNonNull(validFrom, "validFrom");
        Objects.requireNonNull(validUntil, "validUntil");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("Loan period must end after it starts.");
        }
        if (!validFrom.isBefore(validUntil)) {
            throw new IllegalArgumentException("Validity must end after it starts.");
        }
    }

    /** True only for the exact borrower, item and period; approval for anything else is not enough. */
    public boolean matches(String borrower, String item, LocalDate periodStart, LocalDate periodEnd) {
        return borrowerId.equals(borrower) && itemId.equals(item)
                && start.equals(periodStart) && endExclusive.equals(periodEnd);
    }

    /** Equality at the start of the window is valid; equality at its end is not. */
    public boolean validAt(Instant now) {
        Objects.requireNonNull(now, "now");
        return !now.isBefore(validFrom) && now.isBefore(validUntil);
    }

    public boolean approvesAt(Instant now) {
        return status == ApprovalStatus.APPROVED && validAt(now);
    }

    private static void requireText(String value, String name) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(name + " must not be blank.");
        }
    }
}

3.5 after/src/main/java/edu/se506/lending/application/port/ApprovalSource.java

package edu.se506.lending.application.port;

import edu.se506.lending.domain.ApprovalRecord;
import java.time.LocalDate;
import java.util.Optional;

/**
 * Finds the sponsor approval recorded for one exact request. Empty means that no record exists
 * for it. A source that cannot give a trustworthy answer (unreadable, malformed, ambiguous) must
 * throw {@link ApprovalSourceException}, never return empty.
 */
@FunctionalInterface
public interface ApprovalSource {
    Optional<ApprovalRecord> find(String borrowerId, String itemId, LocalDate start, LocalDate endExclusive);
}

3.6 after/src/main/java/edu/se506/lending/application/port/ApprovalSourceException.java

package edu.se506.lending.application.port;

/** The approval register could not be read or trusted. This is not a sponsor's rejection. */
public final class ApprovalSourceException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public ApprovalSourceException(String message) { super(message); }
    public ApprovalSourceException(String message, Throwable cause) { super(message, cause); }
}

3.7 after/src/main/java/edu/se506/lending/application/VisitorGate.java

package edu.se506.lending.application;

import edu.se506.lending.application.port.ApprovalSource;
import edu.se506.lending.domain.ApprovalRecord;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

/**
 * The extra checks for a visiting researcher, kept apart from the count and duration policy: the
 * feature flag, the item allowlist and a matching sponsor approval that is valid at one request-time
 * instant. It returns a rejection text, or empty when the request may go on to the coordinator.
 * An unreadable or untrustworthy approval register is not a rejection: the exception from the
 * source is not caught here, so no reservation can follow it.
 */
public final class VisitorGate {
    public static final String DISABLED = "Rejected: visitor reservations are not enabled.";
    public static final String ITEM_NOT_ALLOWED = "Rejected: item not available to visitors.";
    public static final String NOT_APPROVED = "Rejected: no valid sponsor approval.";

    private static final Set<String> ALLOWED_ITEMS = Set.of("CAM1", "CAM2");

    private final ApprovalSource source;
    private final Clock clock;
    private final boolean enabled;

    public VisitorGate(ApprovalSource source, Clock clock, boolean enabled) {
        this.source = Objects.requireNonNull(source, "source");
        this.clock = Objects.requireNonNull(clock, "clock");
        this.enabled = enabled;
    }

    /** A gate for applications that have not enabled visitors: the approval source is never read. */
    public static VisitorGate disabled() {
        return new VisitorGate((borrowerId, itemId, start, end) -> {
            throw new IllegalStateException("The approval source must not be read while visitors are disabled.");
        }, Clock.systemUTC(), false);
    }

    public Optional<String> rejection(String borrowerId, String itemId, LocalDate start, int days) {
        if (!enabled) {
            return Optional.of(DISABLED);
        }
        if (!ALLOWED_ITEMS.contains(itemId)) {
            return Optional.of(ITEM_NOT_ALLOWED);
        }
        if (days < 1) {
            return Optional.empty();      // there is no period to look up; the policy rejects it
        }
        Instant now = clock.instant();    // one request-time instant for the whole decision
        LocalDate endExclusive = start.plusDays(days);
        Optional<ApprovalRecord> record = source.find(borrowerId, itemId, start, endExclusive);
        // The source should return only the exact request, but approval for anything else is never enough.
        if (record.isEmpty() || !record.get().matches(borrowerId, itemId, start, endExclusive)
                || !record.get().approvesAt(now)) {
            return Optional.of(NOT_APPROVED);
        }
        return Optional.empty();
    }
}

3.8 after/src/main/java/edu/se506/lending/application/DirectoryReservationService.java

package edu.se506.lending.application;

import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;

import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;

/** Directory-backed entry point; this does not authenticate the caller. */
public final class DirectoryReservationService {
    private final BorrowerDirectory directory;
    private final ReservationCoordinator coordinator;
    private final VisitorGate visitorGate;

    public DirectoryReservationService(BorrowerDirectory directory,
                                       ReservationCoordinator coordinator) {
        this(directory, coordinator, VisitorGate.disabled());
    }

    public DirectoryReservationService(BorrowerDirectory directory,
                                       ReservationCoordinator coordinator,
                                       VisitorGate visitorGate) {
        this.directory = Objects.requireNonNull(directory, "directory");
        this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
        this.visitorGate = Objects.requireNonNull(visitorGate, "visitorGate");
    }

    public String reserve(String borrowerId, String itemId, LocalDate start, int days) {
        if (borrowerId == null || borrowerId.isBlank() || itemId == null || itemId.isBlank()) {
            throw new IllegalArgumentException("Identifiers must not be blank.");
        }
        Objects.requireNonNull(start, "start");
        var found = directory.find(borrowerId);
        if (found.isEmpty()) return "Rejected: borrower not found or inactive.";
        Borrower borrower = found.get();
        if (!borrowerId.equals(borrower.id())) {
            throw new DirectoryException("Directory returned a different borrower");
        }
        if (borrower.category() == BorrowerCategory.VISITOR) {
            Optional<String> rejection = visitorGate.rejection(borrower.id(), itemId, start, days);
            if (rejection.isPresent()) return rejection.get();
        }
        return coordinator.reserve(borrower.id(), itemId, start, days, borrower.category());
    }
}

3.9 after/src/main/java/edu/se506/lending/infrastructure/approvals/FileApprovalSource.java

package edu.se506.lending.infrastructure.approvals;

import edu.se506.lending.application.port.ApprovalSource;
import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

/**
 * Reads the sponsor-approval register: a local UTF-8 tab-separated file with exactly eight columns
 * (borrowerId, itemId, start, endExclusive, sponsorId, status, validFrom, validUntil) and a header.
 * The file is read afresh on every call and is rejected as a whole if any row is invalid or if two
 * rows share a request key. It is a trusted, sequential fixture, not a concurrent approval database.
 */
public final class FileApprovalSource implements ApprovalSource {
    static final String HEADER =
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";

    private final Path file;

    public FileApprovalSource(Path file) {
        this.file = Objects.requireNonNull(file, "file");
    }

    @Override
    public Optional<ApprovalRecord> find(String borrowerId, String itemId, LocalDate start, LocalDate endExclusive) {
        List<ApprovalRecord> records = readAll();
        for (ApprovalRecord record : records) {
            if (record.matches(borrowerId, itemId, start, endExclusive)) {
                return Optional.of(record);
            }
        }
        return Optional.empty();
    }

    private List<ApprovalRecord> readAll() {
        List<String> lines;
        try {
            lines = Files.readAllLines(file, StandardCharsets.UTF_8);
        } catch (IOException failure) {
            throw new ApprovalSourceException("Approval register cannot be read: " + file.getFileName(), failure);
        }
        if (lines.isEmpty() || !HEADER.equals(lines.get(0))) {
            throw new ApprovalSourceException("Approval register has no valid header row.");
        }
        List<ApprovalRecord> records = new ArrayList<>();
        Set<String> keys = new HashSet<>();
        for (int i = 1; i < lines.size(); i++) {
            int row = i + 1;
            ApprovalRecord record = parse(lines.get(i), row);
            String key = record.borrowerId() + "|" + record.itemId() + "|" + record.start() + "|" + record.endExclusive();
            if (!keys.add(key)) {
                throw new ApprovalSourceException("Duplicate approval request key at row " + row + ".");
            }
            records.add(record);
        }
        return List.copyOf(records);
    }

    private static ApprovalRecord parse(String line, int row) {
        String[] columns = line.split("\t", -1);
        if (columns.length != 8) {
            throw new ApprovalSourceException("Row " + row + " has " + columns.length + " columns, not 8.");
        }
        try {
            return new ApprovalRecord(columns[0], columns[1], LocalDate.parse(columns[2]), LocalDate.parse(columns[3]),
                    columns[4], status(columns[5], row), Instant.parse(columns[6]), Instant.parse(columns[7]));
        } catch (DateTimeParseException | IllegalArgumentException bad) {
            throw new ApprovalSourceException("Row " + row + " is not a valid approval record.", bad);
        }
    }

    private static ApprovalStatus status(String text, int row) {
        return switch (text) {
            case "APPROVED" -> ApprovalStatus.APPROVED;
            case "DENIED" -> ApprovalStatus.DENIED;
            default -> throw new ApprovalSourceException("Row " + row + " has an unknown status.");
        };
    }
}

3.10 after/src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryClient.java

package edu.se506.lending.infrastructure.directory;

/** Illustrative provider contract, not an actual university API. See HttpLegacyDirectoryClient for a network client. */
@FunctionalInterface
public interface LegacyDirectoryClient {
    // null means no row; codes are exact and case-sensitive.
    Person lookup(String key);

    record Person(String key, String kind, String status) { }

    final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public Failure(String message) { super(message); }
        public Failure(String message, Throwable cause) { super(message, cause); }
    }
}

3.11 after/src/main/java/edu/se506/lending/infrastructure/directory/LegacyDirectoryAdapter.java

package edu.se506.lending.infrastructure.directory;

import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;

import java.util.Objects;
import java.util.Optional;

public final class LegacyDirectoryAdapter implements BorrowerDirectory {
    private final LegacyDirectoryClient client;

    public LegacyDirectoryAdapter(LegacyDirectoryClient client) {
        this.client = Objects.requireNonNull(client, "client");
    }

    @Override
    public Optional<Borrower> find(String borrowerId) {
        if (borrowerId == null || borrowerId.isBlank()) {
            throw new IllegalArgumentException("Borrower id required");
        }
        LegacyDirectoryClient.Person row;
        try {
            row = client.lookup(borrowerId);
        } catch (LegacyDirectoryClient.Failure failure) {
            throw new DirectoryException("Borrower directory unavailable", failure);
        }
        if (row == null) return Optional.empty();
        if (!borrowerId.equals(row.key())) throw new DirectoryException("Mismatched borrower id");
        BorrowerCategory category;
        if ("S".equals(row.kind())) category = BorrowerCategory.STUDENT;
        else if ("T".equals(row.kind())) category = BorrowerCategory.STAFF;
        else if ("V".equals(row.kind())) category = BorrowerCategory.VISITOR;
        else throw new DirectoryException("Unsupported borrower category");
        if ("N".equals(row.status())) return Optional.empty();
        if (!"Y".equals(row.status())) throw new DirectoryException("Unsupported borrower status");
        return Optional.of(new Borrower(row.key(), category));
    }
}

3.12 after/src/main/java/edu/se506/lending/infrastructure/directory/HttpLegacyDirectoryClient.java

package edu.se506.lending.infrastructure.directory;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
 * Asks a directory service over HTTP: {@code GET <base>/borrowers/<key>}. A 200 response carries one
 * line, the provider's kind and status separated by a tab. A 404 means there is no such row. Everything
 * else is a failure: a timeout, a refused connection, any other status and a body that is not exactly
 * two fields. Policy: one attempt, a bounded wait, no retry and no fallback answer. The wait bounds the
 * whole lookup, including the time to receive the body: the request timeout of the JDK client is not
 * documented to cover the body, and a server that sends its headers and then stalls would otherwise hold
 * the caller for as long as it likes. A caller that cannot get an answer must fail, because an outage
 * must never look like a missing borrower.
 */
public final class HttpLegacyDirectoryClient implements LegacyDirectoryClient {
    private final HttpClient http;
    private final URI base;
    private final Duration requestTimeout;

    public HttpLegacyDirectoryClient(URI base, Duration connectTimeout, Duration requestTimeout) {
        this.base = Objects.requireNonNull(base, "base");
        this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout");
        this.http = HttpClient.newBuilder()
                .connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout"))
                .build();
    }

    @Override
    public Person lookup(String key) {
        URI uri = URI.create(base.toString().replaceAll("/+$", "") + "/borrowers/"
                + URLEncoder.encode(key, StandardCharsets.UTF_8));
        HttpRequest request = HttpRequest.newBuilder(uri).timeout(requestTimeout).GET().build();
        CompletableFuture<HttpResponse<String>> pending =
                http.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        HttpResponse<String> response;
        try {
            response = pending.get(requestTimeout.toNanos(), TimeUnit.NANOSECONDS);
        } catch (TimeoutException late) {
            pending.cancel(true);   // asks the client to drop the exchange; the caller does not wait for it
            throw new Failure("Directory did not answer within " + requestTimeout.toMillis() + " ms.", late);
        } catch (InterruptedException interrupted) {
            pending.cancel(true);
            Thread.currentThread().interrupt();
            throw new Failure("Directory request was interrupted.", interrupted);
        } catch (ExecutionException failed) {
            Throwable cause = failed.getCause() == null ? failed : failed.getCause();
            throw new Failure("Directory unavailable: " + cause.getClass().getSimpleName(), cause);
        }
        if (response.statusCode() == 404) {
            return null;
        }
        if (response.statusCode() != 200) {
            throw new Failure("Directory answered with status " + response.statusCode() + ".");
        }
        String[] fields = response.body().strip().split("\t", -1);
        if (fields.length != 2) {
            throw new Failure("Directory answered with a malformed row.");
        }
        return new Person(key, fields[0], fields[1]);
    }
}

3.13 after/src/main/java/edu/se506/lending/bootstrap/ReservationApplication.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.ConfirmationFormatter;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.port.NotificationSender;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.domain.ReservationRules;
import edu.se506.lending.domain.StaffReservationRules;
import edu.se506.lending.domain.VisitorReservationRules;
import edu.se506.lending.infrastructure.notification.ConsoleNotificationSender;
import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;

import java.time.LocalDate;
import java.util.Map;

public final class ReservationApplication {
    private ReservationApplication() { }

    public static ReservationCoordinator create(NotificationSender sender) {
        return new ReservationCoordinator(
                Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
                       BorrowerCategory.STAFF, new StaffReservationRules(),
                       BorrowerCategory.VISITOR, new VisitorReservationRules()),
                new ConfirmationFormatter(), sender, new InMemoryReservationRepository());
    }

    public static void main(String[] args) {
        ConfirmationFormatter noticeFormat = new ConfirmationFormatter();
        NotificationSender sender = new ConsoleNotificationSender(System.out, noticeFormat);
        ReservationCoordinator coordinator = create(sender);
        String result = coordinator.reserve("B1", "CAM1", LocalDate.of(2026, 10, 20), 2);
        System.out.println("Result: " + result);
    }
}

3.14 after/src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.java

package edu.se506.lending.bootstrap;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.infrastructure.approvals.FileApprovalSource;
import edu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import java.net.URI;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

/**
 * The runnable system for Chapter 13: a directory service reached over HTTP, a sponsor-approval file
 * and one feature flag. Settings are system properties so that a test can start it as a separate process:
 * lending.directory.url (required), lending.approvals.file (required), lending.visitors (default false),
 * lending.now (an ISO instant, default the system clock) and lending.directory.timeout.millis (default 1000).
 */
public final class Chapter13Demo {
    public static void main(String[] args) {
        URI directoryUrl = URI.create(required("lending.directory.url"));
        Path approvals = Path.of(required("lending.approvals.file"));
        boolean visitorsEnabled = Boolean.parseBoolean(System.getProperty("lending.visitors", "false"));
        String now = System.getProperty("lending.now");
        Clock clock = now == null ? Clock.systemUTC() : Clock.fixed(Instant.parse(now), ZoneOffset.UTC);
        long timeoutMillis = Long.parseLong(System.getProperty("lending.directory.timeout.millis", "1000"));

        var sender = new RecordingNotificationSender();
        ReservationCoordinator coordinator = ReservationApplication.create(sender);
        var directory = new LegacyDirectoryAdapter(new HttpLegacyDirectoryClient(
                directoryUrl, Duration.ofMillis(timeoutMillis), Duration.ofMillis(timeoutMillis)));
        var gate = new VisitorGate(new FileApprovalSource(approvals), clock, visitorsEnabled);
        var service = new DirectoryReservationService(directory, coordinator, gate);

        LocalDate start = LocalDate.of(2026, 10, 20);
        System.out.println("Visitors enabled: " + visitorsEnabled);
        attempt("Student B1", service, "B1", "CAM1", start, 2);
        attempt("Visitor V1", service, "V1", "CAM2", start, 2);
        attempt("Visitor V2", service, "V2", "CAM2", start, 2);
        attempt("Unknown U9", service, "U9", "MIC1", start, 1);
        attempt("Outage BROKEN", service, "BROKEN", "MIC1", start, 1);
        System.out.println("Reservations: " + coordinator.reservations().size());
        System.out.println("Notices: " + sender.received().size());
    }

    private static void attempt(String label, DirectoryReservationService service,
                                String borrower, String item, LocalDate start, int days) {
        String outcome;
        try {
            outcome = service.reserve(borrower, item, start, days);
        } catch (DirectoryException failure) {
            outcome = "Unavailable: " + failure.getMessage();
        } catch (ApprovalSourceException failure) {
            outcome = "Approval register unavailable: " + failure.getMessage();
        }
        System.out.println(label + ": " + outcome);
    }

    private static String required(String property) {
        String value = System.getProperty(property);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Missing system property " + property);
        }
        return value;
    }
}

4. New and changed test listings

4.1 after/src/test/java/edu/se506/lending/visitor/VisitorRulesTest.java

package edu.se506.lending.visitor;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import edu.se506.lending.domain.VisitorReservationRules;
import java.time.Instant;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

/** Unit tests for the visitor count and duration rule and for the approval record's own rules. */
class VisitorRulesTest {
    private static final LocalDate START = LocalDate.of(2026, 10, 20);
    private static final Instant FROM = Instant.parse("2026-10-18T00:00:00Z");
    private static final Instant UNTIL = Instant.parse("2026-10-20T00:00:00Z");

    private static ApprovalRecord record(ApprovalStatus status) {
        return new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1", status, FROM, UNTIL);
    }

    @ParameterizedTest
    @CsvSource({"0,1,true", "2,3,true", "0,2,true", "3,1,false", "0,4,false", "0,0,false", "-1,1,false", "0,-1,false"})
    void countAndDurationBoundaries(int count, int days, boolean expected) {
        assertEquals(expected, new VisitorReservationRules().mayRequest(count, days));
    }

    @Test void equalityAtTheStartOfTheWindowIsValidAndAtTheEndIsNot() {
        ApprovalRecord approved = record(ApprovalStatus.APPROVED);
        assertTrue(approved.validAt(FROM));
        assertTrue(approved.validAt(UNTIL.minusSeconds(1)));
        assertFalse(approved.validAt(UNTIL));
        assertFalse(approved.validAt(FROM.minusSeconds(1)));
    }

    @Test void onlyAnApprovedStatusApprovesAndOnlyInsideTheWindow() {
        Instant inside = Instant.parse("2026-10-19T12:00:00Z");
        assertTrue(record(ApprovalStatus.APPROVED).approvesAt(inside));
        assertFalse(record(ApprovalStatus.DENIED).approvesAt(inside));
        assertFalse(record(ApprovalStatus.APPROVED).approvesAt(UNTIL));
    }

    @Test void aRecordMatchesOnlyTheExactBorrowerItemAndPeriod() {
        ApprovalRecord approved = record(ApprovalStatus.APPROVED);
        LocalDate end = START.plusDays(2);
        assertTrue(approved.matches("V1", "CAM1", START, end));
        assertFalse(approved.matches("V2", "CAM1", START, end));
        assertFalse(approved.matches("V1", "CAM2", START, end));
        assertFalse(approved.matches("V1", "CAM1", START.plusDays(1), end));
        assertFalse(approved.matches("V1", "CAM1", START, end.plusDays(1)));
    }

    @Test void anInvalidRecordCannotBeBuilt() {
        assertThrows(IllegalArgumentException.class, () ->
                new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), " ", ApprovalStatus.APPROVED, FROM, UNTIL));
        assertThrows(IllegalArgumentException.class, () ->
                new ApprovalRecord("V1", "CAM1", START, START, "S1", ApprovalStatus.APPROVED, FROM, UNTIL));
        assertThrows(IllegalArgumentException.class, () ->
                new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1", ApprovalStatus.APPROVED, UNTIL, UNTIL));
        assertThrows(NullPointerException.class, () ->
                new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1", null, FROM, UNTIL));
    }
}

4.2 after/src/test/java/edu/se506/lending/visitor/VisitorGateTest.java

package edu.se506.lending.visitor;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.ApprovalSource;
import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;

/** Unit tests for the visitor gate with a controlled approval source and clock. */
class VisitorGateTest {
    private static final LocalDate START = LocalDate.of(2026, 10, 20);
    private static final Instant FROM = Instant.parse("2026-10-18T00:00:00Z");
    private static final Instant UNTIL = Instant.parse("2026-10-20T00:00:00Z");
    private static final Instant NOW = Instant.parse("2026-10-19T12:00:00Z");

    private final AtomicInteger reads = new AtomicInteger();

    private ApprovalSource sourceOf(ApprovalRecord record) {
        return (borrower, item, start, end) -> {
            reads.incrementAndGet();
            return Optional.ofNullable(record);
        };
    }

    private static ApprovalRecord record(ApprovalStatus status) {
        return new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1", status, FROM, UNTIL);
    }

    private VisitorGate gate(ApprovalRecord record, Instant now, boolean enabled) {
        return new VisitorGate(sourceOf(record), Clock.fixed(now, ZoneOffset.UTC), enabled);
    }

    @Test void aMatchingApprovedRecordInsideItsWindowLetsTheRequestContinue() {
        assertEquals(Optional.empty(), gate(record(ApprovalStatus.APPROVED), NOW, true).rejection("V1", "CAM1", START, 2));
        assertEquals(1, reads.get());
    }

    @Test void aDisabledGateRejectsWithoutReadingTheSource() {
        assertEquals(Optional.of(VisitorGate.DISABLED),
                gate(record(ApprovalStatus.APPROVED), NOW, false).rejection("V1", "CAM1", START, 2));
        assertEquals(0, reads.get());
    }

    @Test void anItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource() {
        assertEquals(Optional.of(VisitorGate.ITEM_NOT_ALLOWED),
                gate(record(ApprovalStatus.APPROVED), NOW, true).rejection("V1", "MIC1", START, 2));
        assertEquals(0, reads.get());
    }

    @Test void aNonPositiveDurationIsLeftToThePolicyWithoutReadingTheSource() {
        VisitorGate gate = gate(record(ApprovalStatus.APPROVED), NOW, true);
        assertEquals(Optional.empty(), gate.rejection("V1", "CAM1", START, 0));
        assertEquals(Optional.empty(), gate.rejection("V1", "CAM1", START, -2));
        assertEquals(0, reads.get());
    }

    @Test void aMissingOrDeniedRecordIsAnExpectedRejection() {
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(null, NOW, true).rejection("V1", "CAM1", START, 2));
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED),
                gate(record(ApprovalStatus.DENIED), NOW, true).rejection("V1", "CAM1", START, 2));
    }

    @Test void theWindowIncludesItsStartAndExcludesItsEnd() {
        ApprovalRecord approved = record(ApprovalStatus.APPROVED);
        assertEquals(Optional.empty(), gate(approved, FROM, true).rejection("V1", "CAM1", START, 2));
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(approved, UNTIL, true).rejection("V1", "CAM1", START, 2));
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED),
                gate(approved, FROM.minusSeconds(1), true).rejection("V1", "CAM1", START, 2));
    }

    @Test void aSourceThatReturnsADifferentRequestDoesNotApprove() {
        ApprovalRecord other = new ApprovalRecord("V2", "CAM1", START, START.plusDays(2), "S1",
                ApprovalStatus.APPROVED, FROM, UNTIL);
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(other, NOW, true).rejection("V1", "CAM1", START, 2));
    }

    @Test void anOperationalFailureOfTheSourceIsNotCaught() {
        ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
        VisitorGate gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
        assertSame(failure, assertThrows(ApprovalSourceException.class, () -> gate.rejection("V1", "CAM1", START, 2)));
    }

    @Test void oneRequestReadsTheClockOnce() {
        AtomicInteger instants = new AtomicInteger();
        Clock counting = new Clock() {
            @Override public ZoneId getZone() { return ZoneOffset.UTC; }
            @Override public Clock withZone(ZoneId zone) { return this; }
            @Override public Instant instant() { instants.incrementAndGet(); return NOW; }
        };
        new VisitorGate(sourceOf(record(ApprovalStatus.APPROVED)), counting, true).rejection("V1", "CAM1", START, 2);
        assertEquals(1, instants.get());
    }

    @Test void theDisabledFactoryNeverReadsASource() {
        assertEquals(Optional.of(VisitorGate.DISABLED), VisitorGate.disabled().rejection("V1", "CAM1", START, 2));
    }
}

4.3 after/src/test/java/edu/se506/lending/visitor/VisitorAcceptanceTest.java

package edu.se506.lending.visitor;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.LoanService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.application.port.BorrowerDirectory;
import edu.se506.lending.application.port.NotificationException;
import edu.se506.lending.bootstrap.LoanSnapshotRows;
import edu.se506.lending.bootstrap.OverdueReportService;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import edu.se506.lending.domain.Borrower;
import edu.se506.lending.domain.BorrowerCategory;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * The acceptance matrix of change request CR-12 (examples AC1 to AC18), run through the real
 * directory service, coordinator, repository and loan service with a controlled directory,
 * approval source and clock. Each test builds its own fixture so one failure cannot mask another.
 */
class VisitorAcceptanceTest {
    private static final LocalDate START = LocalDate.of(2026, 10, 20);
    private static final Instant FROM = Instant.parse("2026-10-18T00:00:00Z");
    private static final Instant UNTIL = Instant.parse("2026-10-20T00:00:00Z");
    private static final Instant NOW = Instant.parse("2026-10-19T12:00:00Z");
    private static final String SUCCESS_V1_CAM1 = "Confirmed: R1 for V1 on CAM1 [2026-10-20, 2026-10-22).";
    private static final String LIMIT = "Rejected: borrower limit or duration.";

    private Locale savedLocale;

    @BeforeEach void pinTheLocale() { savedLocale = Locale.getDefault(); Locale.setDefault(Locale.ROOT); }
    @AfterEach void restoreTheLocale() { Locale.setDefault(savedLocale); }

    /** A complete small system with a controlled directory, approval list, clock and sender. */
    private static final class World {
        final List<ApprovalRecord> approvals = new ArrayList<>();
        final AtomicInteger approvalReads = new AtomicInteger();
        final RecordingNotificationSender sender = new RecordingNotificationSender();
        final ReservationCoordinator coordinator;
        final DirectoryReservationService service;

        World(boolean enabled, Instant now, edu.se506.lending.application.port.NotificationSender notifier) {
            coordinator = ReservationApplication.create(notifier);
            BorrowerDirectory directory = id -> switch (id) {
                case "V1", "V2" -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR));
                case "S1" -> Optional.of(new Borrower(id, BorrowerCategory.STUDENT));
                case "T1" -> Optional.of(new Borrower(id, BorrowerCategory.STAFF));
                default -> Optional.empty();
            };
            VisitorGate gate = new VisitorGate((borrower, item, start, end) -> {
                approvalReads.incrementAndGet();
                return approvals.stream().filter(r -> r.matches(borrower, item, start, end)).findFirst();
            }, Clock.fixed(now, ZoneOffset.UTC), enabled);
            service = new DirectoryReservationService(directory, coordinator, gate);
        }

        World(boolean enabled, Instant now) { this(enabled, now, new RecordingNotificationSender()); }

        World approve(String borrower, String item, LocalDate start, int days) {
            approvals.add(new ApprovalRecord(borrower, item, start, start.plusDays(days), "S1",
                    ApprovalStatus.APPROVED, FROM, UNTIL));
            return this;
        }
    }

    private static void assertNothingChanged(World w) {
        assertTrue(w.coordinator.reservations().isEmpty());
    }

    @Test void ac1_aVisitorWithAMatchingApprovalReservesCam1ForTwoDays() {
        var recorder = new RecordingNotificationSender();
        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 2);
        assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
        assertEquals(1, w.coordinator.reservations().size());
        assertEquals(1, recorder.received().size());
    }

    @Test void ac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowed() {
        var w = new World(true, NOW).approve("V1", "CAM2", START, 3);
        assertEquals("Confirmed: R1 for V1 on CAM2 [2026-10-20, 2026-10-23).", w.service.reserve("V1", "CAM2", START, 3));
    }

    @Test void ac3_fourDaysIsRejectedEvenWithAMatchingRecord() {
        var recorder = new RecordingNotificationSender();
        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 4);
        assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START, 4));
        assertNothingChanged(w);
        assertTrue(recorder.received().isEmpty());
    }

    @Test void ac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister() {
        var recorder = new RecordingNotificationSender();
        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 2);
        assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START, 0));
        assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START, -1));
        assertNothingChanged(w);
        assertEquals(0, w.approvalReads.get());
        assertTrue(recorder.received().isEmpty());
    }

    @Test void ac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecord() {
        var w = new World(true, NOW).approve("V1", "MIC1", START, 2);
        assertEquals(VisitorGate.ITEM_NOT_ALLOWED, w.service.reserve("V1", "MIC1", START, 2));
        assertNothingChanged(w);
    }

    @Test void ac6_aMissingOrDeniedApprovalIsAnExpectedRejection() {
        var missing = new World(true, NOW);
        assertEquals(VisitorGate.NOT_APPROVED, missing.service.reserve("V1", "CAM1", START, 2));
        assertNothingChanged(missing);
        var denied = new World(true, NOW);
        denied.approvals.add(new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1",
                ApprovalStatus.DENIED, FROM, UNTIL));
        assertEquals(VisitorGate.NOT_APPROVED, denied.service.reserve("V1", "CAM1", START, 2));
        assertNothingChanged(denied);
    }

    @Test void ac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise() {
        var w = new World(true, NOW).approve("V2", "CAM1", START, 2).approve("V1", "CAM2", START, 2)
                .approve("V1", "CAM1", START, 3);
        assertEquals(VisitorGate.NOT_APPROVED, w.service.reserve("V1", "CAM1", START, 2));
        assertNothingChanged(w);
    }

    @Test void ac8_theRequestInstantEqualToValidFromIsValid() {
        var w = new World(true, FROM).approve("V1", "CAM1", START, 2);
        assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
    }

    @Test void ac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejected() {
        var atExpiry = new World(true, UNTIL).approve("V1", "CAM1", START, 2);
        assertEquals(VisitorGate.NOT_APPROVED, atExpiry.service.reserve("V1", "CAM1", START, 2));
        var early = new World(true, FROM.minusSeconds(1)).approve("V1", "CAM1", START, 2);
        assertEquals(VisitorGate.NOT_APPROVED, early.service.reserve("V1", "CAM1", START, 2));
        assertNothingChanged(atExpiry);
        assertNothingChanged(early);
    }

    @Test void ac10_anUnreadableApprovalRegisterIsAnOperationalFailureNotARejection() {
        var recorder = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(recorder);
        ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), coordinator, gate);
        assertSame(failure, assertThrows(ApprovalSourceException.class, () -> service.reserve("V1", "CAM1", START, 2)));
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(recorder.received().isEmpty());
    }

    @Test void precedence_theRegisterIsReadBeforeTheCountAndDurationRulesApply() {
        var recorder = new RecordingNotificationSender();
        var coordinator = ReservationApplication.create(recorder);
        ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
        var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), coordinator, gate);
        // Four days breaks the duration rule, but the gate reads the register first, so the failure is what the caller sees.
        assertSame(failure, assertThrows(ApprovalSourceException.class, () -> service.reserve("V1", "CAM1", START, 4)));
        // Zero days is left to the coordinator: the register is not read and the limit rejection is returned.
        assertEquals(LIMIT, service.reserve("V1", "CAM1", START, 0));
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(recorder.received().isEmpty());
    }

    @Test void ac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturned() {
        Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
        var w = new World(true, NOW);
        var loans = new LoanService(w.coordinator, checkoutDay);
        for (int i = 0; i < 3; i++) {
            assertTrue(w.coordinator.reserve("V1", "CAM" + (1 + i % 2), START.plusDays(10L * i), 1, BorrowerCategory.VISITOR)
                    .startsWith("Confirmed"));
        }
        loans.checkOut("R1");
        loans.returnLoan("R1");
        w.approve("V1", "CAM1", START.plusDays(40), 1);
        assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START.plusDays(40), 1));
        assertEquals(3, w.coordinator.reservations().size());
    }

    @Test void ac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister() {
        var w = new World(false, NOW).approve("V1", "CAM1", START, 2);
        assertEquals(VisitorGate.DISABLED, w.service.reserve("V1", "CAM1", START, 2));
        assertEquals(0, w.approvalReads.get());
        assertNothingChanged(w);
        var defaultService = new DirectoryReservationService(
                id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), ReservationApplication.create(new RecordingNotificationSender()));
        assertEquals(VisitorGate.DISABLED, defaultService.reserve("V1", "CAM1", START, 2));
    }

    @Test void ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister() {
        for (boolean enabled : new boolean[] {false, true}) {
            var w = new World(enabled, NOW);
            assertEquals("Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-10-27).", w.service.reserve("S1", "CAM1", START, 7));
            assertEquals("Confirmed: R2 for T1 on MIC1 [2026-10-20, 2026-11-03).", w.service.reserve("T1", "MIC1", START, 14));
            assertEquals("Rejected: borrower limit or duration.", w.service.reserve("S1", "CAM2", START, 8));
            assertEquals(0, w.approvalReads.get());
        }
    }

    @Test void ac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule() {
        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
        assertTrue(w.coordinator.reserve("S1", "CAM1", START, 2).startsWith("Confirmed: R1"));
        assertEquals("Rejected: item unavailable.", w.service.reserve("V1", "CAM1", START, 2));
        assertEquals(1, w.coordinator.reservations().size());
    }

    @Test void ac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace() {
        var w = new World(true, NOW, reservation -> { throw new NotificationException("outbox down"); })
                .approve("V1", "CAM1", START, 2);
        assertEquals(SUCCESS_V1_CAM1 + " Notification failed; reservation retained.", w.service.reserve("V1", "CAM1", START, 2));
        assertEquals(1, w.coordinator.reservations().size());
    }

    @Test void ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff() {
        Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
        assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
        var afterwards = new DirectoryReservationService(
                id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), w.coordinator, VisitorGate.disabled());
        assertEquals(VisitorGate.DISABLED, afterwards.reserve("V1", "CAM2", START, 1));
        var loans = new LoanService(w.coordinator, checkoutDay);
        assertTrue(loans.checkOut("R1").active());
        assertFalse(loans.returnLoan("R1").active());
    }

    @Test void ac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore() {
        var w = new World(true, NOW).approve("U9", "CAM1", START, 2);
        assertEquals("Rejected: borrower not found or inactive.", w.service.reserve("U9", "CAM1", START, 2));
        assertEquals(0, w.approvalReads.get());
        assertNothingChanged(w);
    }

    @Test void ac18_aVisitorLoanInTheOverdueReportKeepsTheExistingKindRuleSd11_1() {
        Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
        Clock reportDay = Clock.fixed(Instant.parse("2026-10-22T09:00:00Z"), ZoneOffset.UTC);
        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
        assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
        var loans = new LoanService(w.coordinator, checkoutDay);
        loans.checkOut("R1");
        var rows = new LoanSnapshotRows(loans::loans, w.coordinator::reservations, borrower -> "V");
        // The legacy report knows only S and T. Any other kind pays 1.00 a day (suspected defect SD-11-1,
        // pinned in Chapter 11 and not fixed here), so a visitor one day late is fined 1.00.
        assertEquals("OVERDUE REPORT 2026-10-22\nCAM1 V1 1 day(s) late, fine 1.00\n1 overdue, total fines 1.00\n",
                new OverdueReportService(rows, reportDay).report());
    }
}

4.4 after/src/test/java/edu/se506/lending/integration/DirectoryStub.java

package edu.se506.lending.integration;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * A controlled local directory service for tests: the JDK's own HTTP server on the loopback
 * address and a free port. Known keys answer with a kind and a status separated by a tab. The key
 * "SLOW" waits before sending anything until the stub is closed, "SLOWBODY" sends its headers and the
 * first byte of the body and then holds the rest until the stub is closed, "BROKEN" answers 500 and
 * "GARBAGE" answers with a body that is not two fields. Everything else is 404. It is a stand-in for a real provider, not the provider.
 */
public final class DirectoryStub implements AutoCloseable {
    private final HttpServer server;
    private final CountDownLatch release = new CountDownLatch(1);
    private volatile int requests;

    public DirectoryStub() throws IOException {
        server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        server.createContext("/borrowers/", this::handle);
        server.start();
    }

    public URI baseUri() {
        return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
    }

    public int requests() {
        return requests;
    }

    private void handle(HttpExchange exchange) throws IOException {
        requests++;
        String key = exchange.getRequestURI().getPath().substring("/borrowers/".length());
        int status = 200;
        boolean stallBody = false;
        String body;
        switch (key) {
            case "B1" -> body = "S\tY";
            case "T1" -> body = "T\tY";
            case "V1", "V2" -> body = "V\tY";
            case "VOFF" -> body = "V\tN";
            case "ODD" -> body = "X\tY";
            case "GARBAGE" -> body = "only-one-field";
            case "BROKEN" -> { status = 500; body = "server error"; }
            case "SLOW" -> {
                awaitRelease();
                body = "S\tY";
            }
            case "SLOWBODY" -> {
                stallBody = true;
                body = "S\tY";
            }
            default -> { status = 404; body = "none"; }
        }
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        try {
            exchange.sendResponseHeaders(status, bytes.length);
            if (stallBody) {
                exchange.getResponseBody().write(bytes, 0, 1);
                exchange.getResponseBody().flush();
                awaitRelease();
                exchange.getResponseBody().write(bytes, 1, bytes.length - 1);
            } else {
                exchange.getResponseBody().write(bytes);
            }
        } catch (IOException clientGone) {
            // the client gave up (a timeout test); nothing more to send
        } finally {
            exchange.close();
        }
    }

    private void awaitRelease() {
        try {
            release.await(30, TimeUnit.SECONDS);
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
        }
    }

    @Override public void close() {
        release.countDown();
        server.stop(0);
    }
}

4.5 The directory contract

4.5.1 after/src/test/java/edu/se506/lending/integration/DirectoryClientContract.java

package edu.se506.lending.integration;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import org.junit.jupiter.api.Test;

/**
 * The behavioural contract that every {@link LegacyDirectoryClient} must honour, written once and run
 * against each implementation: the in-memory fixture the earlier chapters used and the real HTTP client.
 * A contract test says what callers may rely on. It does not prove that a real provider honours it.
 */
abstract class DirectoryClientContract {
    /** A client that answers "S" and "Y" for the key B1 and has no row for the key NONE. */
    abstract LegacyDirectoryClient client() throws Exception;

    /** A client for which every lookup fails because the provider cannot be reached or understood. */
    abstract LegacyDirectoryClient failingClient() throws Exception;

    @Test void aKnownKeyReturnsItsRowWithTheKeyItWasAskedFor() throws Exception {
        LegacyDirectoryClient.Person row = client().lookup("B1");
        assertEquals(new LegacyDirectoryClient.Person("B1", "S", "Y"), row);
    }

    @Test void anUnknownKeyReturnsNullAndIsNotAFailure() throws Exception {
        assertNull(client().lookup("NONE"));
    }

    @Test void aProviderThatCannotAnswerFailsAndNeverReturnsAnEmptyAnswer() throws Exception {
        LegacyDirectoryClient failing = failingClient();
        assertThrows(LegacyDirectoryClient.Failure.class, () -> failing.lookup("B1"));
    }
}

4.5.2 after/src/test/java/edu/se506/lending/integration/FixtureDirectoryClientContractTest.java

package edu.se506.lending.integration;

import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;

/** The contract run against the in-memory fixture that earlier chapters used. */
class FixtureDirectoryClientContractTest extends DirectoryClientContract {
    @Override LegacyDirectoryClient client() {
        return key -> "B1".equals(key) ? new LegacyDirectoryClient.Person("B1", "S", "Y") : null;
    }

    @Override LegacyDirectoryClient failingClient() {
        return key -> { throw new LegacyDirectoryClient.Failure("fixture provider is down"); };
    }
}

4.5.3 after/src/test/java/edu/se506/lending/integration/HttpDirectoryClientContractTest.java

package edu.se506.lending.integration;

import edu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;

/** The same contract run against the real HTTP client and a controlled local service. */
@Tag("integration")
class HttpDirectoryClientContractTest extends DirectoryClientContract {
    private DirectoryStub stub;

    @BeforeEach void startStub() throws IOException { stub = new DirectoryStub(); }
    @AfterEach void stopStub() { stub.close(); }

    @Override LegacyDirectoryClient client() {
        return new HttpLegacyDirectoryClient(stub.baseUri(), Duration.ofSeconds(2), Duration.ofSeconds(2));
    }

    @Override LegacyDirectoryClient failingClient() throws IOException {
        int closedPort;
        try (ServerSocket socket = new ServerSocket(0)) {
            closedPort = socket.getLocalPort();
        }
        return new HttpLegacyDirectoryClient(URI.create("http://127.0.0.1:" + closedPort),
                Duration.ofSeconds(2), Duration.ofSeconds(2));
    }
}

4.6 Integration and end-to-end tests

4.6.1 after/src/test/java/edu/se506/lending/integration/DirectoryOutageIntegrationTest.java

package edu.se506.lending.integration;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.DirectoryReservationService;
import edu.se506.lending.application.ReservationCoordinator;
import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.DirectoryException;
import edu.se506.lending.bootstrap.ReservationApplication;
import edu.se506.lending.infrastructure.approvals.FileApprovalSource;
import edu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;
import edu.se506.lending.infrastructure.directory.LegacyDirectoryClient;
import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
 * The directory policy of Chapter 13 against a controlled local service: one attempt, a bounded wait,
 * no retry and no fallback. An outage is a failure for the caller and never a "not found" answer, and a
 * borrower is never given a category the provider did not state.
 */
@Tag("integration")
class DirectoryOutageIntegrationTest {
    private static final LocalDate START = LocalDate.of(2026, 10, 20);
    private static final String HEADER =
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";

    private DirectoryStub stub;
    private RecordingNotificationSender sender;
    private ReservationCoordinator coordinator;

    @BeforeEach void start() throws IOException {
        stub = new DirectoryStub();
        sender = new RecordingNotificationSender();
        coordinator = ReservationApplication.create(sender);
    }

    @AfterEach void stop() { stub.close(); }

    private DirectoryReservationService service(URI base, Duration timeout, VisitorGate gate) {
        var client = new HttpLegacyDirectoryClient(base, timeout, timeout);
        return new DirectoryReservationService(new LegacyDirectoryAdapter(client), coordinator, gate);
    }

    private void assertNothingHappened() {
        assertTrue(coordinator.reservations().isEmpty());
        assertTrue(sender.received().isEmpty());
    }

    @Test void anOrdinaryStudentRequestWorksThroughTheRealHttpClient() {
        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
        assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", service.reserve("B1", "CAM1", START, 2));
        assertEquals(1, sender.received().size());
    }

    @Test void aMissingRowIsAnOrdinaryRejection() {
        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
        assertEquals("Rejected: borrower not found or inactive.", service.reserve("NOBODY", "CAM1", START, 2));
        assertNothingHappened();
    }

    @Test void aServerErrorIsAnOutageNotANotFoundAndIsNotRetried() {
        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
        DirectoryException failure = assertThrows(DirectoryException.class, () -> service.reserve("BROKEN", "CAM1", START, 2));
        assertInstanceOf(LegacyDirectoryClient.Failure.class, failure.getCause());
        assertEquals(1, stub.requests());
        assertNothingHappened();
    }

    @Test void aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest() {
        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.disabled());
        long begin = System.nanoTime();
        DirectoryException failure = assertThrows(DirectoryException.class, () -> service.reserve("SLOW", "CAM1", START, 2));
        long elapsedMillis = Duration.ofNanos(System.nanoTime() - begin).toMillis();
        assertInstanceOf(LegacyDirectoryClient.Failure.class, failure.getCause());
        assertTrue(elapsedMillis < 5_000, "the call must give up after its timeout, but took " + elapsedMillis + " ms");
        assertEquals(1, stub.requests());
        assertNothingHappened();
    }

    @Test void aStalledBodyIsAFailureWithinTheOverallWait() {
        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.disabled());
        // The headers and the first byte arrive at once and the rest is held. A client whose timeout covers
        // only the headers would wait for the stub to release the body and return a result, so the outcome
        // is asserted, with a generous outer deadline so that a defect shows as a failure and not a hang.
        DirectoryException failure = assertTimeoutPreemptively(Duration.ofSeconds(5),
                () -> assertThrows(DirectoryException.class, () -> service.reserve("SLOWBODY", "CAM1", START, 2)));
        assertInstanceOf(LegacyDirectoryClient.Failure.class, failure.getCause());
        assertEquals(1, stub.requests());
        assertNothingHappened();
    }

    @Test void aRefusedConnectionIsAnOutage() throws IOException {
        int closedPort;
        try (ServerSocket socket = new ServerSocket(0)) {
            closedPort = socket.getLocalPort();
        }
        var service = service(URI.create("http://127.0.0.1:" + closedPort), Duration.ofSeconds(2), VisitorGate.disabled());
        assertThrows(DirectoryException.class, () -> service.reserve("B1", "CAM1", START, 2));
        assertNothingHappened();
    }

    @Test void aMalformedAnswerIsAnOutageNotAGuess() {
        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
        assertThrows(DirectoryException.class, () -> service.reserve("GARBAGE", "CAM1", START, 2));
        assertNothingHappened();
    }

    @Test void anUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent() {
        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
        DirectoryException failure = assertThrows(DirectoryException.class, () -> service.reserve("ODD", "CAM1", START, 2));
        assertEquals("Unsupported borrower category", failure.getMessage());
        assertNothingHappened();
    }

    @Test void anInactiveVisitorIsRejectedAndTheApprovalRegisterIsNotRead(@TempDir Path dir) {
        Path missingRegister = dir.resolve("never-read.tsv");
        var gate = new VisitorGate(new FileApprovalSource(missingRegister), Clock.systemUTC(), true);
        var service = service(stub.baseUri(), Duration.ofSeconds(2), gate);
        assertEquals("Rejected: borrower not found or inactive.", service.reserve("VOFF", "CAM1", START, 2));
        assertNothingHappened();
    }

    @Test void aVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile(@TempDir Path dir) throws IOException {
        Path register = dir.resolve("approvals.tsv");
        Files.writeString(register, HEADER + "\nV1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t"
                + "2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z\n", StandardCharsets.UTF_8);
        Clock clock = Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"), ZoneOffset.UTC);
        var gate = new VisitorGate(new FileApprovalSource(register), clock, true);
        var service = service(stub.baseUri(), Duration.ofSeconds(2), gate);
        assertEquals("Confirmed: R1 for V1 on CAM1 [2026-10-20, 2026-10-22).", service.reserve("V1", "CAM1", START, 2));
        assertEquals("Rejected: no valid sponsor approval.", service.reserve("V2", "CAM1", START, 2));
        assertEquals(1, coordinator.reservations().size());
    }
}

4.6.2 after/src/test/java/edu/se506/lending/integration/FileApprovalSourceIntegrationTest.java

package edu.se506.lending.integration;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import edu.se506.lending.infrastructure.approvals.FileApprovalSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
 * Integration tests for the approval-register adapter. Every test writes a real file and reads it
 * back through the real adapter: nothing replaces the file system or the parser, because those are
 * the boundary under test.
 */
@Tag("integration")
class FileApprovalSourceIntegrationTest {
    private static final LocalDate START = LocalDate.of(2026, 10, 20);
    private static final LocalDate END = LocalDate.of(2026, 10, 22);
    private static final String HEADER =
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";
    private static final String GOOD =
            "V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";

    @TempDir Path dir;

    private Path register(String... lines) throws IOException {
        Path file = dir.resolve("approvals.tsv");
        Files.writeString(file, String.join("\n", lines) + "\n", StandardCharsets.UTF_8);
        return file;
    }

    private static ApprovalSourceException failureOf(Path file) {
        return assertThrows(ApprovalSourceException.class,
                () -> new FileApprovalSource(file).find("V1", "CAM1", START, END));
    }

    @Test void aMatchingRowIsReturnedWithEveryFieldParsed() throws IOException {
        Optional<ApprovalRecord> found = new FileApprovalSource(register(HEADER, GOOD)).find("V1", "CAM1", START, END);
        ApprovalRecord record = found.orElseThrow();
        assertEquals("S1", record.sponsorId());
        assertEquals(ApprovalStatus.APPROVED, record.status());
        assertEquals(Instant.parse("2026-10-18T00:00:00Z"), record.validFrom());
        assertEquals(Instant.parse("2026-10-20T00:00:00Z"), record.validUntil());
    }

    @Test void aRequestWithNoMatchingRowIsEmptyNotAFailure() throws IOException {
        var source = new FileApprovalSource(register(HEADER, GOOD));
        assertEquals(Optional.empty(), source.find("V2", "CAM1", START, END));
        assertEquals(Optional.empty(), source.find("V1", "CAM2", START, END));
        assertEquals(Optional.empty(), source.find("V1", "CAM1", START, END.plusDays(1)));
    }

    @Test void aDeniedRowIsReturnedAsDeniedForTheCallerToDecide() throws IOException {
        String denied = GOOD.replace("APPROVED", "DENIED");
        assertEquals(ApprovalStatus.DENIED,
                new FileApprovalSource(register(HEADER, denied)).find("V1", "CAM1", START, END).orElseThrow().status());
    }

    @Test void theFileIsReadAfreshOnEveryCall() throws IOException {
        Path file = register(HEADER);
        var source = new FileApprovalSource(file);
        assertEquals(Optional.empty(), source.find("V1", "CAM1", START, END));
        Files.writeString(file, HEADER + "\n" + GOOD + "\n", StandardCharsets.UTF_8);
        assertTrue(source.find("V1", "CAM1", START, END).isPresent());
    }

    @Test void aMissingFileIsAnOperationalFailureWithItsCause() {
        ApprovalSourceException failure = failureOf(dir.resolve("absent.tsv"));
        assertInstanceOf(NoSuchFileException.class, failure.getCause());
    }

    @Test void aWrongOrMissingHeaderIsAFailure() throws IOException {
        failureOf(register(GOOD));
        failureOf(register(HEADER.replace("sponsorId", "sponsor"), GOOD));
        Path empty = dir.resolve("empty.tsv");
        Files.writeString(empty, "", StandardCharsets.UTF_8);
        failureOf(empty);
    }

    @Test void aRowWithTheWrongNumberOfColumnsIsAFailure() throws IOException {
        failureOf(register(HEADER, GOOD + "\textra"));
        failureOf(register(HEADER, "V1\tCAM1\t2026-10-20"));
    }

    @Test void anUnknownStatusIsAFailureNotADenial() throws IOException {
        failureOf(register(HEADER, GOOD.replace("APPROVED", "approved")));
        failureOf(register(HEADER, GOOD.replace("APPROVED", "PENDING")));
    }

    @Test void anInvalidDateOrInstantIsAFailure() throws IOException {
        failureOf(register(HEADER, GOOD.replace("2026-10-20\t2026-10-22", "20-10-2026\t2026-10-22")));
        failureOf(register(HEADER, GOOD.replace("2026-10-18T00:00:00Z", "2026-10-18")));
    }

    @Test void aBlankSponsorOrAnInvalidIntervalIsAFailure() throws IOException {
        failureOf(register(HEADER, GOOD.replace("\tS1\t", "\t \t")));
        failureOf(register(HEADER, GOOD.replace("2026-10-20\t2026-10-22", "2026-10-22\t2026-10-20")));
        failureOf(register(HEADER, GOOD.replace("2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z",
                "2026-10-20T00:00:00Z\t2026-10-18T00:00:00Z")));
    }

    @Test void aDuplicateRequestKeyIsAFailureEvenIfTheRowsDisagree() throws IOException {
        failureOf(register(HEADER, GOOD, GOOD.replace("APPROVED", "DENIED")));
    }

    @Test void oneBadRowRejectsTheWholeRegisterEvenWhenTheRequestMatchesAGoodRow() throws IOException {
        failureOf(register(HEADER, GOOD, "V9\tCAM2\tnot-a-date\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z"));
    }

    @Test void bytesThatAreNotUtf8AreAFailure() throws IOException {
        Path file = dir.resolve("binary.tsv");
        byte[] header = (HEADER + "\n").getBytes(StandardCharsets.UTF_8);
        byte[] bad = {(byte) 0xC3, (byte) 0x28, '\n'};
        byte[] all = new byte[header.length + bad.length];
        System.arraycopy(header, 0, all, 0, header.length);
        System.arraycopy(bad, 0, all, header.length, bad.length);
        Files.write(file, all);
        failureOf(file);
    }
}

4.6.3 after/src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.java

package edu.se506.lending.e2e;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.integration.DirectoryStub;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
 * End-to-end journeys at the real system boundary of this command-line application: a separate Java
 * process, started as a user would start it, talking over HTTP to a controlled local directory service
 * and reading a real approval file. The classpath comes from the system property e2e.classpath
 * (default target/classes), so the same journeys can be run against a packaged jar.
 */
@Tag("e2e")
class Chapter13JourneyE2ETest {
    private static final String HEADER =
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";
    private static final String V1_CAM2 =
            "V1\tCAM2\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";

    @TempDir Path dir;

    private record Run(int exitCode, List<String> lines) { }

    private Run run(DirectoryStub stub, Path register, boolean visitors) throws IOException, InterruptedException {
        String java = Path.of(System.getProperty("java.home"), "bin",
                System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java").toString();
        String classpath = System.getProperty("e2e.classpath", "target/classes");
        ProcessBuilder builder = new ProcessBuilder(java,
                "-Dlending.directory.url=" + stub.baseUri(),
                "-Dlending.approvals.file=" + register,
                "-Dlending.visitors=" + visitors,
                "-Dlending.now=2026-10-19T12:00:00Z",
                "-Dlending.directory.timeout.millis=2000",
                "-cp", classpath, "edu.se506.lending.bootstrap.Chapter13Demo");
        builder.redirectErrorStream(true);
        Process process = builder.start();
        List<String> lines = new ArrayList<>();
        try (var reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) lines.add(line);
        }
        assertTrue(process.waitFor(60, TimeUnit.SECONDS), "the demo did not finish");
        return new Run(process.exitValue(), lines);
    }

    private Path register(String... rows) throws IOException {
        Path file = dir.resolve("approvals.tsv");
        Files.writeString(file, String.join("\n", rows) + "\n", StandardCharsets.UTF_8);
        return file;
    }

    @Test void withTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed() throws Exception {
        try (var stub = new DirectoryStub()) {
            Run run = run(stub, register(HEADER, V1_CAM2), false);
            assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
            assertEquals(List.of(
                    "Visitors enabled: false",
                    "Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",
                    "Visitor V1: Rejected: visitor reservations are not enabled.",
                    "Visitor V2: Rejected: visitor reservations are not enabled.",
                    "Unknown U9: Rejected: borrower not found or inactive.",
                    "Outage BROKEN: Unavailable: Borrower directory unavailable",
                    "Reservations: 1",
                    "Notices: 1"), run.lines());
        }
    }

    @Test void withTheFlagOnAnApprovedVisitorIsServedAndAnUnapprovedOneIsRefused() throws Exception {
        try (var stub = new DirectoryStub()) {
            Run run = run(stub, register(HEADER, V1_CAM2), true);
            assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
            assertEquals(List.of(
                    "Visitors enabled: true",
                    "Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",
                    "Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22).",
                    "Visitor V2: Rejected: no valid sponsor approval.",
                    "Unknown U9: Rejected: borrower not found or inactive.",
                    "Outage BROKEN: Unavailable: Borrower directory unavailable",
                    "Reservations: 2",
                    "Notices: 2"), run.lines());
        }
    }

    @Test void aMalformedRegisterFailsOnlyTheVisitorRequestsAndIsReportedAsAnOutage() throws Exception {
        try (var stub = new DirectoryStub()) {
            Run run = run(stub, register(HEADER, V1_CAM2, "garbage row"), true);
            assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
            assertEquals("Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", run.lines().get(1));
            assertEquals("Visitor V1: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(2));
            assertEquals("Visitor V2: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(3));
            assertEquals("Reservations: 1", run.lines().get(6));
        }
    }
}

4.7 The three changed regression tests

Each change adds the visitor policy to a map that lists every category, and one import where the class had none for it. Apply the hunks; every other line is as in the earlier companions. Lines starting with a plus sign are added and lines starting with a minus sign are removed.

4.7.1 after/src/test/java/edu/se506/lending/regression/BehaviourStrategyTest.java

--- chapter11/src/test/java/edu/se506/lending/regression/BehaviourStrategyTest.java
+++ chapter13/src/test/java/edu/se506/lending/regression/BehaviourStrategyTest.java
@@ -6,6 +6,7 @@
 import edu.se506.lending.domain.BorrowingPolicy;
 import edu.se506.lending.domain.ReservationRules;
 import edu.se506.lending.domain.StaffReservationRules;
+import edu.se506.lending.domain.VisitorReservationRules;
 import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
 import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;
 
@@ -38,7 +39,8 @@
     @Test void injectedPolicyChangesDecisionWithoutChangingCoordinator() {
         BorrowingPolicy temporaryStaffRule = (count, days) -> count >= 0 && count < 1 && days >= 1 && days <= 2;
         var coordinator = new ReservationCoordinator(
-                Map.of(BorrowerCategory.STUDENT, new ReservationRules(), BorrowerCategory.STAFF, temporaryStaffRule),
+                Map.of(BorrowerCategory.STUDENT, new ReservationRules(), BorrowerCategory.STAFF, temporaryStaffRule,
+                        BorrowerCategory.VISITOR, new VisitorReservationRules()),
                 new ConfirmationFormatter(), new RecordingNotificationSender(), new InMemoryReservationRepository());
         var start = LocalDate.of(2026, 10, 20);
         assertEquals("Rejected: borrower limit or duration.", coordinator.reserve("T1", "CAM1", start, 3, BorrowerCategory.STAFF));

4.7.2 after/src/test/java/edu/se506/lending/regression/Chapter06Checks.java

--- chapter11/src/test/java/edu/se506/lending/regression/Chapter06Checks.java
+++ chapter13/src/test/java/edu/se506/lending/regression/Chapter06Checks.java
@@ -10,6 +10,7 @@
 import edu.se506.lending.domain.Reservation;
 import edu.se506.lending.domain.ReservationRules;
 import edu.se506.lending.domain.StaffReservationRules;
+import edu.se506.lending.domain.VisitorReservationRules;
 import edu.se506.lending.infrastructure.notification.ConsoleNotificationSender;
 import edu.se506.lending.infrastructure.notification.RecordingNotificationSender;
 import edu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;
@@ -111,7 +112,8 @@
 
     private static Map<BorrowerCategory, BorrowingPolicy> policies() {
         return Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
-                      BorrowerCategory.STAFF, new StaffReservationRules());
+                      BorrowerCategory.STAFF, new StaffReservationRules(),
+                      BorrowerCategory.VISITOR, new VisitorReservationRules());
     }
 
     private static final class FailingSender implements NotificationSender {

4.7.3 after/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java

--- chapter11/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java
+++ chapter13/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java
@@ -76,12 +76,14 @@
     }
     @Test void coordinatorRequiresARepository() {
         var policies = Map.<BorrowerCategory, BorrowingPolicy>of(BorrowerCategory.STUDENT, new ReservationRules(),
-                BorrowerCategory.STAFF, new StaffReservationRules());
+                BorrowerCategory.STAFF, new StaffReservationRules(),
+                BorrowerCategory.VISITOR, new VisitorReservationRules());
         assertThrows(NullPointerException.class,
                 () -> new ReservationCoordinator(policies, new ConfirmationFormatter(), reservation -> { }, null));
     }
     private ReservationCoordinator coordinator(ReservationRepository repository, NotificationSender sender) {
         return new ReservationCoordinator(Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
-                BorrowerCategory.STAFF, new StaffReservationRules()), new ConfirmationFormatter(), sender, repository);
+                BorrowerCategory.STAFF, new StaffReservationRules(),
+                BorrowerCategory.VISITOR, new VisitorReservationRules()), new ConfirmationFormatter(), sender, repository);
     }
 }

Other test files that build a coordinator needed no edit, and the 159 inherited invocations passed with these four edits and nothing else.

5. Build, scripts and documentation

5.1 after/pom.xml

Change the project version from 0.11.0 to 0.13.1 and nothing else. The POM already sets failIfNoTests to true for Surefire, which is why a stage that selects no tests fails (Section 1.3). The hunk is an edit to the Chapter 11 POM:

--- chapter11/pom.xml
+++ chapter13/pom.xml
@@ -4,7 +4,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>edu.se506</groupId>
   <artifactId>equipment-lending</artifactId>
-  <version>0.11.0</version>
+  <version>0.13.1</version>
   <properties>
     <maven.compiler.release>17</maven.compiler.release>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

5.2 after/scripts/pipeline.sh

#!/usr/bin/env bash
# A modest delivery pipeline run on one machine. Each stage must pass before the next one starts,
# and the first failing stage stops the run with a non-zero status. CHAPTER_MVN names the Maven
# command (default: mvn); CHAPTER_M2 optionally names a local Maven repository.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi

stage() {
  local name="$1"
  shift
  echo "=== STAGE: ${name}"
  local began=$SECONDS
  "$@"
  echo "=== PASSED: ${name} ($((SECONDS - began)) s)"
}

stage "1 compile" "$MVN" "${ARGS[@]}" -q compile
stage "2 unit tests and architecture checks" "$MVN" "${ARGS[@]}" test -DexcludedGroups=integration,e2e
stage "3 integration tests (real file, real HTTP client, local stub service)" "$MVN" "${ARGS[@]}" test -Dgroups=integration
stage "4 end-to-end journeys (separate process)" "$MVN" "${ARGS[@]}" test -Dgroups=e2e
stage "5 package" "$MVN" "${ARGS[@]}" package -DskipTests
echo "PIPELINE PASSED"

5.3 after/scripts/simulate-release.sh

#!/usr/bin/env bash
# This script does not enforce the pipeline's gates: it packages with -DskipTests and then runs only the
# end-to-end smoke journeys, so it assumes that scripts/pipeline.sh has already passed for the same candidate.
# Rollback assumes the same version-derived jar file name in both releases, so the exercise's two releases are
# two labels for one build and not a tested cross-version rollback.
#
# SIMULATION of continuous delivery to a staging target. Nothing here is a server, a network
# deployment or a production release: "staging" is a folder, "deploying" is copying a jar into it,
# and the smoke test is the end-to-end journeys run against the jar that was copied.
#
#   bash scripts/simulate-release.sh              build, deliver as the current release, smoke-test it
#   RELEASE_LABEL=v2 bash scripts/simulate-release.sh   deliver again; the old release becomes "previous"
#   bash scripts/simulate-release.sh rollback     make "previous" the current release again, smoke-test it
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi
STAGING="build/staging"
VERSION="$(sed -n 's:^  <version>\(.*\)</version>$:\1:p' pom.xml | head -1)"
JAR="equipment-lending-${VERSION}.jar"

smoke() {
  echo "--- smoke test of $(cat "${STAGING}/current/RELEASE") (end-to-end journeys against ${STAGING}/current/${JAR})"
  "$MVN" "${ARGS[@]}" test -Dgroups=e2e "-De2e.classpath=${STAGING}/current/${JAR}"
}

case "${1:-deliver}" in
  deliver)
    "$MVN" "${ARGS[@]}" -q package -DskipTests
    mkdir -p "${STAGING}"
    if [ -d "${STAGING}/current" ]; then
      rm -rf "${STAGING}/previous"
      mv "${STAGING}/current" "${STAGING}/previous"
    fi
    mkdir -p "${STAGING}/current"
    cp "target/${JAR}" "${STAGING}/current/${JAR}"
    echo "${RELEASE_LABEL:-${VERSION}}" > "${STAGING}/current/RELEASE"
    echo "--- delivered $(cat "${STAGING}/current/RELEASE") to ${STAGING}/current (simulation)"
    smoke
    ;;
  rollback)
    if [ ! -d "${STAGING}/previous" ]; then
      echo "No previous release to roll back to." >&2
      exit 1
    fi
    rm -rf "${STAGING}/current"
    mv "${STAGING}/previous" "${STAGING}/current"
    echo "--- rolled back to $(cat "${STAGING}/current/RELEASE") (simulation)"
    smoke
    ;;
  *)
    echo "usage: simulate-release.sh [deliver|rollback]" >&2
    exit 2
    ;;
esac

5.4 after/.github/workflows/pipeline.yml

This workflow expresses the stages as jobs, each waiting for the one before it (needs). It has not been run: no hosted CI run is claimed. The action versions are those in the file; check the current ones before relying on it.

name: Delivery pipeline

on:
  push:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  unit:
    name: 1 Compile, unit tests and architecture checks
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
          cache: maven
      - name: Compile
        run: mvn --batch-mode -q compile
      - name: Unit tests
        run: mvn --batch-mode test -DexcludedGroups=integration,e2e

  integration:
    name: 2 Integration tests
    needs: unit
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
          cache: maven
      - name: Integration tests
        run: mvn --batch-mode test -Dgroups=integration

  e2e:
    name: 3 End-to-end journeys
    needs: integration
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
          cache: maven
      - name: Compile
        run: mvn --batch-mode -q compile
      - name: End-to-end journeys
        run: mvn --batch-mode test -Dgroups=e2e

  package:
    name: 4 Package the release candidate
    needs: e2e
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
          cache: maven
      - name: Package
        run: mvn --batch-mode package -DskipTests
      - name: Keep the jar
        uses: actions/upload-artifact@v7
        with:
          name: release-candidate
          path: target/equipment-lending-*.jar
          if-no-files-found: error

5.5 after/README.md

# Chapter 13 checkpoint

Use a full Java 17 JDK and Maven 3.9.11. The JDK must include jdk.jdeps for the compiled dependency tests. Existing dependency and plugin versions are unchanged; only the project version changes (to 0.13.1).

From this directory, run the whole delivery pipeline (compile; unit tests and architecture checks; integration tests; end-to-end journeys; package):

```bash
bash scripts/pipeline.sh
```

or the single command `mvn --batch-mode verify`, which runs every test in one pass. The build executes 232 JUnit invocations: 159 inherited from Chapter 11 and 73 new. Of the 232, 203 run in the unit stage, 26 are tagged `integration` and 3 are tagged `e2e`.

What Chapter 13 adds: a visiting-researcher category (VISITOR), a VisitorReservationRules policy, a VisitorGate that holds one feature flag (default off), an item allowlist and the approval check, an ApprovalSource port with a real file adapter (FileApprovalSource) that reads a tab-separated sponsor register, and an HTTP client for the borrower directory (HttpLegacyDirectoryClient) with a bounded wait over the whole lookup (the body included), one attempt, no retry and no fallback answer. A missing approval is an expected rejection; an unreadable register or an unreachable directory is a failure and never looks like a rejection or a missing borrower.

The directory service in the tests is a local stub built on the JDK's embedded HTTP server. The end-to-end journeys start Chapter13Demo as a separate process against that stub and a real approval file; the demo reads its settings from system properties (see the class comment). There is no real directory service or approval service, so what the stub does is an assumption.

`scripts/simulate-release.sh` is a SIMULATION of delivery: staging is a folder, deploying is copying a jar, and the smoke test is the end-to-end journeys against the copied jar. `.github/workflows/pipeline.yml` expresses the same stages for a hosted runner; it has not been run. `docs/ADR-013-visiting-researchers.md` records the decision, the flag removal plan and the rollback. The directory `ai-activity` holds the two tests of the AI activity; they are not part of the Maven build.

SD-11-1 (the report's rate for any other kind of borrower, which a late visitor now reaches) and SD-12 (the count rule counts every retained reservation) are not changed here.

The package `legacy` stands for the old records system's overdue report. It is isolated, like `patterns`, `behaviour` and `examples`: production code under `edu.se506.lending` must not refer to it, and DependencyRulesTest checks that. Only bootstrap launches it or feeds it rows. LoanRow is the old system's export format and is deliberately unchanged.

The optional bridge (OverdueRowSource, OverdueReportService and LoanSnapshotRows in bootstrap) feeds the report from the live loan and reservation snapshots. It reads one date and one snapshot per report, and a failure to read is never reported as zero overdue loans.

The directory `stages` holds the report at each step of the refactoring. It is not part of the Maven build. To run a stage, copy its OverdueReport.java over src/main/java/legacy/OverdueReport.java and run:

```bash
mvn --batch-mode -Dtest='OverdueReport*Test' test
```

Stages 1 to 4 each pass the same 28 invocations. Stage 0 is the code as found and cannot be tested without a change, because it reads the system clock. stageC is a separate functional change (unknown kinds are rejected) with its own replacement tests; to run it, copy all three of its files to their src locations.

The directory `probe` holds a program that runs the original, unseamed report beside the seamed one and compares their output. It is not part of the Maven build. From this directory, with stage 1 or the final report in place:

```bash
mkdir -p probe/out
javac --release 17 -d probe/out src/main/java/legacy/*.java probe/OriginalUnseamed.java probe/OriginalReportProbe.java
java -cp probe/out legacy.OriginalReportProbe
```

The characterisation tests record what the report does today. A passing characterisation test means unchanged, not correct. Four behaviours are recorded as suspected or surprising in docs/SUSPECTED-DEFECTS-legacy-overdue-report.md and are not fixed here. ADR-011 records the decision to pin, isolate and refactor the report.

To print the package metrics, run `mvn --batch-mode compile` and then `java tools/PackageMetrics.java target/classes`.

ADR-010 from Chapter 10 is still in docs. No hosted CI run and no peer review is claimed.

5.6 after/docs/ADR-013-visiting-researchers.md

# ADR-013: Visiting-researcher reservations behind a flag, with two real boundaries

Date: 13 October 2026
Status: Implemented teaching example; peer review pending.

## Context

Change request CR-12 (Chapter 12) adds a visiting-researcher category. A visitor may reserve CAM1 or CAM2 for one to three days when a sponsor approval matches the exact borrower, item and period and is valid at request time. Student and staff behaviour must not change.

Two outside collaborators now have real boundaries. The borrower directory, which the synthetic fixture of Chapter 8 stood in for, can be slow, down or wrong. The sponsor-approval register is a file that can be missing or malformed. Until now neither could fail in a way that tests could see.

## Decision

- Add `VISITOR`, a `VisitorReservationRules` policy (count and duration only) and a `VisitorGate` that holds the feature flag, the item allowlist and the approval check. The existing `BorrowingPolicy` contract is not widened.
- Define an `ApprovalSource` port. `FileApprovalSource` reads a UTF-8 tab-separated register of eight columns and rejects the whole file if any row is invalid or any request key repeats. A missing record is an expected rejection. An unreadable or untrustworthy register is an `ApprovalSourceException`, which no code on the reservation path catches.
- Reach the directory over HTTP with `HttpLegacyDirectoryClient`, behind the existing `LegacyDirectoryClient` contract. **Policy: one attempt, a bounded wait, no retry and no fallback answer.** The wait bounds the whole lookup, including the time to receive the body. A timeout, a refused connection, any other status and a malformed row are failures, which the adapter turns into `DirectoryException`. An outage is never reported as “not found”, and no borrower is ever given a category that the provider did not state.
- Introduce one flag, `visitingResearchersEnabled`, default **false**. When it is off, visitor requests are rejected explicitly and the approval register is not read. Student and staff requests never depend on it.
- Test the file adapter with real temporary files and the HTTP client against a local stub service, and run the contract for directory clients against both the in-memory fixture and the HTTP client. Run focused end-to-end journeys against a separate process.

## Alternatives considered

- Pass sponsor approval to `BorrowingPolicy` through a flag or a static: rejected, because the policy would then depend on hidden global state and could not be tested for identity or time.
- Fall back to a cached or default directory answer after a timeout: rejected, because a stale answer would grant a category the provider did not currently state.
- Retry after a timeout: not adopted in this change. A retry would repeat a slow call for the person waiting, and it would change the one-attempt notification and directory contracts of earlier chapters.
- A database or a real HTTP service for approvals: not needed for CR-12 and outside the course scope.

## Consequences

The directory and the register are now two places that can fail at the edge of the system, and each failure has its own test. Adding `VISITOR` forced a policy for it in every coordinator construction, so four fixture constructions in three test files that enumerate every category were updated. No assertion was weakened.

The count rule still counts every retained reservation, which is SD-12, unresolved. The legacy overdue report still treats any kind other than `S` or `T` at the rate of SD-11-1, so a visitor who is late is charged one unit a day with no grace and no cap. Neither is changed here.

## Flag removal plan

The flag exists to let the controlled demonstration prove both paths before visitors are released. Remove it when all of these hold: the released variant of CR-12 is accepted, both settings have passed in the pipeline for the release candidate, and a decision to keep visitors enabled is recorded. Then delete `VisitorGate.DISABLED`, the `enabled` parameter and the disabled-path tests together, in one commit. Review the flag at the end of Chapter 14's handover; a flag with no removal decision is a permanent branch.

## Rollback

To stop new visitor reservations, turn the flag off. That does not delete existing reservations or undo notices already sent, and an existing reservation can still be checked out and returned. To go back to the previous release, redeliver it (the simulated delivery keeps one previous release) and smoke-test it.

## Validation and review

Run `bash scripts/pipeline.sh` for all stages, then `bash scripts/simulate-release.sh` for the simulated delivery. No hosted CI run, public deployment or independent peer review is claimed.

## Revisit triggers

A decision on SD-11-1 or SD-12; a requirement to revoke an approval after a reservation; a real approval service or directory with authentication; concurrent writers to the register; a need for retry or caching after measured outages.

6. The AI activity files

Both files live in after/ai-activity/ and are outside the build. To run them, copy them into src/test/java/edu/se506/lending/aifixture/ and run mvn test -Dtest='ProposedRegisterIntegrationTest,RepairedRegisterIntegrationTest' -Dgroups=integration.

6.1 after/ai-activity/ProposedRegisterIntegrationTest.java

package edu.se506.lending.aifixture;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.ApprovalSource;
import edu.se506.lending.domain.ApprovalRecord;
import edu.se506.lending.domain.ApprovalStatus;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

/** AI-proposed "integration test" for the approval register. */
@Tag("integration")
class ProposedRegisterIntegrationTest {
    @Test void anApprovedVisitorIsAcceptedFromTheRegister() {
        LocalDate start = LocalDate.of(2026, 10, 20);
        LocalDate end = start.plusDays(2);
        Instant now = Instant.parse("2026-10-19T12:00:00Z");
        ApprovalSource register = mock(ApprovalSource.class);
        ApprovalRecord record = new ApprovalRecord("V1", "CAM1", start, end, "S1", ApprovalStatus.APPROVED,
                Instant.parse("2026-10-18T00:00:00Z"), Instant.parse("2026-10-20T00:00:00Z"));
        when(register.find("V1", "CAM1", start, end)).thenReturn(Optional.of(record));

        VisitorGate gate = new VisitorGate(register, Clock.fixed(now, ZoneOffset.UTC), true);

        assertEquals(Optional.empty(), gate.rejection("V1", "CAM1", start, 2));
        verify(register).find("V1", "CAM1", start, end);
    }
}

6.2 after/ai-activity/RepairedRegisterIntegrationTest.java

package edu.se506.lending.aifixture;

import static org.junit.jupiter.api.Assertions.*;

import edu.se506.lending.application.VisitorGate;
import edu.se506.lending.application.port.ApprovalSourceException;
import edu.se506.lending.infrastructure.approvals.FileApprovalSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Optional;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/** The repaired test: a real register file, read by the real adapter, through the real gate. */
@Tag("integration")
class RepairedRegisterIntegrationTest {
    private static final String HEADER =
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";
    private static final String ROW =
            "V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";
    private static final LocalDate START = LocalDate.of(2026, 10, 20);

    @Test void anApprovedVisitorIsAcceptedFromARealRegisterFile(@TempDir Path dir) throws IOException {
        Path file = dir.resolve("approvals.tsv");
        Files.writeString(file, HEADER + "\n" + ROW + "\n", StandardCharsets.UTF_8);
        Clock clock = Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"), ZoneOffset.UTC);
        VisitorGate gate = new VisitorGate(new FileApprovalSource(file), clock, true);

        assertEquals(Optional.empty(), gate.rejection("V1", "CAM1", START, 2));
        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate.rejection("V2", "CAM1", START, 2));

        Files.writeString(file, HEADER + "\n" + ROW + "\nbroken row\n", StandardCharsets.UTF_8);
        assertThrows(ApprovalSourceException.class, () -> gate.rejection("V1", "CAM1", START, 2));
    }
}

7. Worked explanation and evidence

7.1 Why the gate sits in front of the coordinator

BorrowingPolicy.mayRequest(count, days) answers a count and duration question and nothing else. Approval needs the borrower, the item, the period and the time. Putting any of those into the policy through a flag or a static would give it hidden state and make it untestable for identity and time. So the policy stays small (VisitorReservationRules), and the request-specific decision lives in VisitorGate, which sits in the application layer and is called by DirectoryReservationService before the coordinator. Student and staff requests never reach the gate. The order inside the gate is cheap to expensive: flag, then item, then duration, then one clock read, then one register read, then an exact-request re-check and the validity window.

The exact-request re-check looks redundant, since the source is asked for the exact request. It is there because the source is a boundary: a source that returns the wrong record (a bug, or a register that was changed underneath) must not approve. aSourceThatReturnsADifferentRequestDoesNotApprove and the fault experiment in Section 12.2 test it.

7.2 Exceptions and expected results

An expected result is a return value; an operational failure is an exception that nothing on the request path catches. FileApprovalSource.find returns an empty Optional when no record matches and throws ApprovalSourceException when the register cannot be trusted. The gate does not catch it, and neither does the service. The demonstration catches it at the edge and prints Approval register unavailable: .... That is the whole difference between “no sponsor approved this” and “we could not find out”, and AC10 checks that a malformed register creates no reservation and sends no notice. The same applies to the directory: DirectoryException is the failure and a missing row is a rejection.

7.3 The acceptance examples, with their evidence

ExampleTest in VisitorAcceptanceTestAlso checked at a narrower level
AC1ac1_aVisitorWithAMatchingApprovalReservesCam1ForTwoDaysaMatchingApprovedRecordInsideItsWindowLetsTheRequestContinue
AC2ac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowedcountAndDurationBoundaries
AC3ac3_fourDaysIsRejectedEvenWithAMatchingRecordcountAndDurationBoundaries
AC4ac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegisteraNonPositiveDurationIsLeftToThePolicyWithoutReadingTheSource
AC5ac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecordanItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource
AC6ac6_aMissingOrDeniedApprovalIsAnExpectedRejectionaMissingOrDeniedRecordIsAnExpectedRejection; aDeniedRowIsReturnedAsDeniedForTheCallerToDecide
AC7ac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthoriseaRecordMatchesOnlyTheExactBorrowerItemAndPeriod
AC8ac8_theRequestInstantEqualToValidFromIsValidtheWindowIncludesItsStartAndExcludesItsEnd
AC9ac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejectedequalityAtTheStartOfTheWindowIsValidAndAtTheEndIsNot
AC10ac10_anUnreadableApprovalRegisterIsAnOperationalFailureNotARejectionThe file tests of Section 7.4; anOperationalFailureOfTheSourceIsNotCaught; the third journey
AC11ac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturnedcountAndDurationBoundaries
AC12ac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegisteraDisabledGateRejectsWithoutReadingTheSource; the first journey
AC13ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegisterThe inherited 159 invocations
AC14ac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule
AC15ac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace
AC16ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff
AC17ac17_anInactiveOrUnknownBorrowerIsRejectedAsBeforeanUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent; anInactiveVisitorIsRejectedAndTheApprovalRegisterIsNotRead
AC18ac18_aVisitorLoanInTheOverdueReportKeepsTheExistingKindRuleSd11_1The Chapter 11 characterisation tests

7.4 What the file tests cover

FileApprovalSourceIntegrationTest writes real files into a @TempDir and reads them through the real adapter. Thirteen tests cover: a matching row with every field parsed; no matching row (empty, not a failure); a denied row (returned for the caller to decide); a file changed between two calls (read afresh every time); a missing file (a failure, with the NoSuchFileException as its cause); a wrong, missing or empty header; a row with the wrong number of columns; an unknown status (a failure, not a denial); a bad date or instant; a blank sponsor or an interval whose end is not after its start; a duplicate request key even when the rows disagree; one bad row rejecting the whole register even when the request matches a good row; and bytes that are not UTF-8.

The whole-file rule is a design choice, and it has a cost: one bad row stops every visitor request until the register is repaired. The alternative, skipping bad rows, would let a typo silently remove a sponsor’s approval, or worse, hide a duplicate that makes two rows disagree. The fault experiment includes it (Section 12.2), and the ADR names the revisit triggers.

7.5 What the stub is, and what it is not

DirectoryStub is a JDK HttpServer on the loopback address with an ephemeral port. It answers keys B1, T1, V1, V2, VOFF, ODD and GARBAGE with prepared bodies, BROKEN with status 500, SLOW by not answering until the test releases it, and SLOWBODY by sending the headers and the first byte of the body and then holding the rest until the test releases it. Closing the stub releases any waiting request, so a test never leaves a thread hanging. Its answers are the author’s assumption of what a provider could say. Nothing in this book checks them against a real directory, which is why the chapter names a provider contract test as a limit and not as something done. The interface contract (DirectoryClientContract) is run against the in-memory example and the HTTP client, so a caller can substitute one for the other; that is a statement about our two implementations, not about the provider.

7.6 The failing pipeline, read

A deliberate fault was introduced into a scratch copy: the header comparison in FileApprovalSource.readAll was changed from lines.isEmpty() || !HEADER.equals(lines.get(0)) to lines.isEmpty(). The pipeline ended with exit status 1 after stage 3, which is where a person should look first:

=== STAGE: 1 compile
=== PASSED: 1 compile (5 s)
=== STAGE: 2 unit tests and architecture checks
[INFO] Tests run: 203, Failures: 0, Errors: 0, Skipped: 0
=== PASSED: 2 unit tests and architecture checks (11 s)
=== STAGE: 3 integration tests (real file, real HTTP client, local stub service)
[ERROR] edu.se506.lending.integration.FileApprovalSourceIntegrationTest.aWrongOrMissingHeaderIsAFailure -- Time elapsed: 0.013 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected edu.se506.lending.application.port.ApprovalSourceException to be thrown, but nothing was thrown.
[ERROR] Failures: 
[ERROR]   FileApprovalSourceIntegrationTest.aWrongOrMissingHeaderIsAFailure:84->failureOf:44 Expected edu.se506.lending.application.port.ApprovalSourceException to be thrown, but nothing was thrown.
[ERROR] Tests run: 26, Failures: 1, Errors: 0, Skipped: 0

Stages 4 and 5 never started. Reading it: the first failing stage is 3; the first failing test, by name, promises that a wrong or missing header is a failure; the assertion says that it was not. Stage 2 could not have caught it, because no unit test reads a real file: the stage-2 tests use a controlled approval source, and that is correct for their purpose. The other 25 integration tests passed, so the fault is local to the header, and the test’s first assertion that fails is at line 84 (the first of its three cases: a file with no header at all, the register’s first row used as a header and skipped). This is the reason the integration stage exists.

7.7 The simulated delivery and its limits

simulate-release.sh kept one previous release and smoke-tested each step against the copied jar. The script packages with the tests skipped and runs only the end-to-end journeys, so it assumes that the pipeline has already passed for the same candidate; it does not enforce the other gates. Rollback assumes the same version-derived jar file name in both releases. Both releases were built from the same source, so the rollback demonstrated the mechanics (which folder is current, and that the smoke test runs against it) and not a change of behaviour. A real rollback has more to settle: data written by the new release, notices already sent and reservations already made (AC16 pins that a visitor reservation made before the flag went off stays usable).

7.8 A timeout that does not cover the body

A client that calls the blocking send with a request timeout relies on a timeout that the JDK documents as the time within which the response must be received. The documentation says nothing about a body [8]. A test that uses a stub which waits before sending anything exercises the one case the timeout certainly covers.

A different case: a server that sends the headers and the first byte at once and holds the rest. With the client’s timeout at 300 ms and the server holding the body for 1,600 ms, a client that relies on the request timeout alone returned a valid result after about 1.7 seconds. The policy said “a bounded wait” and that client did not keep it, because the timeout bounded the wait for the headers and not for the whole answer.

The client in this chapter therefore sends the request asynchronously and waits for the future for at most the configured time. On timeout it cancels the future, which asks the client to drop the exchange and is not guaranteed to be prompt or to take effect [13][14], and fails the lookup with a message that names the wait. The caller does not depend on the cancellation, because it has already stopped waiting. An interrupted wait cancels, restores the interrupt flag and fails. A failed future becomes a Failure whose message names the cause’s class. The header timeout stays on the request.

RunResult
New test aStalledBodyIsAFailureWithinTheOverallWait against a client that relies on the request timeout aloneFails: execution timed out after 5000 ms (the outer deadline), because the client waits for the stub to release the body and returns it
The same test against the client in this chapterPasses. A probe with the same 300 ms timeout and 1,600 ms stall fails the lookup after about 330 ms: Directory did not answer within 300 ms.
Fault experiment, Section 12.2Removing the overall deadline is caught by the new test alone. Removing the header timeout alone is not caught: the overall deadline still bounds the call, so it is an equivalent change. Removing both is caught by both slow-provider tests

Not changed: one logical attempt, no retry, no fallback category, the distinction between an unavailable directory and an absent borrower, and no reservation or notice after a failure.

7.9 The order of the visitor decision

The order of the checks is observable when more than one thing is wrong. The gate checks the flag, the item, and (for fewer than one day) steps aside; otherwise it reads the clock and the register; then the coordinator applies the count and duration rules and the availability rule. A four-day visitor request with an unreadable register therefore fails with the register’s error and is not rejected for its duration. The test precedence_theRegisterIsReadBeforeTheCountAndDurationRulesApply pins both ends: four days with an unreadable register throws the register’s exception, and zero days does not read the register and returns the limit rejection. It is not one of the eighteen acceptance examples and is not named ac<N>_.

If a different precedence is wanted, for example duration before register, it is a change to the contract, the implementation and the tests, decided by the requirement’s owner. The picture follows the code.

7.10 What this evidence does not show

The pipeline and the tests show the selected behaviours on the supplied inputs in the stated environment. They do not show behaviour against a real directory or a real approval workflow, concurrency (two writers of the register, or two simultaneous requests), authentication, behaviour under load, a hosted run, Java 17 behaviour, or that the suspected defects SD-11-1 and SD-12 are acceptable. AC18 shows that SD-11-1 now has a consequence for a late visitor, and the change leaves it as it was.

8. Suggested practical solution

8.1 the book’s project, steps B1–B4

B1. Run the pipeline. A good answer reproduces the counts of Section 1.2 (203, 26, 3), notes the time of each stage, and says what a failure at each stage means: 1, the code does not build; 2, a rule or an architectural boundary of our own code is wrong; 3, an adapter does not meet its boundary; 4, the pieces are not joined or a setting is not read; 5, the artefact cannot be built.

B2. Trace three examples. For example:

ExampleTestDeciding production codeObservable resultStage
AC9 (the expiry boundary)ac9_...ApprovalRecord.validAt, called from VisitorGate.rejectionRejected: no valid sponsor approval. at validUntil; no reservation2
AC10 (operational failure)ac10_... and the third journeyFileApprovalSource.readAll throws; nothing catches it on the request pathAn ApprovalSourceException; no reservation or notice. The demonstration prints Approval register unavailable: ...2 and 4
AC12 (the flag)ac12_... and the first journeyVisitorGate.rejection, the first checkRejected: visitor reservations are not enabled.; the register is not read2 and 4

B3. Make a test bite. A good answer predicts before running. Example: change FileApprovalSource so that an unknown status becomes DENIED. Prediction: stage 2 passes (no unit test reads the real file), and stage 3 fails at anUnknownStatusIsAFailureNotADenial. Section 12.2 lists thirty faults and the tests that caught them; use it to check a prediction after making it, not before.

B4. The evidence table. A good answer has one row per risk of Section 13.1, with the test’s name and what it cannot show, and at least one risk that nothing here covers, for example: the real directory’s behaviour; two concurrent writers of the register; a sponsor who is revoked after a reservation; and the effect of an unusual time zone on the request instant.

8.1a the book’s project, optional steps B5–B7

B5. Deliver and roll back. Run the commands of Section 1.4. A good answer records current and previous after each step (after the second delivery, v2 is current and v1 previous; after the rollback, v1 is current and nothing is previous), and says that the rollback in this simulation changes a folder and not any data.

B6. AI activity. See Section 9.

B7. Break a boundary on purpose. In a scratch copy, change the timeout of aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest from 300 milliseconds to twenty seconds and run mvn test -Dtest=DirectoryOutageIntegrationTest -Dgroups=integration. Observed: the test fails after 20.03 seconds with the call must give up after its timeout, but took 20015 ms, and the class takes 20.58 seconds instead of 1.01. The client did exactly what it was told, and the test’s own bound (five seconds) caught that the wait is too long to be useful. A good answer explains two things: a test of a timeout should use a short, injected timeout, and a pipeline that waits for the real timeout of every failure case is itself a problem, because slow feedback gets skipped.

8.2 your team’s project, steps T1–T5

These are not model answers, because the code is your team’s. A good answer shows:

  • T1: the change implemented from the smallest vertical slice outwards; one flag that defaults to off; acceptance examples that each have a test; expected rejections and operational failures that are different results.
  • T2: a boundary test that does not replace the boundary: a real temporary file, a local stub service or a throw-away database; at least one failure case (missing, malformed or slow); no mock standing in for the reader under test.
  • T3: the existing suite run first; every changed example listed with the reason; tests for the flag on and off.
  • T4: at least three stages; a captured passing run; a captured failing run read in the form of Section 7.6 (first failing stage, first failing test, why the earlier stages could not have caught it); a stage that fails when it selects nothing.
  • T5: a removal plan for the flag with a named condition; a rollback step that says what it does not undo; a demonstration; a retrospective with one next action; a limits statement; and the eight items of Section 13.8, each marked complete or a gap.

A weak answer replaces the boundary with a mock and calls it an integration test, tests only the happy path of the boundary, defaults to a guess when a service fails, or claims a hosted run that did not happen.

9. AI activity solution

The example describes a test that “builds a visitor approval, makes the register return it, and checks that the gate accepts the visitor and that the register was consulted”. The code does that, and nothing more.

QuestionAnswer
Which part of the system does it touch?VisitorGate and ApprovalRecord. Nothing else.
Is there a file in it?No. mock(ApprovalSource.class) replaces the register; FileApprovalSource, the parser and the file system are not in the test.
What does it say after the parser is deleted?The same: it passes.
What does @Tag("integration") promise that the body does not deliver?A test of a real boundary. The tag puts it in stage 3, where it adds time and a false sense of cover.
What does verify(register).find(...) establish?That the gate asked the mock for the exact request. That is a fact about the gate, not about the register.

Both tests were run against two faults, each applied to its own scratch copy of the final code.

RunProposed testRepaired test
No faultPassesPasses
Fault A: the validFrom and validUntil columns are read the wrong way roundPassesFails: anApprovedVisitorIsAcceptedFromARealRegisterFile throws ApprovalSourceException: Row 2 is not a valid approval record.
Fault B: a broken row is skipped instead of rejecting the whole filePassesFails: Expected ApprovalSourceException to be thrown, but nothing was thrown at the broken-row assertion

The example’s description is accurate about what the test does, and the name and tag are misleading about what it verifies. The repaired test writes a real register into a temporary directory, reads it through the real FileApprovalSource, passes it to the real gate, and adds the failure case: a broken row appended to the file makes the gate throw the register’s exception and not reject politely.

What the original is good for: it is a fair unit test of the gate’s decision logic with a controlled source, and it belongs in the unit stage under an honest name (VisitorGateTest has several like it). Accept it as a unit test; reject it as an integration test.

The repaired test is deliberately small. It does not replace FileApprovalSourceIntegrationTest, which carries the detail, and it covers only the faults that reach the gate. A header fault, for example, would pass it. Run against the two faults above, it says what it covers and no more.

The secondary activity (reading a pipeline failure) is Section 7.6. If a live assistant is used, keep its actual output, apply any test it writes to a scratch copy, and run it against a fault. Do not use a generated test to approve generated code.

10. Intentionally flawed alternatives

These fragments are diagnostic alternatives, not files to compile. Each corresponds to a fault in the experiment of Section 12.2 or to a mistake in Section 13.10 of the chapter.

10.1 An outage that becomes “not found”

// WRONG: a failure of the provider is reported as an absent borrower.
try {
    return fetch(key);
} catch (IOException | InterruptedException failure) {
    return null;
}

The caller then tells a real borrower that they do not exist. The fault experiment includes “connection failure treated as not found”, and aRefusedConnectionIsAnOutage catches it. The correct code fails the request with a Failure that the adapter turns into DirectoryException.

10.2 A guess for an unknown code

// WRONG: an unknown kind is given the nearest known category.
default -> BorrowerCategory.STUDENT;

A visitor, or a kind added tomorrow, becomes a student without anyone deciding it. anUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent fails it.

10.3 A wait that is not bounded

// WRONG: no timeout; the request waits for ever.
HttpRequest request = HttpRequest.newBuilder(uri).GET().build();

The officer’s screen freezes. aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest fails it.

10.4 A mock standing in for the file

// WRONG for a test named FileApprovalSource...: the reader is replaced by the thing it should be tested against.
ApprovalSource register = mock(ApprovalSource.class);

Section 9 runs it.

10.5 A flag that is read once and never removed

// WRONG: a flag with no owner and no removal condition.
public static boolean visitingResearchersEnabled = true;

A global, mutable and on by default. It cannot be tested for both settings in one process without cleaning up, and it has no removal plan. The chapter’s flag is a constructor argument that defaults to off, and the ADR names the condition for removal.

10.6 A stage that can select nothing

mvn test -Dgroups=integraton   # misspelt: no test has this tag

Without failIfNoTests, a stage that runs zero tests passes. With it, the stage fails with No tests were executed!. The pipeline would otherwise report green for a stage that checked nothing.

11. Review answers and transfer solution

11.1 Concept answers

1. Unit test and a file adapter. A unit test controls everything the unit collaborates with, so it cannot show that the real parser reads the real file correctly: the file system, the encoding, the line endings and the parser are exactly what it has replaced. It shows that the logic that consumes a parsed record is right when given one.

2. The directory contract. It says that a known key returns its row, an unknown key returns nothing and is not a failure, and a provider that cannot answer fails and never returns an empty answer. Two implementations (the in-memory example and the HTTP client against the stub) pass it, so they are interchangeable for a caller. It can never show how the real provider behaves: the stub’s answers are the author’s assumption, and a provider contract test run against the real service would be the evidence.

3. Why not “not found”. An empty answer tells a caller the borrower is absent. After a timeout we do not know that. Two other defaults the policy refuses: treating an unknown kind code as student or staff, and answering from a cached or earlier result when the provider is down.

4. Delivery and deployment. Continuous delivery keeps the software releasable at any time; continuous deployment releases every passing change automatically. simulate-release.sh imitates delivery (a release candidate that has passed the stages and is smoke-tested in a staging folder). It imitates no deployment to production.

11.2 Applied diagnosis answers

5. The file reader replaced by a mock. The risk that is no longer covered is the parse of the real register: the header, the columns, the dates, the encoding and the duplicate keys. Keep the repeatability by using a temporary directory: write the file inside the test, read it with the real adapter, and delete it afterwards. Repeatable and real are not opposites.

6. A green pipeline and a failing demonstration. In the pipeline, an end-to-end journey with the flag on, which would have started the program with the flag that the demonstration uses. In the delivery process, a smoke test of the packaged jar with the settings of the target and a printed first line stating the effective setting (the program prints Visitors enabled: ...). Both are checks of configuration, and neither is a check of logic.

11.3 Transfer solution

For the model-serving team:

RiskCheck
The approval rule (only approved versions are served to external callers)Unit tests with a controlled approval source: approved, not approved, expired, a different version, the boundary of the validity window
The approval list file has a stray column or a duplicateIntegration test with a real temporary file and each failure case; a whole-file rule or a skip rule, chosen and written down
The registry times out or returns nonsenseIntegration test against a local stub with a bounded wait; the policy says the request fails and the version is not served, and that an unknown version is never defaulted to the latest approved one
The flag is on in the demonstration and off in the buildOne end-to-end journey that starts the serving process with each setting
Old behaviour changedThe existing suite

The flag enables the rule for external callers; rollback turns it off, but does not recall a response already served or revoke an approval a person already acted on. The evidence would not show the model’s accuracy, fairness or drift: nothing here evaluates the model, only the gate in front of it.

11.4 Quiz 2 revision pointers

None. Quiz 2 covers the earlier weeks; Section 13.11 of the chapter creates no new assessment.

12. Instructor notes

12.1 Suggested demonstration and review

Run pipeline.sh on a clean copy and read each stage’s counts aloud. Then make the header-check fault of Section 7.6 and run it again; ask students to predict which stage fails before they see it. Run the flag-off and flag-on journeys and ask what the first printed line is for. Run the simulated delivery twice and the rollback, and ask what the rollback did not change. Show the proposed AI test passing a parser fault before showing the repaired one failing.

Review your team’s project against the eight items of Section 13.8 of the chapter: ask for the first failing stage of the failing run, the name of the test, and a statement of what the pipeline did not check. A weak answer to the last question is “nothing”.

12.2 Do the tests detect faults?

Thirty faults were run one at a time in a copy of the project, through mvn test. Twenty-nine were caught. One survived, and it is an equivalent change (below). The harness reports the first two failing tests, in alphabetical order, so the third column is a sample of what noticed each fault and not a full list.

AreaFaultA test that caught it
GateDuration check removed (the register is read for zero days)VisitorAcceptanceTest.ac4_...; precedence_...
GateFlag ignoredac12_...; the first journey
GateAllowlist also admits MIC1ac5_...; anItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource
GateExact-request re-check removedaSourceThatReturnsADifferentRequestDoesNotApprove
GateClock read twiceoneRequestReadsTheClockOnce
RecordExpiry instant is validac9_...; theWindowIncludesItsStartAndExcludesItsEnd
RecordStart instant is not validac8_...; theWindowIncludesItsStartAndExcludesItsEnd
RecordDENIED also approvesac6_...; aMissingOrDeniedRecordIsAnExpectedRejection
RecordBorrower ignored when matchingThe second journey; aVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile
ServiceGate applied to every categoryThe first and third journeys
ServiceVisitors bypass the gateThe first and third journeys
RulesVisitors may borrow four daysac3_...; countAndDurationBoundaries
RulesVisitors may hold three reservations before anotherac11_...; countAndDurationBoundaries
DirectoryCode V maps to STUDENTThe first and third journeys
WiringVisitors get the staff policyac11_...; ac3_...
FileUnknown status becomes DENIEDanUnknownStatusIsAFailureNotADenial
FileDuplicate keys allowedaDuplicateRequestKeyIsAFailureEvenIfTheRowsDisagree
FileHeader not checkedaWrongOrMissingHeaderIsAFailure
FileExtra columns allowedaRowWithTheWrongNumberOfColumnsIsAFailure
FileBad rows skipped instead of rejecting the fileThe third journey; aBlankSponsorOrAnInvalidIntervalIsAFailure
FileRegister read once and cachedtheFileIsReadAfreshOnEveryCall
FileFirst row returned whatever the requestaRequestWithNoMatchingRowIsEmptyNotAFailure
HTTP404 treated as an outageThe first and second journeys
HTTPOther statuses treated as not foundThe first and second journeys
HTTPNo overall deadline (only the header timeout is left)aStalledBodyIsAFailureWithinTheOverallWait (the only test that notices)
HTTPNo timeout of any kind (header timeout and deadline both removed)aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest; aStalledBodyIsAFailureWithinTheOverallWait
HTTPHeader timeout removed, overall deadline keptSurvived. Equivalent: the overall deadline bounds the call either way
HTTPA late answer is treated as not foundaSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest; aStalledBodyIsAFailureWithinTheOverallWait
HTTPMalformed body not checkedaMalformedAnswerIsAnOutageNotAGuess
HTTPConnection failure treated as not foundaRefusedConnectionIsAnOutage; aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest

One reading note: some faults were first noticed by an end-to-end journey (the service and directory-mapping faults, for example). That does not mean no narrower test would notice; the harness lists only the first two names. It does mean those faults would be caught by stage 4 if every earlier stage were somehow silent, which is a reason to keep a few journeys.

Before the stalled-body test existed, no fault in the experiment concerned the body of the response, so the experiment could not have found the problem of Section 7.8. A fault experiment finds the faults that someone thought to apply, which is the same limit as the tests it measures. These counts come from one fault set on one small change, chosen by the author, and they say nothing about how this suite compares with earlier chapters. They do not show that the suite catches every fault, that the real directory behaves like the stub, or that the rule is the right rule.

References

The online references of the student chapter (sources 1–9, 13 and 14) were opened on 13 October 2026; see the chapter for the source context.

  1. Fowler, M., Integration Test.
  2. Fowler, M., Contract Test.
  3. Fowler, M., Deployment Pipeline.
  4. GitHub, Workflow syntax for GitHub Actions.
  5. Fowler, M., Continuous Delivery.
  6. Hodgson, P., Feature Toggles (aka Feature Flags).
  7. Maven, Using JUnit 5 Platform in Surefire.
  8. Oracle, HttpRequest.Builder (Java SE 17).
  9. Oracle, com.sun.net.httpserver (Java SE 17).
  10. JUnit, User Guide 5.13.4.
  11. Oracle, HttpClient (Java SE 17).
  12. Oracle, CompletableFuture (Java SE 17).

Chapter 14 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 14 Companion: Code, Explanations and Solutions.

Responding to Change and Handing Over
OO Development and Agile Practices

1. Scope and execution

This file accompanies Chapter 14, Responding to Change and Handing Over. It contains the handover version of the visiting-researcher project (0.14.0): a requirements-to-evidence table with a test that keeps it true, a reproducible demonstration, a script that follows the handover in a fresh copy, the handover documents, a decision record about the flag, the prepared and tested removal of that flag, the AI activity files, model solutions, the experiments behind the chapter’s figures and teaching notes.

No production code changed. Every file under src/main is byte for byte as in the Chapter 13 version. What changed is tests, scripts, documents and the version.

Input version. The starting point is the Chapter 13 version, which is the Chapter 10 companion, the new and changed files of the Chapter 11 companion and the new and changed files of the Chapter 13 companion. To keep verified listings from being copied again, this file lists only the files that are new or changed. Every other file is exactly as in those companions.

1.1 Reconstruct the version

  1. Create the directory after. Save every listing of the Chapter 10 companion, then the new and changed listings of the Chapter 11 companion, then those of the Chapter 13 companion, applying the hunks of the Chapter 13 companion with patch -p1 from inside after.
  2. Save every listing in Sections 3 and 4 of this file at its path. Where a path already exists (README.md, scripts/pipeline.sh, scripts/simulate-release.sh), the listing here replaces it. Apply the hunk of Section 3.2 to pom.xml with patch -p1.
  3. The files under ai-activity/ and rehearsal/ are not compiled by Maven.

Use a full Java 17 JDK, including the jdk.jdeps module, and Maven 3.9.11. The pinned versions are unchanged (JUnit Jupiter 5.13.4, Mockito 5.20.0, Compiler Plugin 3.14.1, Surefire 3.5.4); only the project version becomes 0.14.0. Then, from inside after:

mvn --batch-mode verify
bash scripts/pipeline.sh
bash scripts/demo.sh
bash scripts/handover-check.sh

1.2 Expected output

mvn --batch-mode verify ends with Tests run: 235, Failures: 0, Errors: 0, Skipped: 0 and BUILD SUCCESS. The 235 are the 232 of Chapter 13 and the three of TraceabilityTest. scripts/pipeline.sh ends with PIPELINE PASSED, with 206, 26 and 3 invocations in stages 2, 3 and 4. scripts/demo.sh prints:

== visitors disabled ==
Visitors enabled: false
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Rejected: visitor reservations are not enabled.
Visitor V2: Rejected: visitor reservations are not enabled.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 1
Notices: 1
== visitors enabled ==
Visitors enabled: true
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22).
Visitor V2: Rejected: no valid sponsor approval.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 2
Notices: 2

(Maven and the mocking library print WARNING lines on a recent JDK; they are not errors. The Maven warnings appear before the demonstration lines.) scripts/handover-check.sh ends with HANDOVER CHECK PASSED after twelve ok lines.

1.3 Run the traceability check alone

mvn --batch-mode test -Dtest=TraceabilityTest

Expected: Tests run: 3, Failures: 0, Errors: 0, Skipped: 0.

2. Design map

2.1 What is new or changed

PathStatusPurpose
src/test/java/edu/se506/lending/docs/TraceabilityTest.javaNewFails the build if the requirements table names a test or class that is gone, or an acceptance example loses its row
src/test/java/edu/se506/lending/integration/DemoRun.javaNewStarts the local directory stub and runs the system twice, for scripts/demo.sh
scripts/demo.shNewCompiles and runs DemoRun; one command, no other setup
scripts/handover-check.shNewFollows docs/HANDOVER.md in a fresh copy and compares the results
scripts/pipeline.sh, scripts/simulate-release.shChangedA clear message and a non-zero exit when Maven is missing
docs/HANDOVER.mdNewThe handover bundle
docs/REQUIREMENTS-TO-EVIDENCE.mdNewTen contract clauses and eighteen acceptance examples traced to a deciding class and a test, and five known gaps
docs/KNOWN-ISSUES.mdNewFourteen known issues, each with a decision
docs/RELEASE-NOTES-0.14.0.mdNewWhat changed in this version
docs/ADR-014-flag-handover.mdNewDecision: keep the flag, rehearse the removal
rehearsal/remove-visitor-flag.patchNewThe prepared, tested removal of the visitor flag. Not applied
ai-activity/README-draft.md, ai-activity/README-corrected.mdNewThe AI-drafted README and the corrected one
README.md, pom.xmlChangedHandover pointer; version 0.14.0

2.2 Test counts by stage

StageChapter 13Chapter 14
2 Unit tests and architecture checks203206 (adds TraceabilityTest, 3)
3 Integration2626
4 End-to-end33
Total232235

2.3 What TraceabilityTest checks, and what it does not

CheckMethod
Every test named in the Evidence column exists as a methodeveryTestNamedInTheTableStillExists (also requires at least 29 named tests, so that a deleted row cannot hide)
Every class named in the Decided by column exists under src/maineveryClassNamedAsDecidingStillExists
AC1 to AC18 each have exactly one row, and every ac<N>_ test in VisitorAcceptanceTest appears in the tableeveryAcceptanceExampleHasExactlyOneRowAndEveryAcceptanceTestIsInTheTable
That a named test is the right evidence for its requirementNot checked. A reviewer decides

3. New and changed listings: build, scripts, documents

3.1 after/README.md

# Chapter 14 checkpoint: handover version 0.14.0

Start with `docs/HANDOVER.md`: what this is, what you need, how to set it up, how to see it work, what is known to be missing.

Use a full Java 17 JDK and Maven 3.9.11. The JDK must include jdk.jdeps for the compiled dependency tests. From this directory:

```bash
mvn --batch-mode verify
bash scripts/pipeline.sh
bash scripts/demo.sh
```

The expected results, including the number of test invocations in each pipeline stage, are in `docs/HANDOVER.md`, which `scripts/handover-check.sh` checks in a fresh copy. `TraceabilityTest` fails the build if `docs/REQUIREMENTS-TO-EVIDENCE.md` names a test or class that no longer exists.

Version 0.14.0 changes no production behaviour. It adds the demonstration (`scripts/demo.sh`), the handover documents in `docs/`, a script that follows the handover in a fresh copy (`scripts/handover-check.sh`) and the rehearsed, not applied, removal of the visitor flag (`rehearsal/remove-visitor-flag.patch`, decided in `docs/ADR-014-flag-handover.md`). Known issues, with decisions, are in `docs/KNOWN-ISSUES.md`.

The visitor flag `lending.visitors` defaults to false. `scripts/simulate-release.sh` is a SIMULATION of delivery. `.github/workflows/pipeline.yml` has never run. No Java 17 run, hosted CI run or independent review is claimed.

The package `legacy` stands for the old records system's overdue report. It is isolated, like `patterns`, `behaviour` and `examples`: production code under `edu.se506.lending` must not refer to it, and DependencyRulesTest checks that. Only bootstrap launches it or feeds it rows. LoanRow is the old system's export format and is deliberately unchanged.

The optional bridge (OverdueRowSource, OverdueReportService and LoanSnapshotRows in bootstrap) feeds the report from the live loan and reservation snapshots. It reads one date and one snapshot per report, and a failure to read is never reported as zero overdue loans.

The directory `stages` holds the report at each step of the refactoring. It is not part of the Maven build. To run a stage, copy its OverdueReport.java over src/main/java/legacy/OverdueReport.java and run:

```bash
mvn --batch-mode -Dtest='OverdueReport*Test' test
```

Stages 1 to 4 each pass the same 28 invocations. Stage 0 is the code as found and cannot be tested without a change, because it reads the system clock. stageC is a separate functional change (unknown kinds are rejected) with its own replacement tests; to run it, copy all three of its files to their src locations.

The directory `probe` holds a program that runs the original, unseamed report beside the seamed one and compares their output. It is not part of the Maven build. From this directory, with stage 1 or the final report in place:

```bash
mkdir -p probe/out
javac --release 17 -d probe/out src/main/java/legacy/*.java probe/OriginalUnseamed.java probe/OriginalReportProbe.java
java -cp probe/out legacy.OriginalReportProbe
```

The characterisation tests record what the report does today. A passing characterisation test means unchanged, not correct. Four behaviours are recorded as suspected or surprising in docs/SUSPECTED-DEFECTS-legacy-overdue-report.md and are not fixed here. ADR-011 records the decision to pin, isolate and refactor the report.

To print the package metrics, run `mvn --batch-mode compile` and then `java tools/PackageMetrics.java target/classes`.

ADR-010 from Chapter 10 is still in docs. No hosted CI run and no peer review is claimed.

3.2 after/pom.xml

Change the project version from 0.13.1 to 0.14.0 and nothing else. The hunk is an edit to the Chapter 13 POM:

--- chapter13/pom.xml
+++ chapter14/pom.xml
@@ -4,7 +4,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>edu.se506</groupId>
   <artifactId>equipment-lending</artifactId>
-  <version>0.13.1</version>
+  <version>0.14.0</version>
   <properties>
     <maven.compiler.release>17</maven.compiler.release>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

3.3 after/scripts/pipeline.sh

#!/usr/bin/env bash
# A modest delivery pipeline run on one machine. Each stage must pass before the next one starts,
# and the first failing stage stops the run with a non-zero status. CHAPTER_MVN names the Maven
# command (default: mvn); CHAPTER_M2 optionally names a local Maven repository.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
if ! command -v "$MVN" >/dev/null 2>&1 && [ ! -f "$MVN" ]; then
  echo "Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2
  exit 2
fi
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi

stage() {
  local name="$1"
  shift
  echo "=== STAGE: ${name}"
  local began=$SECONDS
  "$@"
  echo "=== PASSED: ${name} ($((SECONDS - began)) s)"
}

stage "1 compile" "$MVN" "${ARGS[@]}" -q compile
stage "2 unit tests and architecture checks" "$MVN" "${ARGS[@]}" test -DexcludedGroups=integration,e2e
stage "3 integration tests (real file, real HTTP client, local stub service)" "$MVN" "${ARGS[@]}" test -Dgroups=integration
stage "4 end-to-end journeys (separate process)" "$MVN" "${ARGS[@]}" test -Dgroups=e2e
stage "5 package" "$MVN" "${ARGS[@]}" package -DskipTests
echo "PIPELINE PASSED"

3.4 after/scripts/simulate-release.sh

#!/usr/bin/env bash
# This script does not enforce the pipeline's gates: it packages with -DskipTests and then runs only the
# end-to-end smoke journeys, so it assumes that scripts/pipeline.sh has already passed for the same candidate.
# Rollback assumes the same version-derived jar file name in both releases, so the exercise's two releases are
# two labels for one build and not a tested cross-version rollback.
#
# SIMULATION of continuous delivery to a staging target. Nothing here is a server, a network
# deployment or a production release: "staging" is a folder, "deploying" is copying a jar into it,
# and the smoke test is the end-to-end journeys run against the jar that was copied.
#
#   bash scripts/simulate-release.sh              build, deliver as the current release, smoke-test it
#   RELEASE_LABEL=v2 bash scripts/simulate-release.sh   deliver again; the old release becomes "previous"
#   bash scripts/simulate-release.sh rollback     make "previous" the current release again, smoke-test it
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
if ! command -v "$MVN" >/dev/null 2>&1 && [ ! -f "$MVN" ]; then
  echo "Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2
  exit 2
fi
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi
STAGING="build/staging"
VERSION="$(sed -n 's:^  <version>\(.*\)</version>$:\1:p' pom.xml | head -1)"
JAR="equipment-lending-${VERSION}.jar"

smoke() {
  echo "--- smoke test of $(cat "${STAGING}/current/RELEASE") (end-to-end journeys against ${STAGING}/current/${JAR})"
  "$MVN" "${ARGS[@]}" test -Dgroups=e2e "-De2e.classpath=${STAGING}/current/${JAR}"
}

case "${1:-deliver}" in
  deliver)
    "$MVN" "${ARGS[@]}" -q package -DskipTests
    mkdir -p "${STAGING}"
    if [ -d "${STAGING}/current" ]; then
      rm -rf "${STAGING}/previous"
      mv "${STAGING}/current" "${STAGING}/previous"
    fi
    mkdir -p "${STAGING}/current"
    cp "target/${JAR}" "${STAGING}/current/${JAR}"
    echo "${RELEASE_LABEL:-${VERSION}}" > "${STAGING}/current/RELEASE"
    echo "--- delivered $(cat "${STAGING}/current/RELEASE") to ${STAGING}/current (simulation)"
    smoke
    ;;
  rollback)
    if [ ! -d "${STAGING}/previous" ]; then
      echo "No previous release to roll back to." >&2
      exit 1
    fi
    rm -rf "${STAGING}/current"
    mv "${STAGING}/previous" "${STAGING}/current"
    echo "--- rolled back to $(cat "${STAGING}/current/RELEASE") (simulation)"
    smoke
    ;;
  *)
    echo "usage: simulate-release.sh [deliver|rollback]" >&2
    exit 2
    ;;
esac

3.5 after/scripts/demo.sh

#!/usr/bin/env bash
# A reproducible demonstration with no other setup: compile, start the local directory stub inside
# one process, and run the system twice (visitors disabled, then enabled). CHAPTER_MVN names the
# Maven command (default: mvn); CHAPTER_M2 optionally names a local Maven repository.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
if ! command -v "$MVN" >/dev/null 2>&1 && [ ! -f "$MVN" ]; then
  echo "Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2
  exit 2
fi
if ! command -v java >/dev/null 2>&1; then
  echo "Java was not found. Install a full Java 17 JDK and make sure java is on the PATH." >&2
  exit 2
fi
ARGS=(--batch-mode -q)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi

"$MVN" "${ARGS[@]}" test-compile

SEP=":"
case "$(uname -s)" in
  MINGW*|MSYS*|CYGWIN*) SEP=";" ;;
esac
java -cp "target/classes${SEP}target/test-classes" edu.se506.lending.integration.DemoRun

3.6 after/scripts/handover-check.sh

#!/usr/bin/env bash
# Follows docs/HANDOVER.md in a fresh copy of the project, so that a stale or missing step shows up
# here and not on the day a new reader tries it. It copies the project without build output into a
# new folder, runs the pipeline and the demonstration there, and compares what they print with what
# HANDOVER.md promises. The expected figures below must be kept in step with HANDOVER.md.
#
# CHAPTER_MVN names the Maven command (default: mvn); CHAPTER_M2 optionally names a local Maven
# repository. Both are passed on to the scripts it runs.
set -euo pipefail

EXPECT_UNIT=206          # stage 2 of the pipeline
EXPECT_INTEGRATION=26    # stage 3
EXPECT_E2E=3             # stage 4
EXPECT_TOTAL=$((EXPECT_UNIT + EXPECT_INTEGRATION + EXPECT_E2E))

SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
( cd "$SRC" && tar cf - --exclude=./target --exclude=./build --exclude=./.git . ) | ( cd "$WORK" && tar xf - )
cd "$WORK"

failures=0
expect() {   # expect <file> <fixed text> <what it shows>
  if grep -qF -- "$2" "$1"; then
    echo "ok   $3"
  else
    echo "FAIL $3 (expected to find: $2)"
    failures=$((failures + 1))
  fi
}

echo "--- following HANDOVER.md in ${WORK}"
if bash scripts/pipeline.sh > pipeline.out 2>&1; then echo "ok   the pipeline exited 0"; else echo "FAIL the pipeline did not exit 0"; failures=$((failures + 1)); fi
expect pipeline.out "PIPELINE PASSED" "the pipeline reports PIPELINE PASSED"
expect pipeline.out "Tests run: ${EXPECT_UNIT}, Failures: 0, Errors: 0, Skipped: 0" "stage 2 ran ${EXPECT_UNIT} invocations"
expect pipeline.out "Tests run: ${EXPECT_INTEGRATION}, Failures: 0, Errors: 0, Skipped: 0" "stage 3 ran ${EXPECT_INTEGRATION} invocations"
expect pipeline.out "Tests run: ${EXPECT_E2E}, Failures: 0, Errors: 0, Skipped: 0" "stage 4 ran ${EXPECT_E2E} invocations"

expect docs/HANDOVER.md "Tests run: ${EXPECT_TOTAL}, Failures: 0" "HANDOVER.md promises ${EXPECT_TOTAL} invocations in total"
expect docs/HANDOVER.md "Stage 2 runs ${EXPECT_UNIT} invocations, stage 3 runs ${EXPECT_INTEGRATION} and stage 4 runs ${EXPECT_E2E}." "HANDOVER.md promises the stage counts"

if bash scripts/demo.sh > demo.out 2>&1; then echo "ok   the demonstration exited 0"; else echo "FAIL the demonstration did not exit 0"; failures=$((failures + 1)); fi
expect demo.out "Visitor V1: Rejected: visitor reservations are not enabled." "the disabled run rejects a visitor"
expect demo.out "Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22)." "the enabled run confirms an approved visitor"
expect demo.out "Visitor V2: Rejected: no valid sponsor approval." "the enabled run rejects an unapproved visitor"
expect demo.out "Outage BROKEN: Unavailable: Borrower directory unavailable" "an outage is reported as unavailable"

if [ "$failures" -eq 0 ]; then
  echo "HANDOVER CHECK PASSED"
else
  echo "HANDOVER CHECK FAILED: ${failures} problem(s)"
  exit 1
fi

3.7 after/docs/HANDOVER.md

# Handover: Campus Equipment Lending System, version 0.14.0

This is the handover bundle for the book project. It is written for a person who has not seen the project before and has about an hour. A team applies the same headings to its own project.

## 1. What this is

A small Java application that lets borrowers reserve campus equipment. It decides reservations by borrower category (student, staff, visiting researcher), records loans and returns, and prints an overdue report. Visiting researchers are supported by a change request, CR-12, which is a **teaching contract** and not an institutional policy. The visitor behaviour is behind one flag that is **off by default**.

It is an in-memory teaching system: no real database, no real borrower directory and no real sponsor-approval service. The directory and the approval register are controlled stand-ins.

## 2. What you need

| Need | Detail |
|---|---|
| A full Java 17 JDK | It must include the `jdk.jdeps` module, which two architecture tests use. The build compiles with `--release 17`. The checks were run with `--release 17` on a newer JDK; a Java 17 runtime run has not been made. |
| Maven 3.9.11 | The pinned dependency versions are in `pom.xml`. The first build downloads them. |
| A POSIX shell | Git Bash on Windows is enough. The scripts are written for `bash`. |
| Network access | Only for the first build, to fetch dependencies. |

## 3. Set up and check that it works

First check the tools:

```bash
java -version
mvn -version
```

You should see Java 17 or later and Maven 3.9.x. If `mvn` is not found, install Maven, or set `CHAPTER_MVN` to the full path of the Maven command for the scripts (for example `export CHAPTER_MVN=/path/to/apache-maven-3.9.11/bin/mvn`). The scripts print a message and stop if Maven or Java is missing. The commands below that start with `mvn` need Maven on the PATH.

Then, from the project directory (the one that contains `pom.xml`):

```bash
mvn --batch-mode verify
```

Expected: `BUILD SUCCESS` and `Tests run: 235, Failures: 0, Errors: 0, Skipped: 0`.

To run the same checks as the delivery pipeline, stage by stage, and stop at the first failure:

```bash
bash scripts/pipeline.sh
```

Expected: five stages, each ending `PASSED`, then `PIPELINE PASSED`. Stage 2 runs 206 invocations, stage 3 runs 26 and stage 4 runs 3.

If your Maven command is not `mvn`, or you keep dependencies in a separate folder, set `CHAPTER_MVN` and `CHAPTER_M2` before running a script; the scripts say so in their first lines.

**Things you may see that are not errors.** On a recent JDK Maven 3.9.11 prints `WARNING` lines about `sun.misc.Unsafe`, and the mocking library prints a warning that a Java agent was loaded dynamically. The build still ends with `BUILD SUCCESS`. Read the last lines of the output, not the first. A folder name with spaces in it is fine.

**If something fails.** A failing stage prints its name and stops the pipeline. Read the name of the first failing test before the stack trace. If the first build fails while downloading dependencies, check your network and try again. If `TraceabilityTest` fails, a document and the code disagree: read its message, which names the missing test or class.

## 4. See it work

```bash
bash scripts/demo.sh
```

This compiles the project, starts the local directory stub inside one process, and runs the system twice. With visitors disabled, visitor requests are rejected with `visitor reservations are not enabled`. With visitors enabled, the visitor V1 with a matching approval is confirmed, V2 with none is rejected, and an unavailable directory is reported as unavailable and not as a missing borrower. The clock is fixed so that the sample approval is always valid.

## 5. How it is organised

| Package | Role |
|---|---|
| `domain` | Borrowers, reservations, loans, borrowing policies, approval records |
| `application` | Use cases: reserving, loans, the visitor gate, ports for the directory, approvals and notices |
| `infrastructure` | Adapters: the directory client and adapter, the approval file, notices, in-memory storage |
| `bootstrap` | Wiring and the runnable demonstrations |
| `legacy` | The old overdue report, isolated; production code does not depend on it |

The architecture tests (`DependencyRulesTest`) fail the build if a dependency points the wrong way. The decisions behind the structure are in `docs/ADR-010-application-structure.md`, `docs/ADR-011-legacy-overdue-report.md`, `docs/ADR-013-visiting-researchers.md` and `docs/ADR-014-flag-handover.md`.

## 6. The visitor flag

The flag `lending.visitors` defaults to `false`. To change it for the demonstration program, pass `-Dlending.visitors=true`. A mistyped value means `false`, which is the safe direction; the program prints the effective setting on its first line. To stop new visitor reservations, turn the flag off. That does not remove reservations that already exist. The decision about removing the flag is in `docs/ADR-014-flag-handover.md`.

## 7. Requirements to evidence

`docs/REQUIREMENTS-TO-EVIDENCE.md` traces the ten contract clauses and the eighteen acceptance examples to the class that decides each and the test that checks it, and lists five known gaps. `TraceabilityTest` fails the build if a named test or class disappears.

## 8. Known limitations

`docs/KNOWN-ISSUES.md` is the full list, with what was decided and why. In brief:

- The directory and the approval register are stand-ins. Nothing here was run against a real service.
- Two writers of the register, or simultaneous requests, were not considered.
- A late visitor is charged at the rate of the report's "any other kind" rule (suspected defect SD-11-1). The count rule counts every retained reservation, including returned ones (SD-12). Both are unresolved and need an owner's decision.
- No Java 17 run, and no hosted CI run, has been made. The file `.github/workflows/pipeline.yml` has never run.
- `scripts/simulate-release.sh` is a simulation of delivery. Nothing is deployed anywhere.

## 9. Where things are

| Path | What |
|---|---|
| `src/main/java` | Production code |
| `src/test/java` | Tests: unit, integration (tag `integration`), end-to-end (tag `e2e`), and the traceability check |
| `scripts/pipeline.sh` | The five-stage pipeline |
| `scripts/demo.sh` | The demonstration |
| `scripts/simulate-release.sh` | Simulated delivery and rollback |
| `scripts/handover-check.sh` | Copies the project to a new folder and follows this document |
| `docs/` | Decision records, requirements to evidence, known issues, release notes, this file |
| `tools/PackageMetrics.java` | Package coupling and stability figures; see the Chapter 10 notes |
| `stages/`, `probe/`, `ai-activity/`, `rehearsal/` | Teaching material outside the build |

## 10. Demonstration outline (about ten minutes)

1. The problem in one sentence, and what changed in this version (one minute).
2. Run `bash scripts/pipeline.sh` and point at the stage counts (two minutes; start it before you begin talking).
3. Run `bash scripts/demo.sh` and read the two outputs side by side (three minutes).
4. Open `docs/REQUIREMENTS-TO-EVIDENCE.md` and follow one row from requirement to test to stage (two minutes).
5. State what is not shown, from section 8, and the next action (two minutes).

The timings are a suggestion. Your instructions decide the length and the format of your own demonstration.

3.8 after/docs/REQUIREMENTS-TO-EVIDENCE.md

# Requirements to evidence: visiting-researcher reservations (CR-12)

This table traces each requirement and acceptance example to the code that decides it and the test that checks it. It is checked by `TraceabilityTest`: every test named in the *Evidence* column must exist, every acceptance example AC1 to AC18 must have exactly one row, and every class named in the *Decided by* column must exist. The test checks that names exist. It does not check that the named test is the right one: a reviewer does that.

The stage is the pipeline stage that runs the evidence (`scripts/pipeline.sh`): 2 is unit tests and architecture checks, 3 is integration tests, 4 is end-to-end journeys.

CR-12 is a teaching contract. It is not an institutional borrowing policy.

## Contract clauses

| ID | Requirement | Decided by | Evidence | Stage |
|---|---|---|---|---|
| C1 | A visitor is recognised through the borrower directory, with the kind code `V` | `LegacyDirectoryAdapter`, `DirectoryReservationService` | `DirectoryOutageIntegrationTest.aVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile` | 3 |
| C2 | Only CAM1 and CAM2 may be requested | `VisitorGate` | `VisitorGateTest.anItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource` | 2 |
| C3 | One to three days, and no more than three retained reservations | `VisitorReservationRules` | `VisitorRulesTest.countAndDurationBoundaries` | 2 |
| C4 | The approval matches the borrower, the item and the exact period | `ApprovalRecord` | `VisitorRulesTest.aRecordMatchesOnlyTheExactBorrowerItemAndPeriod` | 2 |
| C5 | The approval is valid at the request instant (start inclusive, end exclusive) | `ApprovalRecord`, `VisitorGate` | `VisitorGateTest.theWindowIncludesItsStartAndExcludesItsEnd` | 2 |
| C6 | A missing, denied, mismatched or expired approval creates no reservation and sends no notice | `VisitorGate`, `DirectoryReservationService` | `VisitorAcceptanceTest.ac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise` | 2 |
| C7 | An unreadable or malformed register is a failure, not a rejection | `FileApprovalSource` | `FileApprovalSourceIntegrationTest.oneBadRowRejectsTheWholeRegisterEvenWhenTheRequestMatchesAGoodRow` | 3 |
| C8 | An unreachable or slow directory is a failure, never "not found" | `HttpLegacyDirectoryClient`, `LegacyDirectoryAdapter` | `DirectoryOutageIntegrationTest.aServerErrorIsAnOutageNotANotFoundAndIsNotRetried` `DirectoryOutageIntegrationTest.aStalledBodyIsAFailureWithinTheOverallWait` | 3 |
| C9 | The visitor behaviour is behind one flag that defaults to off | `VisitorGate`, `Chapter13Demo` | `Chapter13JourneyE2ETest.withTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed` | 4 |
| C10 | Student and staff behaviour is unchanged | `ReservationCoordinator` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister` | 2 |

## Acceptance examples

| ID | Example | Decided by | Evidence | Stage |
|---|---|---|---|---|
| AC1 | CAM1, two days, matching approved record valid at request time | `VisitorGate`, `ReservationCoordinator` | `VisitorAcceptanceTest.ac1_aVisitorWithAMatchingApprovalReservesCam1ForTwoDays` | 2 |
| AC2 | CAM2, three days, with its own record | `VisitorReservationRules` | `VisitorAcceptanceTest.ac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowed` | 2 |
| AC3 | Four days is rejected | `VisitorReservationRules` | `VisitorAcceptanceTest.ac3_fourDaysIsRejectedEvenWithAMatchingRecord` | 2 |
| AC4 | Zero or negative days is rejected without reading the register | `VisitorGate`, `VisitorReservationRules` | `VisitorAcceptanceTest.ac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister` | 2 |
| AC5 | An item outside the allowlist is rejected | `VisitorGate` | `VisitorAcceptanceTest.ac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecord` | 2 |
| AC6 | A missing or denied approval is an expected rejection | `VisitorGate` | `VisitorAcceptanceTest.ac6_aMissingOrDeniedApprovalIsAnExpectedRejection` | 2 |
| AC7 | An approval for another borrower, item or period does not authorise | `ApprovalRecord`, `VisitorGate` | `VisitorAcceptanceTest.ac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise` | 2 |
| AC8 | The request instant equal to `validFrom` is valid | `ApprovalRecord` | `VisitorAcceptanceTest.ac8_theRequestInstantEqualToValidFromIsValid` | 2 |
| AC9 | The request instant at `validUntil`, or before `validFrom`, is rejected | `ApprovalRecord` | `VisitorAcceptanceTest.ac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejected` | 2 |
| AC10 | An unreadable register is an operational failure | `FileApprovalSource`, `VisitorGate` | `VisitorAcceptanceTest.ac10_anUnreadableApprovalRegisterIsAnOperationalFailureNotARejection` | 2 |
| AC11 | Three retained reservations block a fourth, even after the loans are returned | `ReservationCoordinator`, `VisitorReservationRules` | `VisitorAcceptanceTest.ac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturned` | 2 |
| AC12 | A disabled flag rejects explicitly without reading the register | `VisitorGate` | `VisitorAcceptanceTest.ac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister` | 2 |
| AC13 | Student and staff requests are unchanged under either setting | `DirectoryReservationService` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister` | 2 |
| AC14 | An overlap with an existing reservation is rejected by the existing rule | `ReservationCoordinator` | `VisitorAcceptanceTest.ac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule` | 2 |
| AC15 | An expected notification failure keeps the reservation | `ReservationCoordinator` | `VisitorAcceptanceTest.ac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace` | 2 |
| AC16 | A visitor reservation stays usable after the approval expires and the flag is off | `LoanService` | `VisitorAcceptanceTest.ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff` | 2 |
| AC17 | An unknown or inactive borrower is rejected as before | `DirectoryReservationService`, `LegacyDirectoryAdapter` | `VisitorAcceptanceTest.ac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore` | 2 |
| AC18 | A visitor loan in the overdue report keeps the existing kind rule (suspected defect SD-11-1) | `OverdueReport`, `FinePolicy` | `VisitorAcceptanceTest.ac18_aVisitorLoanInTheOverdueReportKeepsTheExistingKindRuleSd11_1` | 2 |

## Known gaps: requirements with no evidence here

| ID | Gap | Why it is not covered |
|---|---|---|
| G1 | Behaviour against a real directory service | There is none in this project; the stub is the author's assumption |
| G2 | Two writers of the register, or two simultaneous requests | The register is a trusted sequential file; no concurrency test exists |
| G3 | Revoking an approval after a reservation | Not part of CR-12; see the revisit triggers in `ADR-013-visiting-researchers.md` |
| G4 | The right late-fine rate for a visitor | Suspected defect SD-11-1: only the records office can decide it |
| G5 | Behaviour on a Java 17 runtime and on a hosted CI runner | Not run; the checks used `--release 17` on a newer JDK |

3.9 after/docs/KNOWN-ISSUES.md

# Known issues and decisions, version 0.14.0

Each item has a decision. **Fixed** means changed in this version and covered by a check. **Deferred** means a named owner must decide first, or the cost is not justified yet. **Accepted** means a known limit that the documents state.

The version is a handover, not a feature sprint. Nothing below was fixed by adding behaviour; the one fix is to the setup and demonstration.

| ID | Item | Effect on a user | Decision | Reason and next step |
|---|---|---|---|---|
| KI-1 | The demonstration program needed a directory service that only the tests could start, so a new reader could not run it | A reader could not see the system work | **Fixed** (`scripts/demo.sh`, `DemoRun`) | Covered by the dry run in `scripts/handover-check.sh` |
| KI-2 | SD-11-1: any borrower kind other than student or staff is fined at 1.00 a day with no grace and no cap. A late visitor now reaches this rule (AC18 pins it) | A late visitor is fined at a rate nobody chose | **Deferred** | Only the records office can say what the rate should be. Pinned in `docs/SUSPECTED-DEFECTS-legacy-overdue-report.md` |
| KI-3 | SD-11-2: a loan within the grace period is listed with fine 0.00 and counted as overdue | A report line that may not be wanted | **Deferred** | Needs the owner's answer to "should it appear at all?" |
| KI-4 | SD-11-3: a null kind throws `NullPointerException`, but only for a loan that is overdue and unreturned | A crash if the export ever contains a null kind | **Deferred** | Ask whether the export can contain one |
| KI-5 | SD-11-4: amounts use the default locale (`0,50` under a German locale) | A different separator on a different machine | **Deferred** | Owner decides whether the separator follows the machine or the office |
| KI-6 | SD-12: the count rule counts every retained reservation, including returned ones | A borrower with three returned loans cannot reserve a fourth | **Deferred** | Preserved on purpose by CR-12 and pinned by AC11. Owner decides |
| KI-7 | One bad row in the approval register rejects the whole register | One typo blocks every visitor request until it is repaired | **Accepted** | A silent skip could hide a lost approval or a duplicate. Revisit with real use |
| KI-8 | The directory wait defaults to one second and was not measured. It bounds the whole lookup, body included | A slow or stalled directory fails the request after about a second | **Accepted** | It is a setting, `lending.directory.timeout.millis`. Measure before changing it. The stalled-body case is tested (`aStalledBodyIsAFailureWithinTheOverallWait`) on a newer JDK only; no Java 17 run was made |
| KI-9 | No retry and no cached answer after a directory outage | An outage stops the request | **Accepted** | By design (ADR-013). Revisit after measured outages |
| KI-10 | The directory stub and the approval file are stand-ins | Nothing here shows how a real service behaves | **Accepted** | Needs a provider contract test against a real service, which does not exist here |
| KI-11 | Concurrency: two writers of the register, or simultaneous requests, were not considered | Unknown | **Accepted** | Out of scope; the register is a trusted sequential file |
| KI-12 | No Java 17 run and no hosted CI run; the workflow's action versions were not re-checked | The Java 17 baseline and the workflow are untested | **Deferred** | Run both when a JDK 17 and a hosted runner are available |
| KI-13 | The visitor flag is still in the code | Two paths to keep tested | **Deferred** | Decided in `docs/ADR-014-flag-handover.md`; the removal is rehearsed in `rehearsal/` |
| KI-14 | `DirectoryReservationService` now has a branch on the visitor category | One category-specific special case in the entry point | **Accepted** | Fine for one special category. At a second, replace the branch with a per-category pre-check, as a refactoring with its own tests |

3.10 after/docs/RELEASE-NOTES-0.14.0.md

# Release notes: version 0.14.0 (handover)

Date: 13 October 2026. Built from version 0.13.1, the visiting-researcher change with its corrected directory timeout.

## What this version is

A handover version. It changes **no production behaviour**: every production source file is byte for byte as in 0.13.1. It adds the material that a new reader needs: a runnable demonstration, a requirements-to-evidence table that a test keeps honest, a list of known issues with decisions, and a decision about the visitor flag.

## Added

- `scripts/demo.sh` and `DemoRun`: run the system with visitors disabled and then enabled, with no other setup.
- `scripts/handover-check.sh`: copies the project to a new folder, follows `docs/HANDOVER.md` there and compares the results.
- `docs/HANDOVER.md`, `docs/KNOWN-ISSUES.md`, `docs/REQUIREMENTS-TO-EVIDENCE.md` and `docs/ADR-014-flag-handover.md`.
- `TraceabilityTest` (three invocations): fails the build if a test or class named in the requirements table no longer exists, or an acceptance example loses its row.
- `rehearsal/remove-visitor-flag.patch`: the prepared, tested removal of the visitor flag. Not applied.

## Changed

- Version 0.14.0. The build runs 235 invocations (232 from 0.13.1 and the three above).

## Fixed

- KI-1: the demonstration could not be run without a directory service that only the tests could start.

## Behaviour you should know about

- Visitors remain **disabled by default**. Set `-Dlending.visitors=true` for the demonstration program to enable them.
- An unreachable directory or an unreadable approval register stops the request. It is never reported as "not found" or "not approved".

## Known issues

See `docs/KNOWN-ISSUES.md`. The most important: a late visitor is fined at the rate of the report's "any other kind" rule, which nobody has chosen (SD-11-1); the count rule counts returned reservations (SD-12).

## Not verified

No Java 17 run and no hosted CI run has been made. The directory and the approval register are stand-ins; nothing was run against a real service. No independent review has been made of this release.

3.11 after/docs/ADR-014-flag-handover.md

# ADR-014: Keep the visitor flag at handover; removal rehearsed, not applied

Date: 13 October 2026
Status: Decided for the book project by its author, as a model decision. Not an institutional decision. A team decides for its own flag.

## Context

ADR-013 introduced one flag, `visitingResearchersEnabled` (the property `lending.visitors`), default off, and said to remove it when all of three conditions hold: the released variant of CR-12 is accepted, both settings have passed in the pipeline for the release candidate, and a decision to keep visitors enabled is recorded. It also said to review the flag at the end of the handover.

The state today: both settings pass in the pipeline (the second condition holds). CR-12 is a teaching contract and no owner has accepted it as the borrowing policy (the first condition does not hold). Nobody has recorded a decision to keep visitors enabled (the third does not hold).

## Decision

Keep the flag, default off, in version 0.14.0. Do not remove it yet.

The removal was **rehearsed** in a scratch copy so that the cost is known and the change is ready. The rehearsal is `rehearsal/remove-visitor-flag.patch`; apply it from the project directory with `patch -p1`. It deletes the flag, the `DISABLED` result and `VisitorGate.disabled()`, and adds `VisitorGate.noApprovals()` for an application with no register, so that the older two-argument service constructor keeps its meaning: no visitor is approved. It touches eleven files (three production classes, five test classes, the requirements table, the expected numbers in `docs/HANDOVER.md` and `scripts/handover-check.sh`) with 87 lines added and 131 removed, and the prose of `README.md`, the rest of `docs/HANDOVER.md` and the two decision records still needs editing by hand. After the patch, the full build ran 233 invocations with no failures (235 before), the pipeline passed, and the handover check passed.

## Alternatives considered

- **Remove the flag now.** Rejected: it would turn an unaccepted teaching contract into permanent behaviour, and it would remove the one switch that stops new visitor reservations without a new build.
- **Keep the flag with no removal plan.** Rejected: an owner-less flag is a permanent branch. This record names the condition and the prepared change.
- **Replace the flag by configuration that is read at run time.** Not adopted: the flag is already a system property for the demonstration program, and a configuration service is outside the scope of the module.

## Consequences

Both paths stay tested: the acceptance example AC12 (disabled flag), the flag-off journey and the gate's disabled tests remain. A visitor can never be served unless someone sets the property to `true`. Two small costs remain: every new test that builds a visitor gate must pick a setting, and the documents must keep saying that the default is off.

When the three conditions hold, apply `rehearsal/remove-visitor-flag.patch`. It retires AC12 as written (an application with no register approves no visitor instead), rewrites AC13 and AC16, which name the flag, and updates the requirements table and the handover check. Then edit `README.md`, `docs/HANDOVER.md` and ADR-013 by hand, and record the removal in a new decision record. Rollback after removal is a redelivery of the previous release, not a flag change.

## Validation and review

The rehearsal was run, not reasoned about: 233 invocations, 0 failures. No independent peer review is claimed.

## Revisit triggers

An owner accepts or rejects the visitor variant; a decision to keep visitors enabled is recorded; a second flag is proposed (decide the policy for several flags first); or the flag stays in place for longer than the team's own limit for release toggles.

4. New listings: tests, the rehearsal and the AI activity files

4.1 after/src/test/java/edu/se506/lending/docs/TraceabilityTest.java

package edu.se506.lending.docs;

import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;

/**
 * Keeps docs/REQUIREMENTS-TO-EVIDENCE.md honest. It checks that the names in the table still exist
 * (test methods, production classes) and that every acceptance example has exactly one row. It does
 * not check that a named test is the right evidence for its requirement: a reviewer does that.
 */
class TraceabilityTest {
    private static final Path TABLE = Path.of("docs", "REQUIREMENTS-TO-EVIDENCE.md");
    private static final Path MAIN = Path.of("src", "main", "java");
    private static final Path TEST = Path.of("src", "test", "java");
    private static final Pattern TEST_REFERENCE = Pattern.compile("`([A-Z]\\w+)\\.([a-z]\\w*)`");
    private static final Pattern CLASS_REFERENCE = Pattern.compile("`([A-Z]\\w+)`");
    private static final Pattern ACCEPTANCE_ID = Pattern.compile("^AC\\d+$");
    private static final Pattern ACCEPTANCE_TEST = Pattern.compile("void (ac\\d+_\\w+)\\(");

    /** One table row: its cells, trimmed, without the outer bars. */
    private record Row(List<String> cells) {
        String id() { return cells.get(0); }
        String decidedBy() { return cells.get(2); }
        String evidence() { return cells.get(3); }
    }

    private static List<Row> rows() throws IOException {
        List<Row> rows = new ArrayList<>();
        for (String line : Files.readAllLines(TABLE, StandardCharsets.UTF_8)) {
            if (!line.startsWith("| ") || line.startsWith("| ---") || line.startsWith("| ID ")) continue;
            List<String> cells = new ArrayList<>();
            for (String cell : line.substring(1, line.length() - 1).split("\\|", -1)) cells.add(cell.trim());
            if (cells.size() == 5) rows.add(new Row(cells));   // the gap table has three columns
        }
        return rows;
    }

    private static String sourceOf(Path root, String className) throws IOException {
        try (Stream<Path> files = Files.walk(root)) {
            List<Path> found = files.filter(p -> p.getFileName().toString().equals(className + ".java")).toList();
            assertEquals(1, found.size(), "expected exactly one " + className + ".java under " + root + " but found " + found);
            return Files.readString(found.get(0), StandardCharsets.UTF_8);
        }
    }

    @Test void everyTestNamedInTheTableStillExists() throws IOException {
        List<String> missing = new ArrayList<>();
        int checked = 0;
        for (Row row : rows()) {
            Matcher m = TEST_REFERENCE.matcher(row.evidence());
            assertTrue(m.find(), row.id() + " names no test");
            do {
                checked++;
                String source = sourceOf(TEST, m.group(1));
                if (!Pattern.compile("void " + Pattern.quote(m.group(2)) + "\\(").matcher(source).find()) {
                    missing.add(row.id() + ": " + m.group(1) + "." + m.group(2));
                }
            } while (m.find());
        }
        assertTrue(checked >= 29, "the table should name at least 29 tests, found " + checked);
        assertEquals(List.of(), missing, "tests named in the table that no longer exist");
    }

    @Test void everyClassNamedAsDecidingStillExists() throws IOException {
        List<String> missing = new ArrayList<>();
        for (Row row : rows()) {
            Matcher m = CLASS_REFERENCE.matcher(row.decidedBy());
            assertTrue(m.find(), row.id() + " names no deciding class");
            do {
                try {
                    sourceOf(MAIN, m.group(1));
                } catch (AssertionError gone) {
                    missing.add(row.id() + ": " + m.group(1));
                }
            } while (m.find());
        }
        assertEquals(List.of(), missing, "classes named in the table that no longer exist");
    }

    @Test void everyAcceptanceExampleHasExactlyOneRowAndEveryAcceptanceTestIsInTheTable() throws IOException {
        TreeMap<String, Integer> rowsPerExample = new TreeMap<>();
        TreeSet<String> referenced = new TreeSet<>();
        for (Row row : rows()) {
            if (ACCEPTANCE_ID.matcher(row.id()).matches()) rowsPerExample.merge(row.id(), 1, Integer::sum);
            Matcher m = TEST_REFERENCE.matcher(row.evidence());
            while (m.find()) referenced.add(m.group(2));
        }
        for (int n = 1; n <= 18; n++) {
            assertEquals(1, rowsPerExample.getOrDefault("AC" + n, 0), "rows for AC" + n);
        }
        assertEquals(18, rowsPerExample.size(), "unexpected acceptance ids: " + rowsPerExample.keySet());

        Matcher declared = ACCEPTANCE_TEST.matcher(sourceOf(TEST, "VisitorAcceptanceTest"));
        List<String> unlisted = new ArrayList<>();
        int tests = 0;
        while (declared.find()) {
            tests++;
            if (!referenced.contains(declared.group(1))) unlisted.add(declared.group(1));
        }
        assertEquals(18, tests, "VisitorAcceptanceTest should declare one test per example");
        assertEquals(List.of(), unlisted, "acceptance tests that the table does not mention");
    }
}

4.2 after/src/test/java/edu/se506/lending/integration/DemoRun.java

package edu.se506.lending.integration;

import edu.se506.lending.bootstrap.Chapter13Demo;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * A runnable demonstration for a reader who has no directory service: it starts the controlled local
 * directory stub, writes a small approval register, and runs the Chapter 13 system twice, with
 * visitors disabled and then enabled. It lives with the tests because the stub does. The clock is
 * fixed so that the sample approval is always valid. Run it with scripts/demo.sh.
 */
public final class DemoRun {
    private static final String REGISTER = String.join("\n",
            "borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil",
            "V1\tCAM2\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z", "");

    private DemoRun() { }

    public static void main(String[] args) throws IOException {
        Path register = Files.createTempFile("approvals", ".tsv");
        try (DirectoryStub stub = new DirectoryStub()) {
            Files.writeString(register, REGISTER, StandardCharsets.UTF_8);
            System.setProperty("lending.directory.url", stub.baseUri().toString());
            System.setProperty("lending.approvals.file", register.toString());
            System.setProperty("lending.now", "2026-10-19T12:00:00Z");
            for (String enabled : new String[] {"false", "true"}) {
                System.out.println("== visitors " + (enabled.equals("true") ? "enabled" : "disabled") + " ==");
                System.setProperty("lending.visitors", enabled);
                Chapter13Demo.main(new String[0]);
            }
        } finally {
            Files.deleteIfExists(register);
        }
    }
}

4.3 after/rehearsal/remove-visitor-flag.patch

This is a unified diff against the finished 0.14.0 version. Apply it from inside after with patch -p1 < rehearsal/remove-visitor-flag.patch. It is the rehearsal of ADR-014: it is stored and tested, not applied.

diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -36,3 +36,3 @@
 
-Expected: `BUILD SUCCESS` and `Tests run: 235, Failures: 0, Errors: 0, Skipped: 0`.
+Expected: `BUILD SUCCESS` and `Tests run: 233, Failures: 0, Errors: 0, Skipped: 0`.
 
@@ -44,3 +44,3 @@
 
-Expected: five stages, each ending `PASSED`, then `PIPELINE PASSED`. Stage 2 runs 206 invocations, stage 3 runs 26 and stage 4 runs 3.
+Expected: five stages, each ending `PASSED`, then `PIPELINE PASSED`. Stage 2 runs 205 invocations, stage 3 runs 26 and stage 4 runs 2.
 
diff --git a/docs/REQUIREMENTS-TO-EVIDENCE.md b/docs/REQUIREMENTS-TO-EVIDENCE.md
--- a/docs/REQUIREMENTS-TO-EVIDENCE.md
+++ b/docs/REQUIREMENTS-TO-EVIDENCE.md
@@ -20,4 +20,4 @@
 | C8 | An unreachable or slow directory is a failure, never "not found" | `HttpLegacyDirectoryClient`, `LegacyDirectoryAdapter` | `DirectoryOutageIntegrationTest.aServerErrorIsAnOutageNotANotFoundAndIsNotRetried` `DirectoryOutageIntegrationTest.aStalledBodyIsAFailureWithinTheOverallWait` | 3 |
-| C9 | The visitor behaviour is behind one flag that defaults to off | `VisitorGate`, `Chapter13Demo` | `Chapter13JourneyE2ETest.withTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed` | 4 |
-| C10 | Student and staff behaviour is unchanged | `ReservationCoordinator` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister` | 2 |
+| C9 | An application with no approval register approves no visitor (the flag was removed) | `VisitorGate` | `VisitorGateTest.aGateWithNoRegisterApprovesNoVisitor` | 2 |
+| C10 | Student and staff behaviour is unchanged | `ReservationCoordinator` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedAndNeverReadsTheRegister` | 2 |
 
@@ -38,7 +38,7 @@
 | AC11 | Three retained reservations block a fourth, even after the loans are returned | `ReservationCoordinator`, `VisitorReservationRules` | `VisitorAcceptanceTest.ac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturned` | 2 |
-| AC12 | A disabled flag rejects explicitly without reading the register | `VisitorGate` | `VisitorAcceptanceTest.ac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister` | 2 |
-| AC13 | Student and staff requests are unchanged under either setting | `DirectoryReservationService` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister` | 2 |
+| AC12 | An application with no register approves no visitor | `VisitorGate` | `VisitorAcceptanceTest.ac12_anApplicationWithNoRegisterApprovesNoVisitor` | 2 |
+| AC13 | Student and staff requests are unchanged | `DirectoryReservationService` | `VisitorAcceptanceTest.ac13_studentAndStaffBehaviourIsUnchangedAndNeverReadsTheRegister` | 2 |
 | AC14 | An overlap with an existing reservation is rejected by the existing rule | `ReservationCoordinator` | `VisitorAcceptanceTest.ac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule` | 2 |
 | AC15 | An expected notification failure keeps the reservation | `ReservationCoordinator` | `VisitorAcceptanceTest.ac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace` | 2 |
-| AC16 | A visitor reservation stays usable after the approval expires and the flag is off | `LoanService` | `VisitorAcceptanceTest.ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff` | 2 |
+| AC16 | A visitor reservation stays usable after the approval expires and new requests are not approved | `LoanService` | `VisitorAcceptanceTest.ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndNewRequestsAreNotApproved` | 2 |
 | AC17 | An unknown or inactive borrower is rejected as before | `DirectoryReservationService`, `LegacyDirectoryAdapter` | `VisitorAcceptanceTest.ac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore` | 2 |
diff --git a/scripts/handover-check.sh b/scripts/handover-check.sh
--- a/scripts/handover-check.sh
+++ b/scripts/handover-check.sh
@@ -10,5 +10,5 @@
 
-EXPECT_UNIT=206          # stage 2 of the pipeline
+EXPECT_UNIT=205          # stage 2 of the pipeline
 EXPECT_INTEGRATION=26    # stage 3
-EXPECT_E2E=3             # stage 4
+EXPECT_E2E=2             # stage 4
 EXPECT_TOTAL=$((EXPECT_UNIT + EXPECT_INTEGRATION + EXPECT_E2E))
@@ -42,5 +42,4 @@
 if bash scripts/demo.sh > demo.out 2>&1; then echo "ok   the demonstration exited 0"; else echo "FAIL the demonstration did not exit 0"; failures=$((failures + 1)); fi
-expect demo.out "Visitor V1: Rejected: visitor reservations are not enabled." "the disabled run rejects a visitor"
-expect demo.out "Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22)." "the enabled run confirms an approved visitor"
-expect demo.out "Visitor V2: Rejected: no valid sponsor approval." "the enabled run rejects an unapproved visitor"
+expect demo.out "Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22)." "an approved visitor is confirmed"
+expect demo.out "Visitor V2: Rejected: no valid sponsor approval." "an unapproved visitor is rejected"
 expect demo.out "Outage BROKEN: Unavailable: Borrower directory unavailable" "an outage is reported as unavailable"
diff --git a/src/main/java/edu/se506/lending/application/DirectoryReservationService.java b/src/main/java/edu/se506/lending/application/DirectoryReservationService.java
--- a/src/main/java/edu/se506/lending/application/DirectoryReservationService.java
+++ b/src/main/java/edu/se506/lending/application/DirectoryReservationService.java
@@ -19,3 +19,3 @@
                                        ReservationCoordinator coordinator) {
-        this(directory, coordinator, VisitorGate.disabled());
+        this(directory, coordinator, VisitorGate.noApprovals());
     }
diff --git a/src/main/java/edu/se506/lending/application/VisitorGate.java b/src/main/java/edu/se506/lending/application/VisitorGate.java
--- a/src/main/java/edu/se506/lending/application/VisitorGate.java
+++ b/src/main/java/edu/se506/lending/application/VisitorGate.java
@@ -13,3 +13,3 @@
  * The extra checks for a visiting researcher, kept apart from the count and duration policy: the
- * feature flag, the item allowlist and a matching sponsor approval that is valid at one request-time
+ * item allowlist and a matching sponsor approval that is valid at one request-time
  * instant. It returns a rejection text, or empty when the request may go on to the coordinator.
@@ -19,3 +19,2 @@
 public final class VisitorGate {
-    public static final String DISABLED = "Rejected: visitor reservations are not enabled.";
     public static final String ITEM_NOT_ALLOWED = "Rejected: item not available to visitors.";
@@ -27,15 +26,11 @@
     private final Clock clock;
-    private final boolean enabled;
 
-    public VisitorGate(ApprovalSource source, Clock clock, boolean enabled) {
+    public VisitorGate(ApprovalSource source, Clock clock) {
         this.source = Objects.requireNonNull(source, "source");
         this.clock = Objects.requireNonNull(clock, "clock");
-        this.enabled = enabled;
     }
 
-    /** A gate for applications that have not enabled visitors: the approval source is never read. */
-    public static VisitorGate disabled() {
-        return new VisitorGate((borrowerId, itemId, start, end) -> {
-            throw new IllegalStateException("The approval source must not be read while visitors are disabled.");
-        }, Clock.systemUTC(), false);
+    /** A gate for applications with no approval register: no visitor is ever approved. */
+    public static VisitorGate noApprovals() {
+        return new VisitorGate((borrowerId, itemId, start, end) -> Optional.empty(), Clock.systemUTC());
     }
@@ -43,5 +38,2 @@
     public Optional<String> rejection(String borrowerId, String itemId, LocalDate start, int days) {
-        if (!enabled) {
-            return Optional.of(DISABLED);
-        }
         if (!ALLOWED_ITEMS.contains(itemId)) {
diff --git a/src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.java b/src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.java
--- a/src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.java
+++ b/src/main/java/edu/se506/lending/bootstrap/Chapter13Demo.java
@@ -22,3 +22,3 @@
  * and one feature flag. Settings are system properties so that a test can start it as a separate process:
- * lending.directory.url (required), lending.approvals.file (required), lending.visitors (default false),
+ * lending.directory.url (required), lending.approvals.file (required),
  * lending.now (an ISO instant, default the system clock) and lending.directory.timeout.millis (default 1000).
@@ -29,3 +29,2 @@
         Path approvals = Path.of(required("lending.approvals.file"));
-        boolean visitorsEnabled = Boolean.parseBoolean(System.getProperty("lending.visitors", "false"));
         String now = System.getProperty("lending.now");
@@ -38,3 +37,3 @@
                 directoryUrl, Duration.ofMillis(timeoutMillis), Duration.ofMillis(timeoutMillis)));
-        var gate = new VisitorGate(new FileApprovalSource(approvals), clock, visitorsEnabled);
+        var gate = new VisitorGate(new FileApprovalSource(approvals), clock);
         var service = new DirectoryReservationService(directory, coordinator, gate);
@@ -42,3 +41,2 @@
         LocalDate start = LocalDate.of(2026, 10, 20);
-        System.out.println("Visitors enabled: " + visitorsEnabled);
         attempt("Student B1", service, "B1", "CAM1", start, 2);
diff --git a/src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.java b/src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.java
--- a/src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.java
+++ b/src/test/java/edu/se506/lending/e2e/Chapter13JourneyE2ETest.java
@@ -33,3 +33,3 @@
 
-    private Run run(DirectoryStub stub, Path register, boolean visitors) throws IOException, InterruptedException {
+    private Run run(DirectoryStub stub, Path register) throws IOException, InterruptedException {
         String java = Path.of(System.getProperty("java.home"), "bin",
@@ -40,3 +40,2 @@
                 "-Dlending.approvals.file=" + register,
-                "-Dlending.visitors=" + visitors,
                 "-Dlending.now=2026-10-19T12:00:00Z",
@@ -61,24 +60,7 @@
 
-    @Test void withTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed() throws Exception {
+    @Test void anApprovedVisitorIsServedAndAnUnapprovedOneIsRefused() throws Exception {
         try (var stub = new DirectoryStub()) {
-            Run run = run(stub, register(HEADER, V1_CAM2), false);
+            Run run = run(stub, register(HEADER, V1_CAM2));
             assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
             assertEquals(List.of(
-                    "Visitors enabled: false",
-                    "Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",
-                    "Visitor V1: Rejected: visitor reservations are not enabled.",
-                    "Visitor V2: Rejected: visitor reservations are not enabled.",
-                    "Unknown U9: Rejected: borrower not found or inactive.",
-                    "Outage BROKEN: Unavailable: Borrower directory unavailable",
-                    "Reservations: 1",
-                    "Notices: 1"), run.lines());
-        }
-    }
-
-    @Test void withTheFlagOnAnApprovedVisitorIsServedAndAnUnapprovedOneIsRefused() throws Exception {
-        try (var stub = new DirectoryStub()) {
-            Run run = run(stub, register(HEADER, V1_CAM2), true);
-            assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
-            assertEquals(List.of(
-                    "Visitors enabled: true",
                     "Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",
@@ -95,8 +77,8 @@
         try (var stub = new DirectoryStub()) {
-            Run run = run(stub, register(HEADER, V1_CAM2, "garbage row"), true);
+            Run run = run(stub, register(HEADER, V1_CAM2, "garbage row"));
             assertEquals(0, run.exitCode(), String.join("\n", run.lines()));
-            assertEquals("Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", run.lines().get(1));
-            assertEquals("Visitor V1: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(2));
-            assertEquals("Visitor V2: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(3));
-            assertEquals("Reservations: 1", run.lines().get(6));
+            assertEquals("Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", run.lines().get(0));
+            assertEquals("Visitor V1: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(1));
+            assertEquals("Visitor V2: Approval register unavailable: Row 3 has 1 columns, not 8.", run.lines().get(2));
+            assertEquals("Reservations: 1", run.lines().get(5));
         }
diff --git a/src/test/java/edu/se506/lending/integration/DemoRun.java b/src/test/java/edu/se506/lending/integration/DemoRun.java
--- a/src/test/java/edu/se506/lending/integration/DemoRun.java
+++ b/src/test/java/edu/se506/lending/integration/DemoRun.java
@@ -10,4 +10,3 @@
  * A runnable demonstration for a reader who has no directory service: it starts the controlled local
- * directory stub, writes a small approval register, and runs the Chapter 13 system twice, with
- * visitors disabled and then enabled. It lives with the tests because the stub does. The clock is
+ * directory stub, writes a small approval register, and runs the Chapter 13 system once. It lives with the tests because the stub does. The clock is
  * fixed so that the sample approval is always valid. Run it with scripts/demo.sh.
@@ -28,7 +27,3 @@
             System.setProperty("lending.now", "2026-10-19T12:00:00Z");
-            for (String enabled : new String[] {"false", "true"}) {
-                System.out.println("== visitors " + (enabled.equals("true") ? "enabled" : "disabled") + " ==");
-                System.setProperty("lending.visitors", enabled);
-                Chapter13Demo.main(new String[0]);
-            }
+            Chapter13Demo.main(new String[0]);
         } finally {
diff --git a/src/test/java/edu/se506/lending/integration/DirectoryOutageIntegrationTest.java b/src/test/java/edu/se506/lending/integration/DirectoryOutageIntegrationTest.java
--- a/src/test/java/edu/se506/lending/integration/DirectoryOutageIntegrationTest.java
+++ b/src/test/java/edu/se506/lending/integration/DirectoryOutageIntegrationTest.java
@@ -65,3 +65,3 @@
     @Test void anOrdinaryStudentRequestWorksThroughTheRealHttpClient() {
-        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.noApprovals());
         assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).", service.reserve("B1", "CAM1", START, 2));
@@ -71,3 +71,3 @@
     @Test void aMissingRowIsAnOrdinaryRejection() {
-        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.noApprovals());
         assertEquals("Rejected: borrower not found or inactive.", service.reserve("NOBODY", "CAM1", START, 2));
@@ -77,3 +77,3 @@
     @Test void aServerErrorIsAnOutageNotANotFoundAndIsNotRetried() {
-        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.noApprovals());
         DirectoryException failure = assertThrows(DirectoryException.class, () -> service.reserve("BROKEN", "CAM1", START, 2));
@@ -85,3 +85,3 @@
     @Test void aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest() {
-        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.noApprovals());
         long begin = System.nanoTime();
@@ -96,3 +96,3 @@
     @Test void aStalledBodyIsAFailureWithinTheOverallWait() {
-        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofMillis(300), VisitorGate.noApprovals());
         // The headers and the first byte arrive at once and the rest is held. A client whose timeout covers
@@ -112,3 +112,3 @@
         }
-        var service = service(URI.create("http://127.0.0.1:" + closedPort), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(URI.create("http://127.0.0.1:" + closedPort), Duration.ofSeconds(2), VisitorGate.noApprovals());
         assertThrows(DirectoryException.class, () -> service.reserve("B1", "CAM1", START, 2));
@@ -118,3 +118,3 @@
     @Test void aMalformedAnswerIsAnOutageNotAGuess() {
-        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.noApprovals());
         assertThrows(DirectoryException.class, () -> service.reserve("GARBAGE", "CAM1", START, 2));
@@ -124,3 +124,3 @@
     @Test void anUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent() {
-        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.disabled());
+        var service = service(stub.baseUri(), Duration.ofSeconds(2), VisitorGate.noApprovals());
         DirectoryException failure = assertThrows(DirectoryException.class, () -> service.reserve("ODD", "CAM1", START, 2));
@@ -132,3 +132,3 @@
         Path missingRegister = dir.resolve("never-read.tsv");
-        var gate = new VisitorGate(new FileApprovalSource(missingRegister), Clock.systemUTC(), true);
+        var gate = new VisitorGate(new FileApprovalSource(missingRegister), Clock.systemUTC());
         var service = service(stub.baseUri(), Duration.ofSeconds(2), gate);
@@ -143,3 +143,3 @@
         Clock clock = Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"), ZoneOffset.UTC);
-        var gate = new VisitorGate(new FileApprovalSource(register), clock, true);
+        var gate = new VisitorGate(new FileApprovalSource(register), clock);
         var service = service(stub.baseUri(), Duration.ofSeconds(2), gate);
diff --git a/src/test/java/edu/se506/lending/visitor/VisitorAcceptanceTest.java b/src/test/java/edu/se506/lending/visitor/VisitorAcceptanceTest.java
--- a/src/test/java/edu/se506/lending/visitor/VisitorAcceptanceTest.java
+++ b/src/test/java/edu/se506/lending/visitor/VisitorAcceptanceTest.java
@@ -58,3 +58,3 @@
 
-        World(boolean enabled, Instant now, edu.se506.lending.application.port.NotificationSender notifier) {
+        World(Instant now, edu.se506.lending.application.port.NotificationSender notifier) {
             coordinator = ReservationApplication.create(notifier);
@@ -69,3 +69,3 @@
                 return approvals.stream().filter(r -> r.matches(borrower, item, start, end)).findFirst();
-            }, Clock.fixed(now, ZoneOffset.UTC), enabled);
+            }, Clock.fixed(now, ZoneOffset.UTC));
             service = new DirectoryReservationService(directory, coordinator, gate);
@@ -73,3 +73,3 @@
 
-        World(boolean enabled, Instant now) { this(enabled, now, new RecordingNotificationSender()); }
+        World(Instant now) { this(now, new RecordingNotificationSender()); }
 
@@ -88,3 +88,3 @@
         var recorder = new RecordingNotificationSender();
-        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 2);
+        var w = new World(NOW, recorder).approve("V1", "CAM1", START, 2);
         assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
@@ -95,3 +95,3 @@
     @Test void ac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowed() {
-        var w = new World(true, NOW).approve("V1", "CAM2", START, 3);
+        var w = new World(NOW).approve("V1", "CAM2", START, 3);
         assertEquals("Confirmed: R1 for V1 on CAM2 [2026-10-20, 2026-10-23).", w.service.reserve("V1", "CAM2", START, 3));
@@ -101,3 +101,3 @@
         var recorder = new RecordingNotificationSender();
-        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 4);
+        var w = new World(NOW, recorder).approve("V1", "CAM1", START, 4);
         assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START, 4));
@@ -109,3 +109,3 @@
         var recorder = new RecordingNotificationSender();
-        var w = new World(true, NOW, recorder).approve("V1", "CAM1", START, 2);
+        var w = new World(NOW, recorder).approve("V1", "CAM1", START, 2);
         assertEquals(LIMIT, w.service.reserve("V1", "CAM1", START, 0));
@@ -118,3 +118,3 @@
     @Test void ac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecord() {
-        var w = new World(true, NOW).approve("V1", "MIC1", START, 2);
+        var w = new World(NOW).approve("V1", "MIC1", START, 2);
         assertEquals(VisitorGate.ITEM_NOT_ALLOWED, w.service.reserve("V1", "MIC1", START, 2));
@@ -124,6 +124,6 @@
     @Test void ac6_aMissingOrDeniedApprovalIsAnExpectedRejection() {
-        var missing = new World(true, NOW);
+        var missing = new World(NOW);
         assertEquals(VisitorGate.NOT_APPROVED, missing.service.reserve("V1", "CAM1", START, 2));
         assertNothingChanged(missing);
-        var denied = new World(true, NOW);
+        var denied = new World(NOW);
         denied.approvals.add(new ApprovalRecord("V1", "CAM1", START, START.plusDays(2), "S1",
@@ -135,3 +135,3 @@
     @Test void ac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise() {
-        var w = new World(true, NOW).approve("V2", "CAM1", START, 2).approve("V1", "CAM2", START, 2)
+        var w = new World(NOW).approve("V2", "CAM1", START, 2).approve("V1", "CAM2", START, 2)
                 .approve("V1", "CAM1", START, 3);
@@ -142,3 +142,3 @@
     @Test void ac8_theRequestInstantEqualToValidFromIsValid() {
-        var w = new World(true, FROM).approve("V1", "CAM1", START, 2);
+        var w = new World(FROM).approve("V1", "CAM1", START, 2);
         assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
@@ -147,5 +147,5 @@
     @Test void ac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejected() {
-        var atExpiry = new World(true, UNTIL).approve("V1", "CAM1", START, 2);
+        var atExpiry = new World(UNTIL).approve("V1", "CAM1", START, 2);
         assertEquals(VisitorGate.NOT_APPROVED, atExpiry.service.reserve("V1", "CAM1", START, 2));
-        var early = new World(true, FROM.minusSeconds(1)).approve("V1", "CAM1", START, 2);
+        var early = new World(FROM.minusSeconds(1)).approve("V1", "CAM1", START, 2);
         assertEquals(VisitorGate.NOT_APPROVED, early.service.reserve("V1", "CAM1", START, 2));
@@ -159,3 +159,3 @@
         ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
-        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
+        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC));
         var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), coordinator, gate);
@@ -170,3 +170,3 @@
         ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
-        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
+        var gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC));
         var service = new DirectoryReservationService(id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), coordinator, gate);
@@ -182,3 +182,3 @@
         Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
-        var w = new World(true, NOW);
+        var w = new World(NOW);
         var loans = new LoanService(w.coordinator, checkoutDay);
@@ -195,20 +195,14 @@
 
-    @Test void ac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister() {
-        var w = new World(false, NOW).approve("V1", "CAM1", START, 2);
-        assertEquals(VisitorGate.DISABLED, w.service.reserve("V1", "CAM1", START, 2));
-        assertEquals(0, w.approvalReads.get());
-        assertNothingChanged(w);
+    @Test void ac12_anApplicationWithNoRegisterApprovesNoVisitor() {
         var defaultService = new DirectoryReservationService(
                 id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), ReservationApplication.create(new RecordingNotificationSender()));
-        assertEquals(VisitorGate.DISABLED, defaultService.reserve("V1", "CAM1", START, 2));
+        assertEquals(VisitorGate.NOT_APPROVED, defaultService.reserve("V1", "CAM1", START, 2));
     }
 
-    @Test void ac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister() {
-        for (boolean enabled : new boolean[] {false, true}) {
-            var w = new World(enabled, NOW);
-            assertEquals("Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-10-27).", w.service.reserve("S1", "CAM1", START, 7));
-            assertEquals("Confirmed: R2 for T1 on MIC1 [2026-10-20, 2026-11-03).", w.service.reserve("T1", "MIC1", START, 14));
-            assertEquals("Rejected: borrower limit or duration.", w.service.reserve("S1", "CAM2", START, 8));
-            assertEquals(0, w.approvalReads.get());
-        }
+    @Test void ac13_studentAndStaffBehaviourIsUnchangedAndNeverReadsTheRegister() {
+        var w = new World(NOW);
+        assertEquals("Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-10-27).", w.service.reserve("S1", "CAM1", START, 7));
+        assertEquals("Confirmed: R2 for T1 on MIC1 [2026-10-20, 2026-11-03).", w.service.reserve("T1", "MIC1", START, 14));
+        assertEquals("Rejected: borrower limit or duration.", w.service.reserve("S1", "CAM2", START, 8));
+        assertEquals(0, w.approvalReads.get());
     }
@@ -216,3 +210,3 @@
     @Test void ac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule() {
-        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
+        var w = new World(NOW).approve("V1", "CAM1", START, 2);
         assertTrue(w.coordinator.reserve("S1", "CAM1", START, 2).startsWith("Confirmed: R1"));
@@ -223,3 +217,3 @@
     @Test void ac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace() {
-        var w = new World(true, NOW, reservation -> { throw new NotificationException("outbox down"); })
+        var w = new World(NOW, reservation -> { throw new NotificationException("outbox down"); })
                 .approve("V1", "CAM1", START, 2);
@@ -229,9 +223,9 @@
 
-    @Test void ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff() {
+    @Test void ac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndNewRequestsAreNotApproved() {
         Clock checkoutDay = Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"), ZoneOffset.UTC);
-        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
+        var w = new World(NOW).approve("V1", "CAM1", START, 2);
         assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
         var afterwards = new DirectoryReservationService(
-                id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), w.coordinator, VisitorGate.disabled());
-        assertEquals(VisitorGate.DISABLED, afterwards.reserve("V1", "CAM2", START, 1));
+                id -> Optional.of(new Borrower(id, BorrowerCategory.VISITOR)), w.coordinator, VisitorGate.noApprovals());
+        assertEquals(VisitorGate.NOT_APPROVED, afterwards.reserve("V1", "CAM2", START, 1));
         var loans = new LoanService(w.coordinator, checkoutDay);
@@ -242,3 +236,3 @@
     @Test void ac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore() {
-        var w = new World(true, NOW).approve("U9", "CAM1", START, 2);
+        var w = new World(NOW).approve("U9", "CAM1", START, 2);
         assertEquals("Rejected: borrower not found or inactive.", w.service.reserve("U9", "CAM1", START, 2));
@@ -251,3 +245,3 @@
         Clock reportDay = Clock.fixed(Instant.parse("2026-10-22T09:00:00Z"), ZoneOffset.UTC);
-        var w = new World(true, NOW).approve("V1", "CAM1", START, 2);
+        var w = new World(NOW).approve("V1", "CAM1", START, 2);
         assertEquals(SUCCESS_V1_CAM1, w.service.reserve("V1", "CAM1", START, 2));
diff --git a/src/test/java/edu/se506/lending/visitor/VisitorGateTest.java b/src/test/java/edu/se506/lending/visitor/VisitorGateTest.java
--- a/src/test/java/edu/se506/lending/visitor/VisitorGateTest.java
+++ b/src/test/java/edu/se506/lending/visitor/VisitorGateTest.java
@@ -38,4 +38,4 @@
 
-    private VisitorGate gate(ApprovalRecord record, Instant now, boolean enabled) {
-        return new VisitorGate(sourceOf(record), Clock.fixed(now, ZoneOffset.UTC), enabled);
+    private VisitorGate gate(ApprovalRecord record, Instant now) {
+        return new VisitorGate(sourceOf(record), Clock.fixed(now, ZoneOffset.UTC));
     }
@@ -43,3 +43,3 @@
     @Test void aMatchingApprovedRecordInsideItsWindowLetsTheRequestContinue() {
-        assertEquals(Optional.empty(), gate(record(ApprovalStatus.APPROVED), NOW, true).rejection("V1", "CAM1", START, 2));
+        assertEquals(Optional.empty(), gate(record(ApprovalStatus.APPROVED), NOW).rejection("V1", "CAM1", START, 2));
         assertEquals(1, reads.get());
@@ -47,11 +47,5 @@
 
-    @Test void aDisabledGateRejectsWithoutReadingTheSource() {
-        assertEquals(Optional.of(VisitorGate.DISABLED),
-                gate(record(ApprovalStatus.APPROVED), NOW, false).rejection("V1", "CAM1", START, 2));
-        assertEquals(0, reads.get());
-    }
-
     @Test void anItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource() {
         assertEquals(Optional.of(VisitorGate.ITEM_NOT_ALLOWED),
-                gate(record(ApprovalStatus.APPROVED), NOW, true).rejection("V1", "MIC1", START, 2));
+                gate(record(ApprovalStatus.APPROVED), NOW).rejection("V1", "MIC1", START, 2));
         assertEquals(0, reads.get());
@@ -60,3 +54,3 @@
     @Test void aNonPositiveDurationIsLeftToThePolicyWithoutReadingTheSource() {
-        VisitorGate gate = gate(record(ApprovalStatus.APPROVED), NOW, true);
+        VisitorGate gate = gate(record(ApprovalStatus.APPROVED), NOW);
         assertEquals(Optional.empty(), gate.rejection("V1", "CAM1", START, 0));
@@ -67,5 +61,5 @@
     @Test void aMissingOrDeniedRecordIsAnExpectedRejection() {
-        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(null, NOW, true).rejection("V1", "CAM1", START, 2));
+        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(null, NOW).rejection("V1", "CAM1", START, 2));
         assertEquals(Optional.of(VisitorGate.NOT_APPROVED),
-                gate(record(ApprovalStatus.DENIED), NOW, true).rejection("V1", "CAM1", START, 2));
+                gate(record(ApprovalStatus.DENIED), NOW).rejection("V1", "CAM1", START, 2));
     }
@@ -74,6 +68,6 @@
         ApprovalRecord approved = record(ApprovalStatus.APPROVED);
-        assertEquals(Optional.empty(), gate(approved, FROM, true).rejection("V1", "CAM1", START, 2));
-        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(approved, UNTIL, true).rejection("V1", "CAM1", START, 2));
+        assertEquals(Optional.empty(), gate(approved, FROM).rejection("V1", "CAM1", START, 2));
+        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(approved, UNTIL).rejection("V1", "CAM1", START, 2));
         assertEquals(Optional.of(VisitorGate.NOT_APPROVED),
-                gate(approved, FROM.minusSeconds(1), true).rejection("V1", "CAM1", START, 2));
+                gate(approved, FROM.minusSeconds(1)).rejection("V1", "CAM1", START, 2));
     }
@@ -83,3 +77,3 @@
                 ApprovalStatus.APPROVED, FROM, UNTIL);
-        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(other, NOW, true).rejection("V1", "CAM1", START, 2));
+        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), gate(other, NOW).rejection("V1", "CAM1", START, 2));
     }
@@ -88,3 +82,3 @@
         ApprovalSourceException failure = new ApprovalSourceException("register unreadable");
-        VisitorGate gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC), true);
+        VisitorGate gate = new VisitorGate((b, i, s, e) -> { throw failure; }, Clock.fixed(NOW, ZoneOffset.UTC));
         assertSame(failure, assertThrows(ApprovalSourceException.class, () -> gate.rejection("V1", "CAM1", START, 2)));
@@ -99,3 +93,3 @@
         };
-        new VisitorGate(sourceOf(record(ApprovalStatus.APPROVED)), counting, true).rejection("V1", "CAM1", START, 2);
+        new VisitorGate(sourceOf(record(ApprovalStatus.APPROVED)), counting).rejection("V1", "CAM1", START, 2);
         assertEquals(1, instants.get());
@@ -103,4 +97,4 @@
 
-    @Test void theDisabledFactoryNeverReadsASource() {
-        assertEquals(Optional.of(VisitorGate.DISABLED), VisitorGate.disabled().rejection("V1", "CAM1", START, 2));
+    @Test void aGateWithNoRegisterApprovesNoVisitor() {
+        assertEquals(Optional.of(VisitorGate.NOT_APPROVED), VisitorGate.noApprovals().rejection("V1", "CAM1", START, 2));
     }

4.4 after/ai-activity/README-draft.md

<!-- Author-written fictional AI output, constructed for offline review (it is not a recorded service response). -->
# Campus Equipment Lending System

A Java 11 application for reserving campus equipment, with support for students, staff and visiting researchers.

## Quick start

1. Build: `mvn package`
2. Run: `java -jar target/equipment-lending.jar`
3. Check everything: `mvn verify` runs all 230 tests, which pass in under 30 seconds.

## Features

- Visiting researchers are enabled by default. Set `-Dlending.visitors=false` to turn them off.
- Visitors may reserve CAM1 or CAM2 for one to three days when a sponsor approval matches.
- Sponsor approvals are read from a CSV file. The file is cached in memory for speed.
- If the borrower directory does not answer within one second, the system retries three times and then treats the borrower as a student.
- `scripts/pipeline.sh` runs five stages and deploys the application to the staging server.
- Architecture rules are enforced by `DependencyRulesTest`.
- The system is production-ready and safe for concurrent use.

## Demonstration

Run `bash scripts/demo.sh` to see visitors rejected and then approved.

4.5 after/ai-activity/README-corrected.md

# Campus Equipment Lending System

A Java 17 teaching application for reserving campus equipment, with support for students, staff and visiting researchers. It is an in-memory system: there is no real borrower directory and no real sponsor-approval service.

## Quick start

1. Check the tools: `java -version` (Java 17 or later) and `mvn -version` (Maven 3.9.x).
2. Build and test: `mvn --batch-mode verify`. The expected result and the number of tests are in `docs/HANDOVER.md`.
3. See it work: `bash scripts/demo.sh`.
4. Run the smallest program: `java -cp target/classes edu.se506.lending.bootstrap.ReservationApplication`. It confirms one reservation for borrower B1. The jar built by `mvn package` has no main class, so it cannot be started with `java -jar`.

## What it does

- Visiting researchers are **disabled by default**. The demonstration program enables them with `-Dlending.visitors=true`.
- A visitor may reserve CAM1 or CAM2 for one to three days when a sponsor approval matches the borrower, the item and the exact period and is valid at the request time.
- Sponsor approvals are read from a tab-separated file with a fixed header and eight columns. The file is read again on every request.
- The borrower directory is asked once, with a default wait of one second (`-Dlending.directory.timeout.millis`). There is no retry and no fallback. If it cannot answer, the request fails and is reported as unavailable, never as "not found" and never as a student.
- `scripts/pipeline.sh` runs five stages (compile, unit tests and architecture checks, integration tests, end-to-end journeys, package). It deploys nothing. `scripts/simulate-release.sh` simulates delivery into a local folder.
- `DependencyRulesTest` checks that package dependencies point the right way and that there are no package cycles.

## Limits

No Java 17 run and no hosted CI run has been made. The directory and the approval file are stand-ins. Concurrent use was not considered. See `docs/KNOWN-ISSUES.md`.

5. Worked explanation and evidence

5.1 The change footprint

The figures of Section 14.1 come from comparing the Chapter 11 and the Chapter 13 versions with diff -rq, excluding build output, and counting lines.

GroupFilesLines
New production files9399 (63 + 15 + 9 + 73 + 53 + 6 + 16 + 92 + 72)
Existing production files changed521 added, 3 removed
New test files10992
Existing test files changed310 added, 4 removed
New scripts, workflow and record4213
README and POM changed214 added, 8 removed

The five existing production files are BorrowerCategory (+1 −1), DirectoryReservationService (+14), ReservationApplication (+3 −1), LegacyDirectoryAdapter (+1) and LegacyDirectoryClient (+2 −1). A changed line counts as one added and one removed.

5.2 Three experiments on the same change

Each was run in its own scratch copy.

ExperimentStarting pointEditResult
E1Chapter 11 version (159 invocations)Add VISITOR to the enum only45 of 159 fail (1 failure, 44 errors). 89 lines of output say Missing policy: VISITOR; the one failure is RepositoryBoundaryTest.coordinatorRequiresARepository, which expects a NullPointerException and gets an IllegalArgumentException
E2The sameThe enum, VisitorReservationRules and the composition root5 of 159 fail, in the three files that build their own policy map: LegacyRegressionTest.chapter06, BehaviourStrategyTest.injectedPolicyChangesDecisionWithoutChangingCoordinator, and three tests in RepositoryBoundaryTest. With the four map entries added, all 159 pass
E3Chapter 13 version (232 invocations)LegacyDirectoryAdapter maps kind V to BorrowerCategory.STAFF; nothing else4 of 232 fail: the three end-to-end journeys and DirectoryOutageIntegrationTest.aVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile. All 203 unit invocations pass. With the flag off, visitor V1 is confirmed; V2 is rejected only because CAM2 is already taken, with Rejected: item unavailable., not because it is unapproved
B1Chapter 14 version (235 invocations)Remove the visitor policy from ReservationApplication.create73 of 235 fail (3 failures, 70 errors) in 14 test classes; 146 lines of output say Missing policy: VISITOR (each failure is reported more than once). The classes: VisitorAcceptanceTest 19, DirectoryOutageIntegrationTest 10, LoanServiceTest 9, AvailabilityTest 8, ReserveEquipmentCommandTest 4, LegacyRegressionTest 4, DirectoryAdapterTest 4, ReservationConsoleTest 3, NotificationPublisherTest 3, NotificationMockitoTest 3, Chapter13JourneyE2ETest 3, RepositoryBoundaryTest 1, OverdueBridgeTest 1, AvailabilityTddTest 1

What E1 and E2 show is how far the fail-fast check reaches. Everything that is constructed through the composition root fails together when the policy is missing there, which is one fix; the tests that spell out their own map fail separately, which is four fixes. What E3 shows is that this suite has a gap, and that a smaller change can pass a whole stage and fail the next one. Notice what it does not show: that unit tests cannot see such a change. No unit test checks the adapter’s mapping of the visitor code, so nothing in the unit stage goes through it. A focused adapter test would catch the change, and Section 6.2 (B8) says what it should check. The experiment found the hole by trying a change that goes through it, and Chapter 13’s own tests do not close it: the exercise B8 asks the reader to.

5.3 Revised assumptions: notes for the table in Section 14.1

  • Policy answers a count and duration question only. Held: VisitorReservationRules is 16 lines and BorrowingPolicy was not widened. The approval needs identity and time, which a policy does not have, so the request-specific decision is in VisitorGate.
  • Every category has a policy in one place. Held in production (one map in the composition root). Four test constructions also spelled out the map (Chapter 13 companion, Section 4.7).
  • The directory is a synthetic in-process provider with no transport (Chapter 8). Chapter 8 already defined the provider’s Failure, translated it to a DirectoryException and told an outage from an absence. Chapter 13 added the real HTTP client and the cases a transport brings, including a timeout that covers the whole answer, the body included. The contract held and the behaviour behind it grew.
  • Retained reservations are the count. Open, SD-12, pinned by AC11.
  • The report knows two kinds. Open, SD-11-1, shown by AC18.
  • One attempt, no retry. Held for notices (Chapter 6) and extended to the directory.

5.4 The constructed review of the visitor change

This review is author-written and fictional, constructed for this chapter. No real peer review of the project has taken place. The point of the exercise is to check each comment against the code instead of accepting or rejecting it by tone.

#CommentCheckVerdict
R1“DirectoryReservationService now branches on VISITOR. Will that grow?”Read the class: one if on the category, delegating to the gate.Valid. Recorded as KI-14 with its trigger (a second special category). No code change
R2“Where is the flag read in production? I cannot find a global.”Search for lending.visitors: only Chapter13Demo reads it, and passes a boolean to VisitorGate. There is no static state.No issue
R3“Is the old two-argument service constructor still safe for visitors?”ac12_... builds a service with the two-argument constructor and asserts the disabled rejection.Covered
R4“The README says 230 tests. I count 233 after this change.”The count appeared in both README.md and HANDOVER.md.Valid. A number stated in two places rots. The README now states no number, and scripts/handover-check.sh checks the numbers in HANDOVER.md

R4 is the useful one: it led to a change in the version, and the change has a check.

5.5 The dry run, in order

The handover was followed in a fresh folder, as a reader would, before the fixes were made:

  1. Step “check the tools”: mvn -version was not found on the machine’s path. The scripts printed mvn: command not found. Finding D1. Fix: the document starts with a tool check and says what to do; the scripts print a clear message and exit with status 2.
  2. Step “build”: Maven 3.9.11 printed WARNING lines about sun.misc.Unsafe and the mocking library printed a dynamic-agent warning, on a recent JDK. The build ended with BUILD SUCCESS. Finding D2. Fix: the document lists them as things that are not errors.
  3. Step “see it work”: there was no way to run the demonstration program without a directory service. Finding D3 (KI-1). Fix: DemoRun and scripts/demo.sh.
  4. The new clear message was then run on the machine’s real setup, with the Maven command given as a full path to a .cmd file. It rejected the valid path, because command -v does not accept a file that is not marked executable in the shell. Finding D4. Fix: also accept a file that exists. The handover check found this, not a reader.
  5. The project was copied into a folder whose name contains spaces and run again. Nothing broke.

5.6 The rehearsal of the flag removal

The rehearsal was produced by a script in a scratch copy of the 0.14.0 version, and then written out as rehearsal/remove-visitor-flag.patch. It was verified in three ways: the patch applies to a fresh copy and gives a tree identical to the scratch copy; the full build of that tree passed; and scripts/handover-check.sh passed in it.

FileChange
VisitorGate.javaRemoves DISABLED, the enabled field and parameter and the disabled() factory; adds noApprovals() (a gate whose source never has a record)
DirectoryReservationService.javaThe two-argument constructor uses VisitorGate.noApprovals()
Chapter13Demo.javaNo lending.visitors property and no Visitors enabled line
VisitorGateTest.javaDrops the flag argument; removes the two disabled tests; adds aGateWithNoRegisterApprovesNoVisitor
VisitorAcceptanceTest.javaWorld has no flag; AC12 becomes “no register approves no visitor”; AC13 and AC16 are renamed
Chapter13JourneyE2ETest.javaRemoves the flag-off journey, so two journeys remain
DirectoryOutageIntegrationTest.javadisabled() becomes noApprovals(); constructor calls lose the flag
DemoRun.javaRuns once
REQUIREMENTS-TO-EVIDENCE.mdC9, C10, AC12, AC13 and AC16 point at the new names
HANDOVER.md, handover-check.shThe expected counts: 233 in total, 205 in stage 2, 26 in stage 3, 2 in stage 4

Eleven files; 87 lines added and 131 removed. The prose of README.md, the rest of HANDOVER.md and ADR-013 is not edited by the patch, and it still says that the flag exists.

Two things worth noticing. TraceabilityTest is why the table edits are in the patch: without them, the build fails after the patch on the first stale test name, which is the check doing its job. And the specification changed, not just the code: AC12 as written (a disabled flag rejects explicitly) has no meaning without a flag.

5.7 The contribution example

The numbers are synthetic. P1: 38 commits (17 formatting only), 410 lines changed, 4 reviews given (1 requested changes). P2: 9 commits, 1,150 lines, 11 reviews (5 requested changes). P3: 24 commits (3 formatting only), 620 lines, 7 reviews (0 requested changes). The team total is 71 commits and 2,180 changed lines.

MeasureOrderShare of the team total
CommitsP1, P3, P2P1 38 of 71 (more than half)
Lines changedP2, P3, P1P1 410 of 2,180 (19%)
Reviews givenP2, P3, P1
Reviews that requested changesP2, P1, P3

The four orders are three different orders. The sentences about what the people did (a pipeline, an adapter, a demonstration and documents, four pair sessions of which two were recorded) are also synthetic. They are included to show what a count leaves out. Nothing in this example justifies a ranking.

6. Suggested practical solution

6.1 the book’s project, steps B1–B4

B1. Predict and measure what breaks. A good answer predicts before running: removing the one map entry in the composition root breaks everything built through it, which is most of the suite (the experiment gave 73 of 235, in 14 classes), and every message names the missing policy. It says what the result shows: the design fails fast and precisely, and the cost is breadth. It says what the result does not show: that the code is correct. Restore the file and rerun.

B2. Triage. A good answer applies the three questions to each item and gives a reason, for example:

ItemDecisionReason
KI-2 (SD-11-1, visitor late-fine rate)DeferNeeds the records office; any rate would be a guess
KI-7 (whole-file rejection of the register)AcceptA silent skip could hide a lost approval or a duplicate; revisit with real use
KI-8 (one-second wait, not measured)AcceptIt is a setting; measure before changing
KI-12 (no Java 17 or hosted CI run)DeferNeeds a JDK 17 and a hosted runner
KI-14 (the VISITOR branch)AcceptFine for one category; refactor at the second

A good new item comes from reading the project, not from the list: for example, that DemoRun fixes the clock, so the demonstration cannot show an approval that expires (a limitation of the demonstration, accepted), or that nothing checks the README’s description of the architecture packages.

B3. Follow the handover. A good answer lists each place the reader guessed. Likely ones: where the project directory is, how to set CHAPTER_MVN, what the warnings mean, and how long the build takes. It also notes what did not need a guess.

B4. Trace and break a row. A good answer opens the test for each of its three rows and says whether it checks the claim. Example: C5 (valid at request time, start inclusive and end exclusive) is checked by VisitorGateTest.theWindowIncludesItsStartAndExcludesItsEnd, which tests exactly the two edges and one instant before the start. After renaming a named test, TraceabilityTest fails with the row’s ID and the missing name. What it cannot catch: a row that names a different but existing test (Section 10.2, fault 6).

6.2 the book’s project, optional steps B5–B7

B5. Rehearse the removal. Apply the patch from inside a scratch copy of after: patch -p1 < rehearsal/remove-visitor-flag.patch. Run mvn --batch-mode verify (233 invocations) and bash scripts/handover-check.sh (passes). A good answer lists what still says the flag exists: README.md (the paragraph about the flag), docs/HANDOVER.md section 6 (the visitor flag), docs/ADR-013-visiting-researchers.md (the flag removal plan and the rollback), docs/KNOWN-ISSUES.md (KI-13) and docs/REQUIREMENTS-TO-EVIDENCE.md (the descriptions are updated; the contract text in Chapter 12 is not).

B6. AI activity. See Section 7.

B7. Read a synthetic contribution table. A good answer asks questions, for example “What are the seventeen formatting-only commits, and were they needed?” (read the commit list), “What did P2’s two review findings say?” (read the review comments), “Which of P3’s pair sessions were with P1, and what was produced?” (read the pull requests and the retrospective notes). It does not conclude that P1 contributed most because of 38 commits, and it does not conclude that P2 contributed least because of 9.

B8. Close the gap that E3 found. A good answer adds a unit test class for LegacyDirectoryAdapter that uses an in-memory client (no network) and checks at least: a row with kind V and status Y maps to BorrowerCategory.VISITOR (and not to staff or student); a row with kind V and status N gives an empty result; and an unknown kind code throws DirectoryException and is never defaulted. Then it applies the one-line change of Section 14.1 and shows that the unit stage now fails. A weak answer adds a test that passes against the changed adapter too, for example one that only checks that a visitor is found. The new unit tests also raise the stage-2 count, so scripts/handover-check.sh and docs/HANDOVER.md need their numbers updated, which is the handover check doing its job.

6.3 your team’s project, steps T1–T5

These are not model answers, because the code is your team’s. A good answer shows:

  • T1: the four questions answered with real evidence: the list of files changed and what each does; the tests that broke when the change started, and what that says; two or three assumptions the change overturned, with the chapter or document where they were written; actual review comments with responses. It names at least one place where the design bent.
  • T2: a known-issues list in which every item has a decision, a reason, and (for a deferred item) an owner and a trigger; no feature added; a freeze time.
  • T3: the bundle (setup, usage example, limitations, test instructions, requirements-to-evidence table, demonstration outline); a dry run in a fresh copy; a record of what a person outside the team struggled with, and what was changed; at least one statement in the bundle that a script checks.
  • T4: two decision records with a true status, one about the flag or the change (the conditions for removal checked one by one, and a decision) and one other; the old record untouched if the new one applies or supersedes it.
  • T5: at most three retrospective actions with owners, each tied to evidence; an individual reflection of claims with links and honest limits; the checklist of Section 14.7 with each item marked complete or a gap.

A weak answer describes the code instead of the change, lists defects with no decision, writes documents nobody has followed, edits an accepted decision record, ranks people by commit counts or claims a run that did not happen.

7. AI activity solution

The example makes sixteen statements. Each was checked by running the command or reading the code and tests it depends on.

#StatementVerdictEvidence and correction
1A Java 11 applicationContradictedmaven.compiler.release is 17, and javac --release 11 on the main sources fails at behaviour/LifecycleExamples.java: switch rules are not supported in -source 11. A Java 17 application
2Supports students, staff and visiting researchersSupportedBorrowerCategory, ADR-013
3Build: mvn packageSupportedProduces target/equipment-lending-0.14.0.jar (it also runs the tests unless they are skipped)
4Run: java -jar target/equipment-lending.jarContradictedjava -jar target/equipment-lending.jar prints Error: Unable to access jarfile target/equipment-lending.jar (the file name includes the version). With the right name, java -jar target/equipment-lending-0.14.0.jar prints no main manifest attribute, in target\equipment-lending-0.14.0.jar. The POM has no main class. Run the program with java -cp target/classes edu.se506.lending.bootstrap.ReservationApplication, which prints a notice and Result: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
5mvn verify runs all 230 testsContradicted233
6…which pass in under 30 secondsNot establishedA full mvn verify took about 21 seconds on the verification machine with the dependencies already cached. That is one run on one machine; do not promise a time
7Visitors are enabled by default; -Dlending.visitors=false turns them offContradictedThe default is false: Boolean.parseBoolean(System.getProperty("lending.visitors", "false")). The first run of scripts/demo.sh prints Visitors enabled: false. Correct: disabled by default; =true enables
8A visitor may reserve CAM1 or CAM2 for one to three days when a sponsor approval matchesSupportedC2, C3, C4 of the requirements table
9Approvals are read from a CSV fileContradictedTab-separated with a fixed header and eight columns (FileApprovalSource.HEADER)
10The file is cached in memory for speedContradictedtheFileIsReadAfreshOnEveryCall: a changed register is seen at once
11If the directory does not answer within one second, the system retries three times and then treats the borrower as a studentContradicted (the one-second default is correct)One attempt, no retry, no fallback: aServerErrorIsAnOutageNotANotFoundAndIsNotRetried asserts exactly one request; the request fails with Unavailable. A borrower is never given a category the provider did not state
12scripts/pipeline.sh runs five stages…SupportedCompile, unit tests and architecture checks, integration, end-to-end, package
13…and deploys the application to the staging serverContradictedStage 5 only packages. scripts/simulate-release.sh copies a jar into a local folder and is labelled a simulation. Nothing is deployed
14Architecture rules are enforced by DependencyRulesTestPartly supportedIt checks the direction of dependencies between packages (and a separate test checks for cycles). It does not enforce every architecture rule
15The system is production-ready and safe for concurrent useNot established, and contradicted by the project’s own limitsKI-10 and KI-11: stand-in directory and register; concurrency not considered; an in-memory teaching system
16Run bash scripts/demo.sh to see visitors rejected and then approvedSupportedThe output of Section 1.2

Of sixteen statements, six are supported or partly supported as written, eight are contradicted and two are not established. (Counting statements, not sentences: statements 5 and 6 split one sentence.) The corrected README is ai-activity/README-corrected.md. Each command in it was run, and its claims were checked against the same evidence.

Notice which errors reading alone reveals and which only a run does. A reader who knows the project notices the default of the flag and the CSV. Nobody notices that java -jar fails without running it, because the sentence is exactly what a README usually says. And a draft in which half of the statements are contradicted reads as fluently as one that is right. Notice also that the draft says nothing about limitations, and that the corrected README adds them.

If a live assistant is used, keep its actual output, and check its README the same way. Do not use a generated README to approve generated code.

8. Intentionally flawed alternatives

These fragments are diagnostic alternatives, not files to compile. Each corresponds to a mistake in Section 14.9 of the chapter or to a fault in the experiments of Section 10.2.

8.1 A number stated in two places

README.md:    "The build executes 230 JUnit invocations ..."
HANDOVER.md:  "Expected: Tests run: 233 ..."

One of them is stale the moment a test is added, and a reader cannot tell which. State a number once, and have a script check it.

8.2 A decision record edited after the fact

# ADR-013 ...
Status: Accepted
...
- Introduce one flag ... (edited on 20 October: the flag was removed)

The reasons the team had at the time are gone. Write a new record that applies or supersedes the old one, and leave the old one readable.

8.3 A guess presented as a fix

// WRONG for a handover sprint: nobody knows the visitor rate.
private static final FinePolicy VISITOR = new FinePolicy(2, 0.50, 10.00);

It looks like a repair of SD-11-1. It is a new rule, it hides the question and it changes AC18. Defer it with the question and the owner.

8.4 A ranking by counts

Contribution: P1 54%, P3 34%, P2 13%   (by commits)

Habits are not contributions, and the same team has three other orders by other counts (Section 5.7). Use a count to choose which piece of work to read.

8.5 A claim that did not happen

CI: passing on GitHub Actions

The workflow file has never run. Say workflow file written, not run and keep the local run as the evidence.

9. Review answers and transfer solution

9.1 Concept answers

1. The four questions. Which responsibilities were affected (file and line counts do not show importance); what broke when the change started (shows only what the tests cover); which assumptions turned out wrong (only those that were written down); what a reviewer said (only as good as the review).

2. A one-line change that fails four tests. The change is smaller than the requirement: it does not implement the flag, the register check or the failure behaviour. The unit tests cannot say so, because none of them goes through the directory adapter with a real visitor. The stage that crosses the real boundary does, which is the reason to have it.

3. Deferred and accepted. A deferred issue is waiting for a decision that is not ours, so it carries an owner and a trigger (SD-11-1, the late-fine rate). An accepted issue is a limit we decided to live with, so it carries a reason and a statement (the one-second wait, not measured; no retry).

4. A requirements-to-evidence check. It can confirm that a named test or class exists and that each acceptance example has exactly one row. It cannot confirm that the named test is the right evidence for the requirement. A reviewer does that.

9.2 Applied diagnosis answers

5. 40 commits against 8. Questions: Are most of the 40 formatting, generated or reverted? (read the commit list.) What are the eight? (read the diffs and the pull requests.) Who reviewed whom, and what did the reviews find? (read the review comments.) Was there pair work recorded as one author? (read the commit trailers and the retrospective.) Say nothing about contribution until the artefacts are read.

6. A colleague adds a test. Two things that could notice: scripts/handover-check.sh, which compares the pipeline’s stage counts with what the handover promises (cost: it runs the whole pipeline in a fresh copy, about a minute); and a reviewer reading the pull request who knows the number is stated in the handover (cost: attention, and it relies on memory). The script is cheaper to repeat.

9.3 Transfer solution

For the model-serving team:

ItemContent
README for a strangerWhat the service does; what it needs (language runtime, build tool, a model file or a synthetic stand-in); three commands with their expected output; where the decisions are
Tool checkThe runtime and tool versions with the output they should print; what to do if a tool is missing; the warnings that are not errors
DemonstrationA fixed model identifier and fixed synthetic data, a fixed clock if time matters, and a script that runs the same requests with the gate off and on
Requirements to evidenceEach rule (only approved model versions are served to external callers) to the class that decides it and the test, plus a check that the names exist
Known issuesIncluding what was not evaluated about the model: accuracy, fairness, drift, behaviour on data unlike the synthetic data
Decision recordThe flag that gates a model version: the conditions for removal, the owner, what rollback does not undo (responses already served)
Limits statementNo real model evaluated; synthetic identifiers; no load test; no hosted run if none was made

9.4 Quiz and revision pointers

None. This chapter adds no assessed content beyond the descriptor’s final project, and it creates no new rubric.

10. Instructor notes

10.1 Suggested demonstration and review

Run scripts/handover-check.sh, then break one thing (add a trivial test, or edit a number in HANDOVER.md) and run it again. Rename one test named in the requirements table and run TraceabilityTest. Show experiment E3: a one-line change that passes the whole unit stage. Ask the class which stage should have caught it and why. Let students predict B1’s failure count before running it.

For your team’s project, the review is of the bundle and not of the code: ask for the dry-run record, the person outside the team who followed it, the triage decisions and the statement of what was not run. Ask each student for one artefact that shows what they did. A weak answer to “what was not run?” is “nothing”.

10.2 Do the checks detect faults?

TraceabilityTest. Six faults were applied one at a time to a copy of the project and the test was run each time.

FaultResult
A named test is renamedCaught: two of the three methods fail
An acceptance example loses its rowCaught: two of the three methods fail (the row check, and the minimum of 29 named tests)
A deciding class is renamed in the table (VisitorGuard)Caught: the class check fails
An acceptance test is added that no row mentionsCaught: the row check fails
A row names an extra test that existsNot caught; harmless
AC3 is pointed at the AC1 test, which existsNot caught; a wrong mapping that only a reviewer finds

scripts/handover-check.sh. Three faults, each run through the whole script: a test added (stage 2 shows 207 invocations: FAIL stage 2 ran 206 invocations, one problem); a word changed in the demonstration’s output (FAIL an outage is reported as unavailable, and the pipeline fails too, because the end-to-end journeys check the same line: three problems); and HANDOVER.md edited to promise 232 invocations (FAIL HANDOVER.md promises 235 invocations in total, one problem). All three were caught.

These are a few faults, chosen by the author, in two small checks. They say nothing about how these checks compare with others. They show that the checks notice the faults that make a document disagree with a run, and that they do not notice a document that is wrong in a way the script cannot see.

The results establish the selected behaviours on the supplied inputs. They do not establish that the handover is followable by a reader on another machine.

References

The online references of the student chapter (sources 1–9) were opened on 13 October 2026; see the chapter for the source context.

  1. Nygard, M., Documenting Architecture Decisions.
  2. Keep a Changelog 1.1.0.
  3. Schwaber, K. and Sutherland, J., The Scrum Guide.
  4. Retrospective Wiki: The Prime Directive.
  5. Fowler, M., Yagni.
  6. GitHub, Why are my contributions not showing up on my profile?.
  7. GitHub, Creating a commit with multiple authors.
  8. GitHub, About pull request reviews.
  9. Hodgson, P., Feature Toggles (aka Feature Flags).

Chapter 15 Companion: Code, Explanations and Solutions

Code explanations and solutions for Chapter 15 Companion: Code, Explanations and Solutions.

AI as a Software Engineering Assistant
OO Development and Agile Practices

1. Scope and execution

This file accompanies Chapter 15, AI as a Software Engineering Assistant. It contains the lab, a small stand-alone Maven project for the chapter’s worked example; the two constructed AI attempts and the work orders that produced them; the AI assistance record, its template and a script that checks it; three further constructed outputs for the chapter’s other activities; the model solutions; the experiments behind the chapter’s figures; and teaching notes.

The lending project is not touched. The lab does not depend on it, and its handover version 0.14.0 stays as Chapter 14 left it. The lab holds its own copy of LoanPeriod. Chapter 15 therefore has no starting version and no carry-forward to a later chapter. Because the code is not part of the lending project, the dependency rules, the handover check and the requirements table of Chapter 14 are unaffected.

All AI output in this chapter is constructed. Every attempt, chat response and explanation is author-written and labelled, in its own file, as constructed to illustrate. None is a recording of a real model. No live assistant was used to produce or check anything here.

1.1 Reconstruct the lab

  1. Create the directory lab.
  2. Save every listing in Sections 3 to 5 of this file at its path under lab.

Use a full Java 17 JDK and Maven 3.9.11. The pinned versions are those of the earlier chapters (JUnit Jupiter 5.13.4, Compiler Plugin 3.14.1, Surefire 3.5.4). Initial builds need dependency access. Then, from inside lab:

mvn --batch-mode test
bash scripts/try-attempt.sh 1
bash scripts/try-attempt.sh 2 generated
bash scripts/try-attempt.sh 2 adapted
bash scripts/check-record.sh

1.2 Expected output

mvn --batch-mode test ends with Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 and BUILD SUCCESS. The attempts are run in a scratch copy of the lab, which is deleted afterwards:

CommandExpected
try-attempt.sh 1BUILD FAILURE with two cannot find symbol errors, at AvailabilityFinder.java line 18 and line 20
try-attempt.sh 2 generatedTests run: 7, Failures: 0, BUILD SUCCESS
try-attempt.sh 2 (acceptance)BUILD FAILURE: reference to AvailabilityFinder is ambiguous in AvailabilityFinderTest
try-attempt.sh 2 adaptedTests run: 9, Failures: 2: ex4_... and ex5_...
check-record.shchecked docs/AI-RECORD-earliest-start.md and RECORD CHECK PASSED
check-record.sh where only the template existsFAIL no assistance record to check..., RECORD CHECK FAILED: nothing was checked, exit status 1

(Maven and the build print WARNING lines on a recent JDK. They are not errors.)

1.3 Compile attempt 1 by hand

mkdir -p scratch && cp src/main/java/assist/LoanPeriod.java attempts/attempt1/AvailabilityFinder.java scratch/
cd scratch
javac --release 17 -d out2 *.java     # the project's baseline: two errors (intersects and getFirst)
javac -d out1 *.java                  # optional, and only meaningful on a JDK of version 21 or later:
                                      # one error (intersects). On a JDK 17 both commands report both errors

2. Design map

2.1 What is in the lab

PathPurpose
pom.xmlMaven build, Java 17 (maven.compiler.release), JUnit Jupiter 5.13.4, Surefire with failIfNoTests
README.mdWhat the lab is and how to run it
src/main/java/assist/LoanPeriod.javaA copy of the lending project’s half-open period
src/main/java/assist/AvailabilityFinder.javaThe accepted version, written by the developer
src/test/java/assist/AvailabilityFinderTest.javaThe nine acceptance examples EX1 to EX9, written before any prompt
scripts/try-attempt.shTries a constructed attempt in a scratch copy
scripts/check-record.shChecks that an AI assistance record has every heading and none is empty
prompts/prompt1.md, prompts/prompt2.mdThe two work orders
attempts/attempt1/*, attempts/attempt2/*The constructed AI output, outside the build
docs/AI-RECORD-TEMPLATE.md, docs/AI-RECORD-earliest-start.mdThe record template and the filled record
fixtures/*Three further constructed outputs: an explanation, a prompt to send and not to send, a dependency suggestion

2.2 The task, stated once

Given the periods for which an item is already booked, return the earliest start date, on or after from, among searchDays candidate start dates beginning with from itself, on which the item could be reserved for days days. Periods are half-open. Nothing is reserved. Invalid days or searchDays (below one) is an IllegalArgumentException; a null from or bookings list is a NullPointerException.

The acceptance examples, with the bookings 20–22 and 24–26 October unless stated:

IDRequestResult
EX1from 20, 2 days, window 1022 October
EX2from 20, 3 days, window 1026 October
EX3from 10, 2 days, window 10; and no bookings, from 10, 5 days, window 110 October
EX4from 20, 2 days, window 2none
EX5from 20, 2 days, window 322 October; and window 2 gives none
EX6booking 18–20 only; from 20, 2 days, window 120 October
EX7days 0, window 0, a null from, a null listthe exceptions above
EX8the caller’s list gains a booking after constructionthe finder is unchanged
EX9one booking covering 1–31 October; from 5, 1 day, window 10none

3. The lab: build and code listings

3.1 lab/pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>assist-lab</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

3.2 lab/README.md

# Chapter 15 lab: earliest available start date

A small stand-alone project for the AI-assistance chapter. It is **not** part of the lending project and changes nothing in it. It holds a tiny task, the acceptance examples for it, two constructed AI attempts that are rejected, and the record of what happened.

Use a full Java 17 JDK and Maven 3.9.11.

```bash
mvn --batch-mode test                    # the accepted version and its nine acceptance examples
bash scripts/try-attempt.sh 1            # attempt 1 in a scratch copy: does not compile
bash scripts/try-attempt.sh 2 generated  # attempt 2 with its own generated tests: they pass
bash scripts/try-attempt.sh 2 adapted    # attempt 2 with the acceptance examples: two fail
bash scripts/check-record.sh             # the AI assistance record has every heading
```

`attempts/` holds the constructed AI output and is not part of the build. `prompts/` holds the two work orders. `fixtures/` holds three more constructed outputs for the chapter's other activities. `docs/` holds the record template and the filled record.

3.3 lab/src/main/java/assist/LoanPeriod.java

package assist;

import java.time.LocalDate;
import java.util.Objects;

/** A half-open period of days. This is a copy of the lending project's LoanPeriod, so that the lab stands alone. */
public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

3.4 lab/src/main/java/assist/AvailabilityFinder.java

package assist;

import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
 * Finds the earliest start date on which an item can be reserved for a number of days, given the
 * periods for which it is already booked. The search examines searchDays candidate start dates,
 * beginning with from itself. Periods are half-open: a booking that ends on a date does not block a
 * new one that starts on that date. Nothing is reserved and the bookings are not changed.
 */
public final class AvailabilityFinder {
    private final List<LoanPeriod> booked;

    public AvailabilityFinder(List<LoanPeriod> booked) {
        this.booked = List.copyOf(Objects.requireNonNull(booked, "booked"));
    }

    public Optional<LocalDate> earliestStart(LocalDate from, int days, int searchDays) {
        Objects.requireNonNull(from, "from");
        if (days < 1) {
            throw new IllegalArgumentException("days must be at least 1");
        }
        if (searchDays < 1) {
            throw new IllegalArgumentException("searchDays must be at least 1");
        }
        for (int offset = 0; offset < searchDays; offset++) {
            LocalDate start = from.plusDays(offset);
            LoanPeriod wanted = new LoanPeriod(start, start.plusDays(days));
            if (booked.stream().noneMatch(wanted::overlaps)) {
                return Optional.of(start);
            }
        }
        return Optional.empty();
    }
}

3.5 lab/src/test/java/assist/AvailabilityFinderTest.java

package assist;

import static org.junit.jupiter.api.Assertions.*;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;

/** The acceptance examples EX1 to EX9, written from the requirement before any code was requested. */
class AvailabilityFinderTest {
    private static LocalDate oct(int day) { return LocalDate.of(2026, 10, day); }
    private static LoanPeriod period(int from, int endExclusive) { return new LoanPeriod(oct(from), oct(endExclusive)); }

    /** CAM1 is booked for [20, 22) and [24, 26). */
    private static AvailabilityFinder cam1() { return new AvailabilityFinder(List.of(period(20, 22), period(24, 26))); }

    @Test void ex1_aBookingThatEndsOnADateDoesNotBlockAStartOnThatDate() {
        assertEquals(Optional.of(oct(22)), cam1().earliestStart(oct(20), 2, 10));
    }

    @Test void ex2_aLongerStayMustFitBeforeTheNextBooking() {
        assertEquals(Optional.of(oct(26)), cam1().earliestStart(oct(20), 3, 10));
    }

    @Test void ex3_aFreeStartDateIsReturnedImmediately() {
        assertEquals(Optional.of(oct(10)), cam1().earliestStart(oct(10), 2, 10));
        assertEquals(Optional.of(oct(10)), new AvailabilityFinder(List.of()).earliestStart(oct(10), 5, 1));
    }

    @Test void ex4_theWindowCountsCandidateStartDatesAndNotDaysAfterFrom() {
        assertEquals(Optional.empty(), cam1().earliestStart(oct(20), 2, 2));
    }

    @Test void ex5_theLastCandidateInTheWindowIsIncluded() {
        assertEquals(Optional.of(oct(22)), cam1().earliestStart(oct(20), 2, 3));
        assertEquals(Optional.empty(), cam1().earliestStart(oct(20), 2, 2));
    }

    @Test void ex6_aBookingThatEndsOnFromDoesNotBlockFrom() {
        var finder = new AvailabilityFinder(List.of(period(18, 20)));
        assertEquals(Optional.of(oct(20)), finder.earliestStart(oct(20), 2, 1));
    }

    @Test void ex7_invalidArgumentsAreRejected() {
        var finder = cam1();
        assertThrows(IllegalArgumentException.class, () -> finder.earliestStart(oct(20), 0, 5));
        assertThrows(IllegalArgumentException.class, () -> finder.earliestStart(oct(20), 2, 0));
        assertThrows(NullPointerException.class, () -> finder.earliestStart(null, 2, 5));
        assertThrows(NullPointerException.class, () -> new AvailabilityFinder(null));
    }

    @Test void ex8_theCallersListIsCopiedAndNothingIsChanged() {
        List<LoanPeriod> callers = new ArrayList<>(List.of(period(20, 22)));
        var finder = new AvailabilityFinder(callers);
        callers.add(period(22, 24));
        assertEquals(Optional.of(oct(22)), finder.earliestStart(oct(20), 2, 10));
        assertEquals(2, callers.size());
    }

    @Test void ex9_everyCandidateBlockedMeansEmpty() {
        var finder = new AvailabilityFinder(List.of(period(1, 31)));
        assertEquals(Optional.empty(), finder.earliestStart(oct(5), 1, 10));
    }
}

3.6 lab/scripts/try-attempt.sh

#!/usr/bin/env bash
# Tries one of the constructed AI attempts in a scratch copy of the lab: it replaces the accepted
# AvailabilityFinder with the attempt, compiles for Java 17 and runs the tests. The lab itself is not
# changed. Usage: bash scripts/try-attempt.sh 1|2 [generated|acceptance|adapted|both]
#   generated   run the attempt's own generated tests (attempt 2 only)
#   acceptance  run the acceptance tests EX1 to EX9 (default)
#   both        run both sets
#   adapted     the acceptance tests with one change to a test line, `new AvailabilityFinder(null)`
#               becomes `new AvailabilityFinder((List<LoanPeriod>) null)`, for an attempt whose extra
#               constructor makes the original line ambiguous (attempt 2). The change is to the scratch
#               copy only and does not alter what the test asserts.
# CHAPTER_MVN names the Maven command (default: mvn); CHAPTER_M2 optionally names a local repository.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

ATTEMPT="${1:?usage: try-attempt.sh 1|2 [generated|acceptance|adapted|both]}"
WHICH="${2:-acceptance}"
MVN="${CHAPTER_MVN:-mvn}"
if ! command -v "$MVN" >/dev/null 2>&1 && [ ! -f "$MVN" ]; then
  echo "Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2
  exit 2
fi
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi

SRC="attempts/attempt${ATTEMPT}"
[ -d "$SRC" ] || { echo "No such attempt: $SRC" >&2; exit 2; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
tar cf - --exclude=./target --exclude=./attempts . | ( cd "$WORK" && tar xf - )
cp "$SRC"/*.java "$WORK/src/main/java/assist/"
if [ -f "$SRC/AvailabilityFinderGeneratedTest.java" ]; then
  mv "$WORK/src/main/java/assist/AvailabilityFinderGeneratedTest.java" "$WORK/src/test/java/assist/"
fi
cd "$WORK"
case "$WHICH" in
  generated)  [ -f src/test/java/assist/AvailabilityFinderGeneratedTest.java ] || { echo "Attempt ${ATTEMPT} has no generated tests." >&2; exit 2; }
              rm src/test/java/assist/AvailabilityFinderTest.java ;;
  acceptance) rm -f src/test/java/assist/AvailabilityFinderGeneratedTest.java ;;
  both)       ;;
  adapted)    rm -f src/test/java/assist/AvailabilityFinderGeneratedTest.java
              sed -i 's/new AvailabilityFinder(null)/new AvailabilityFinder((List<LoanPeriod>) null)/' src/test/java/assist/AvailabilityFinderTest.java ;;
  *)          echo "Unknown test set: $WHICH" >&2; exit 2 ;;
esac
"$MVN" "${ARGS[@]}" test

3.7 lab/scripts/check-record.sh

#!/usr/bin/env bash
# Checks that an AI assistance record has every heading of the template and that none of them is empty.
# It cannot judge whether what is written is true or useful; a reviewer does that.
# Usage: bash scripts/check-record.sh [record.md ...]   (default: docs/AI-RECORD-*.md except the template)
# It fails, and says so, when there is no record to check, so an empty set can never be reported as verified.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

HEADINGS=("Task" "Tool and model" "Context given" "Output received" "Decision" "Changes made" "Verification" "Limits")
if [ "$#" -gt 0 ]; then
  FILES=("$@")
else
  FILES=()
  for f in docs/AI-RECORD-*.md; do
    [ -e "$f" ] || continue
    [ "$(basename "$f")" = "AI-RECORD-TEMPLATE.md" ] || FILES+=("$f")
  done
fi

if [ "${#FILES[@]}" -eq 0 ]; then
  echo "FAIL no assistance record to check: only the template was found, or no record exists"
  echo "RECORD CHECK FAILED: nothing was checked"
  exit 1
fi

problems=0
for file in "${FILES[@]}"; do
  for heading in "${HEADINGS[@]}"; do
    if ! grep -q "^## ${heading}\$" "$file"; then
      echo "FAIL ${file}: missing heading '${heading}'"
      problems=$((problems + 1))
      continue
    fi
    # the text between this heading and the next must contain a non-blank line
    body="$(awk -v h="## ${heading}" '$0 == h {on=1; next} /^## / {on=0} on' "$file" | tr -d '[:space:]')"
    if [ -z "$body" ]; then
      echo "FAIL ${file}: heading '${heading}' is empty"
      problems=$((problems + 1))
    fi
  done
  echo "checked ${file}"
done
if [ "$problems" -eq 0 ]; then echo "RECORD CHECK PASSED"; else echo "RECORD CHECK FAILED: ${problems} problem(s)"; exit 1; fi

4. Constructed AI output: work orders, attempts and responses

Everything in this section is author-written fictional AI output, constructed to illustrate. It is not a recorded service response.

4.1 lab/prompts/prompt1.md

<!-- The human's first work order. The acceptance examples are not in the prompt. They were derived from the requirement before prompting and are the authority; in this exercise they are also kept back so that they can be used to judge whether the code generalises. -->
You are helping with a small Java task in an existing project.

Task: add a class `AvailabilityFinder` in package `assist` with a method
`Optional<LocalDate> earliestStart(LocalDate from, int days, int searchDays)`. It returns the earliest start
date, on or after `from` and within `searchDays` days, on which an item could be reserved for `days` days,
given the periods for which the item is already booked.

Context (the only code you need):
- `assist.LoanPeriod` (below) is a half-open period: it has `start()`, `endExclusive()` and `overlaps(LoanPeriod)`.
- The bookings are given to the constructor as a `List<LoanPeriod>`.

[the source of LoanPeriod is pasted here]

Constraints: Java 17, standard library only, no new dependencies.
Output: the complete source of `AvailabilityFinder.java` and nothing else.
Scope: do not change `LoanPeriod`. Do not reserve anything.

4.2 Attempt 1

4.2.1 lab/attempts/attempt1/AvailabilityFinder.java

package assist;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

// Author-written fictional AI output, constructed to illustrate. It is not a recorded service response.
public final class AvailabilityFinder {
    private final List<LoanPeriod> booked;

    public AvailabilityFinder(List<LoanPeriod> booked) {
        this.booked = List.copyOf(booked);
    }

    public Optional<LocalDate> earliestStart(LocalDate from, int days, int searchDays) {
        List<LocalDate> free = from.datesUntil(from.plusDays(searchDays))
                .filter(start -> booked.stream()
                        .noneMatch(b -> b.intersects(new LoanPeriod(start, start.plusDays(days)))))
                .toList();
        return free.isEmpty() ? Optional.empty() : Optional.of(free.getFirst());
    }
}

4.2.2 lab/attempts/attempt1/RESPONSE.md

<!-- Author-written fictional AI output, constructed to illustrate. It is not a recorded service response. -->
Here is `AvailabilityFinder.java`. It streams the candidate start dates in the window, keeps those with no overlapping booking, and returns the first. I tested it with Java 17 and it works.

4.3 lab/prompts/prompt2.md

<!-- The human's second work order, after attempt 1 failed to compile. It names the facts found by the compiler and restates the constraints. It still does not contain the acceptance examples. -->
Your code does not compile, for two reasons.
1. `LoanPeriod` has no method `intersects`. The method is `overlaps(LoanPeriod)`; use it.
2. `List.getFirst()` does not exist in Java 17. The project is compiled with `--release 17`.

Please fix those, keep the solution as small as you can, and also write JUnit 5 tests for it.
Output: `AvailabilityFinder.java` and one test class.

4.4 Attempt 2

4.4.1 lab/attempts/attempt2/AvailabilityFinder.java

package assist;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

// Author-written fictional AI output, constructed to illustrate. It is not a recorded service response.
public final class AvailabilityFinder {
    private final AvailabilityRule rule;

    public AvailabilityFinder(List<LoanPeriod> booked) {
        this(AvailabilityRules.noOverlapWith(booked));
    }

    AvailabilityFinder(AvailabilityRule rule) {
        this.rule = rule;
    }

    public Optional<LocalDate> earliestStart(LocalDate from, int days, int searchDays) {
        if (from == null) throw new NullPointerException("from");
        if (days < 1 || searchDays < 1) throw new IllegalArgumentException("days and searchDays must be positive");
        for (int offset = 0; offset <= searchDays; offset++) {
            LocalDate start = from.plusDays(offset);
            if (rule.isFree(new LoanPeriod(start, start.plusDays(days)))) {
                return Optional.of(start);
            }
        }
        return Optional.empty();
    }
}

4.4.2 lab/attempts/attempt2/AvailabilityRule.java

package assist;

// Author-written fictional AI output, constructed to illustrate.
interface AvailabilityRule {
    boolean isFree(LoanPeriod wanted);
}

4.4.3 lab/attempts/attempt2/AvailabilityRules.java

package assist;

import java.util.List;

// Author-written fictional AI output, constructed to illustrate.
final class AvailabilityRules {
    private AvailabilityRules() { }

    static AvailabilityRule noOverlapWith(List<LoanPeriod> booked) {
        List<LoanPeriod> copy = List.copyOf(booked);
        return wanted -> copy.stream().noneMatch(wanted::overlaps);
    }
}

4.4.4 lab/attempts/attempt2/AvailabilityFinderGeneratedTest.java

package assist;

import static org.junit.jupiter.api.Assertions.*;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;

// Author-written fictional AI output, constructed to illustrate. Generated from the code above.
class AvailabilityFinderGeneratedTest {
    private static LocalDate oct(int day) { return LocalDate.of(2026, 10, day); }
    private static LoanPeriod period(int from, int end) { return new LoanPeriod(oct(from), oct(end)); }

    @Test void findsTheFirstFreeDayAfterABooking() {
        var finder = new AvailabilityFinder(List.of(period(20, 22), period(24, 26)));
        assertEquals(Optional.of(oct(22)), finder.earliestStart(oct(20), 2, 10));
    }

    @Test void returnsFromWhenNothingIsBooked() {
        assertEquals(Optional.of(oct(10)), new AvailabilityFinder(List.of()).earliestStart(oct(10), 3, 5));
    }

    @Test void skipsALongStayThatDoesNotFit() {
        var finder = new AvailabilityFinder(List.of(period(20, 22), period(24, 26)));
        assertEquals(Optional.of(oct(26)), finder.earliestStart(oct(20), 3, 10));
    }

    @Test void theSearchWindowIsInclusiveOfTheLastDay() {
        var finder = new AvailabilityFinder(List.of(period(20, 22)));
        assertEquals(Optional.of(oct(22)), finder.earliestStart(oct(20), 2, 2));
    }

    @Test void returnsEmptyWhenTheWindowIsFullyBooked() {
        var finder = new AvailabilityFinder(List.of(period(1, 31)));
        assertEquals(Optional.empty(), finder.earliestStart(oct(5), 1, 3));
    }

    @Test void rejectsNonPositiveDays() {
        var finder = new AvailabilityFinder(List.of());
        assertThrows(IllegalArgumentException.class, () -> finder.earliestStart(oct(1), 0, 3));
    }

    @Test void rejectsNullFrom() {
        assertThrows(NullPointerException.class, () -> new AvailabilityFinder(List.of()).earliestStart(null, 1, 3));
    }
}

4.4.5 lab/attempts/attempt2/RESPONSE.md

<!-- Author-written fictional AI output, constructed to illustrate. It is not a recorded service response. -->
Fixed both issues. The finder now delegates to an `AvailabilityRule`, so new rules can be added without changing it (open/closed principle). I added seven JUnit 5 tests and all of them pass. The behaviour matches your description.

5. Records and examples

5.1 lab/docs/AI-RECORD-TEMPLATE.md

# AI assistance record

## Task
What was asked, in one or two sentences, and who owns the requirement.

## Tool and model
The tool and model, or "none: constructed example". The date. If the tool was not available, say how the exercise was done instead.

## Context given
What the assistant was shown (files, requirement text, constraints). Say what was left out and why. No secrets or personal data.

## Output received
Each candidate output, in order, accepted or rejected. Keep the rejected ones.

## Decision
What was accepted, what was rejected, and the reason for each.

## Changes made
What you changed by hand, and why.

## Verification
The checks that were run, the commands, the results and the date. Say which checks are independent of the assistant's own claims.

## Limits
What the verification does not show. What was not run.

5.2 lab/docs/AI-RECORD-earliest-start.md

# AI assistance record: earliest available start date

## Task
Add `AvailabilityFinder.earliestStart(from, days, searchDays)` to the lab. The requirement and its nine acceptance examples (EX1 to EX9) were written by the developer before any prompt, in `AvailabilityFinderTest`. The developer owns the requirement.

## Tool and model
None: constructed example (author-written, not a recorded service response). 13 October 2026. The two attempts stand in for the output of an assistant, so that the exercise needs no account.

## Context given
Prompt 1 (`prompts/prompt1.md`): the task sentence, the source of `LoanPeriod`, the constraints (Java 17, standard library only, no new dependencies), the output format and the permitted scope. Not shared in this exercise: the acceptance examples. They were derived from the requirement before the prompt, and were kept back so that they could also be used to judge whether the code generalises. Sharing reviewed examples would have been a legitimate choice, but a pass on examples the assistant has seen shows only that it satisfied those examples. Prompt 2 (`prompts/prompt2.md`): the two compiler findings and the request to keep the solution small and add tests.

## Output received
1. Attempt 1: **rejected**. It did not compile. It called `LoanPeriod.intersects`, which does not exist, and `List.getFirst()`, which exists only from Java 21.
2. Attempt 2: **rejected**. It compiled and its own seven generated tests passed. It added an interface and a helper class that the task did not need, and its loop examined one candidate date too many (`offset <= searchDays`). One of its generated tests asserted that behaviour. It also made `new AvailabilityFinder(null)` ambiguous, which broke the compilation of the developer's `ex7` test.

## Decision
Neither attempt was accepted. The developer wrote the final version by hand, using attempt 2 only as a reminder of the shape of the loop.

## Changes made
Removed the interface and the helper class, fixed the window loop to `offset < searchDays`, validated the arguments and copied the bookings list. The generated tests were discarded. The acceptance examples became the tests. The requirement sentence was also rewritten, because the assistant's misreading had a cause in it: "within `searchDays` days" can mean up to and including `from + searchDays`. The new wording says that `searchDays` is the number of candidate start dates examined, beginning with `from` itself.

## Verification
- `javac --release 17` on attempt 1: two errors (the invented method and `getFirst`); plain `javac` on a JDK of version 21 or later reports only the first.
- Attempt 2 with its own generated tests: 7 pass. With the developer's acceptance tests (after the one-line cast that removes the ambiguity): EX4 and EX5 fail.
- Final version, acceptance tests: 9 pass (`mvn --batch-mode test`, Maven 3.9.11, `--release 17`).
- Ten single faults applied to the final version: eight caught by the acceptance tests, two survive and are equivalent: they let an invalid argument through the finder's own check, but the same exception type is then thrown one line later by `LoanPeriod` or `LocalDate`, which is all the example asserts.
- The generated tests of attempt 2, run against the correct code: one fails (`theSearchWindowIsInclusiveOfTheLastDay`), because it asserts the misunderstanding.
- Independent of the assistant's claims: the compiler, the acceptance examples written beforehand, the fault experiment.

## Limits
The acceptance examples are the developer's reading of the requirement; if the requirement is different, they are wrong too. The code was compiled for Java 17 with `--release 17` and run on a newer JDK, so no Java 17 runtime run is claimed. The finder was not connected to the lending system's `ReservationCoordinator`.

5.3 lab/fixtures/explanation-visitor-gate.md

<!-- Author-written fictional AI output, constructed to illustrate. It is not a recorded service response. -->
Task given to the assistant: "Explain what `VisitorGate.rejection` does. Say only what the code shows."

The assistant's explanation:

> `rejection` first checks the feature flag and returns the "not enabled" text if visitors are off. It then checks that the item is CAM1 or CAM2. If the number of days is below one it returns an empty result without asking anyone, because the count and duration policy will reject it. Otherwise it reads the clock once, asks the approval source for the exact request, and rejects unless the record found matches the request and is valid at that instant. The gate caches the record for later requests and writes each rejection to the audit log. If the approval source throws, the gate returns the "no valid sponsor approval" rejection.

5.4 lab/fixtures/privacy-prompt.md

<!-- Constructed example. All names, identifiers and the key are invented. -->
## A prompt that should not be sent

> Our reservation service is failing for Layla Hassan (student ID 2023-48817, [email protected]). Here is the config
> that fails; the directory password is in it:
>
>     lending.directory.url=http://10.20.30.40:8080/borrowers
>     lending.directory.password=not-a-real-password-just-an-example
>
> And here are the last 200 lines of our production log, which include other borrowers' names. What is wrong?

## The same task, with the minimum context

> A Java client calls `GET <base>/borrowers/<key>` and sometimes throws `HttpTimeoutException` after 1000 ms.
> Here is the method (no secrets, no real URL). Here are the three log lines that show the exception, with
> borrower keys replaced by `B1`, `B2`, `B3`. What are the possible causes? List them without choosing one.

5.5 lab/fixtures/dependency-suggestion.md

<!-- Author-written fictional AI output, constructed to illustrate. It is not a recorded service response. -->
Task given to the assistant: "Add a test helper that asserts the first element of a list."

The assistant's suggestion:

> Add these to your `pom.xml` and use `Mocks.firstOf(list)`:
>
>     <dependency>
>       <groupId>org.mockito</groupId>
>       <artifactId>mockito-fluent</artifactId>
>       <version>5.20.0</version>
>       <scope>test</scope>
>     </dependency>
>     <dependency>
>       <groupId>com.google.guava</groupId>
>       <artifactId>guava</artifactId>
>       <version>33.0.0-jre</version>
>       <scope>test</scope>
>     </dependency>

6. Worked explanation and evidence

6.1 Attempt 1 under three compilers’ settings

CommandResult
javac -d out1 *.java (a JDK of version 21 or later, no --release)One error: cannot find symbol, symbol: method intersects(LoanPeriod), location: variable b of type LoanPeriod
javac --release 17 -d out2 *.javaTwo errors: the same, and cannot find symbol, symbol: method getFirst(), location: variable free of type List<LocalDate>
After changing only intersects to overlaps: plain javacNo error. The code compiles
After the same change: javac --release 17One error: getFirst()
Maven (maven.compiler.release 17), try-attempt.sh 1Two errors, at [18,42] and [20,68]
Either command on a JDK 17Both errors. The plain-javac contrast needs a JDK of version 21 or later; the project’s command fails in the same way on every JDK

The third row is the point. After the invented method is fixed, the code compiles on the machine it was written on and fails on the baseline. List.getFirst() is declared in the Java 21 List (since 21) and not in the Java 17 List, and --release restricts the API as well as the language level. An assistant told that the project targets Java 17 may still use it, and a developer who compiles without --release will not notice until the pipeline does.

6.2 Attempt 2, step by step

RunResult
Attempt 2 with its own seven generated tests7 pass
Attempt 2 with the acceptance tests as writtenDoes not compile: reference to AvailabilityFinder is ambiguous at the line new AvailabilityFinder(null) of ex7. Attempt 2 has two constructors, one taking a List and one taking an AvailabilityRule, so a bare null fits both
Attempt 2 with the acceptance tests after the one-line cast (try-attempt.sh 2 adapted)9 run, 2 fail: ex4_... (expected Optional.empty, was Optional[2026-10-22]) and ex5_... (the same)
The 7 generated tests against the accepted finder6 pass and 1 fails: theSearchWindowIsInclusiveOfTheLastDay

What this shows:

  • The failing acceptance examples are both about the window. The loop in attempt 2 is offset <= searchDays, so it examines searchDays + 1 candidates. From the 20th with a window of 2 it examines 20, 21 and 22 and returns the 22nd.
  • The generated test theSearchWindowIsInclusiveOfTheLastDay asserts exactly that result. It was derived from the code, passes on it, and fails on correct code. It is a test of what the code does.
  • The acceptance examples were independent because they were derived from the requirement before any candidate existed, and not from code. They were also not shared with the assistant, which let them be used a second way, to judge whether the output generalises. Sharing them would have been a legitimate choice. It would probably have produced a correct loop, and the pass would then have shown only that the assistant satisfied those examples.
  • The ambiguity is a cost of the abstraction. The one-line cast in try-attempt.sh 2 adapted changes the type of an argument and does not change what the test asserts. Editing a test to suit the code is the wrong direction in general: here the edit is only so that the logic failure can be seen at all. The right response to the compile error, in the developer’s own project, is to remove the unneeded overload.

Size of the abstractions. The accepted AvailabilityFinder is one type of 38 lines, six of them documentation. Attempt 2’s production code is three types in 49 lines (AvailabilityFinder 30, AvailabilityRule 6, AvailabilityRules 13) with no documentation comment. The interface has one implementation, built in one place by a factory that is called once. The task has one rule.

6.3 The explanation of VisitorGate.rejection

The code and tests referred to are in the lending project at version 0.14.0.

#ClaimVerdictEvidence
1The flag is checked first and the “not enabled” text is returnedSupportedThe first statement of rejection is if (!enabled) return Optional.of(DISABLED)
2Then the item is checked against CAM1 or CAM2SupportedALLOWED_ITEMS = Set.of("CAM1", "CAM2") and the check that follows
3Fewer than one day returns empty without asking anyone, because the policy will reject itSupportedif (days < 1) return Optional.empty() and its comment; ac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister asserts that the register is not read; VisitorReservationRules.mayRequest requires requestedDays >= 1
4The clock is read onceSupportedoneRequestReadsTheClockOnce
5The exact request is asked of the source, and the request is rejected unless the record matches and is valid at that instantSupportedaSourceThatReturnsADifferentRequestDoesNotApprove; theWindowIncludesItsStartAndExcludesItsEnd
6The gate caches the record for later requestsContradictedThe class has no field that holds a record. Its fields are the source, the clock and the flag. The file source itself reads afresh on every call (theFileIsReadAfreshOnEveryCall)
7Each rejection is written to the audit logContradictedThe class has no logger and no call that writes anywhere
8If the source throws, the gate returns the “no valid sponsor approval” rejectionContradictedThe exception is not caught: anOperationalFailureOfTheSourceIsNotCaught asserts that the same exception object reaches the caller. This is the difference between a rejection and a failure that Chapters 12 and 13 insist on

A corrected explanation: rejection returns an empty result when the request may go on to the coordinator, and a rejection text otherwise. It checks the flag first, then the item, then returns empty for fewer than one day (the policy rejects it). Otherwise it reads the clock once, asks the source for the exact request and rejects unless the record matches and is valid at that instant. It keeps no state and writes no log. If the source throws, the exception reaches the caller; nothing is reserved.

Note which three claims were wrong. Each describes something a gate like this could plausibly do. All three would have been believed by a reader who did not check, and the third would have produced a wrong design decision about error handling.

6.4 The two prompts

PromptContainsDoes not contain
prompt1.mdThe task sentence; the source of LoanPeriod; the constraints (Java 17, standard library only, no new dependencies); the output format; the scopeThe acceptance examples; any statement of how the window counts
prompt2.mdThe two compiler findings; the request for a small solution and testsThe acceptance examples

Two things about them. First, prompt1.md says “within searchDays days” and nowhere says what that counts. The assistant resolved the ambiguity one way and the developer meant the other. The record shows that the requirement was rewritten afterwards. Second, prompt2.md asks the assistant to write tests. That request is where the shared misreading gets locked in: code and tests from the same source agree with each other.

If the acceptance examples had been in the prompt, the assistant would probably have returned a correct loop. They would still have been good requirement-based acceptance checks. What would have changed is what a pass means: it would show that the assistant satisfied those examples, not that its code is right beyond them. A sound practice is to state the requirement precisely, share reviewed examples if they help, add boundary cases or properties that you derived independently, and keep some of them undisclosed when you want to judge generalisation. The one thing to avoid is deriving the expected results and the implementation from the same unexamined assumption, which is what happens when an assistant writes the code and then its tests from that code.

6.5 The privacy example

The first prompt of privacy-prompt.md should not be sent. What it discloses, and what the second prompt does instead:

In the first promptWhy it should not be sentIn the second prompt
A student’s name, student ID and emailPersonal data about a real borrowerKeys replaced by B1, B2, B3
A configuration file with a password (invented here)A credential. Anything that is a secret in real life must not be pastedNo configuration; the method only
An internal address and pathInformation about your infrastructure<base>, no real URL
200 lines of production log with other borrowers’ namesMore personal data, and far more than the question needsThree log lines, with the keys replaced
“What is wrong?”No hypothesis, no limit on scope“What are the possible causes? List them without choosing one”

The second prompt is also better engineering: it states the symptom precisely (a timeout after 1000 ms) and asks for causes, which you will check, and not for a verdict.

6.6 The dependency suggestion

The constructed suggestion proposes two dependencies for a test helper that asserts the first element of a list. The status codes were read from Maven Central with curl -sI -o /dev/null -w "%{http_code}" on 13 October 2026. Nothing was downloaded.

CoordinateRequestStatus
org.mockito:mockito-corehttps://repo1.maven.org/maven2/org/mockito/mockito-core/200
org.mockito:mockito-junit-jupiterthe same pattern200
org.mockito:mockito-fluenthttps://repo1.maven.org/maven2/org/mockito/mockito-fluent/404
org.mockito:mockito-core:5.20.0the version’s .pom200
org.mockito:mockito-core:9.9.9the version’s .pom404
com.google.guava:guavahttps://repo1.maven.org/maven2/com/google/guava/guava/200
com.google.guava:guava:33.0.0-jrethe version’s .pom200

Decisions. mockito-fluent: reject. It does not exist under that group, and a plausible name is not a reason to search for a similar one. Even a real mocking library would be wrong here, because a helper that asserts the first element of a list does not need mocking. guava:33.0.0-jre: reject. It exists, and nothing in the task needs it: list.get(0) and assertEquals do the job. A status of 200 shows only that something is published under that name. It does not show that it is the project you meant, that it is safe, or that you need it.

6.7 The fault experiment (summary)

The full tables are in Section 10.2.

7. Suggested practical solution

7.1 the book’s project, steps B1–B4

B1. Reject attempt 1. A good answer runs try-attempt.sh 1 and reports two errors, then repeats by hand with javac --release 17 and reads both errors (and, with a JDK of version 21 or later, shows that plain javac reports one). It lists the defects and categories: invented method (intersects); API newer than the baseline (getFirst, Java 21); self-reported verification (“I tested it with Java 17”). It says what the claim could mean: possibly that it compiled on some JDK, perhaps without --release; it cannot mean that it was checked under the project’s settings, because the code does not compile under them.

B2. Diagnose attempt 2. A good answer reports 7 passing generated tests, then 2 failing acceptance examples (EX4, EX5) and the cause (offset <= searchDays). It names the shared misunderstanding (the window is inclusive of one extra candidate) and points at the generated test that asserts it. It says why the acceptance examples were independent (derived first, from the requirement, and not from the code; they were also kept back, so that they could judge generalisation). It reports that the generated tests fail one test against the accepted finder. It notes the overload ambiguity and the unneeded abstraction.

B3. Make the tests bite. A good answer applies three faults, for example offset < searchDays - 1, start.plusDays(days - 1) and allMatch, predicts the failing examples before running (Section 10.2 lists what happened), and finds one that survives. The survivors in the experiment are equivalent faults; a good answer says why no test of the exception type can detect them.

B4. Fill in a record. A good answer uses all eight headings, keeps rejected output, names at least one check that is independent of the assistant and at least one limit, and passes check-record.sh. A weak answer says “reviewed and verified” under Verification.

7.2 the book’s project, optional steps B5–B7

B5. Repeat with a live assistant. There is no model answer, because the output differs. A good answer keeps every attempt, compiles under --release 17, runs the acceptance examples, and names which failure modes from Section 15.6 appeared. An assistant that gets it right the first time is a valid result: record that and the checks that showed it.

B6. Verify two dependencies. See Section 6.6.

B7. Swap and find. A good answer lists the flaws it found, the flaws it missed and why. Missing a flaw of the invented-method kind (it looked like the real name) is common and instructive.

7.3 your team’s project, steps T1–T3 (and T4)

These are not model answers, because the task is your team’s. A good answer shows:

  • T1: a requirement stated precisely, including how any count or window is defined; acceptance examples with inputs and expected results, on the boundaries; the constraints (language level, no new dependencies) and the scope; and a stated decision about what stays out of the prompt.
  • T2: every attempt kept, the project’s own build and compiler settings used, and the acceptance examples run against each attempt. If no live assistant was used, the constructed or classmate-written candidate and the routes of Section 15.9.
  • T3: a record with every heading, the independent checks named, the limits stated, the record checked by the script and reviewed by a person.
  • T4: a short team note that follows the module leader’s rules and says what is shared with tools and what is not, how AI-assisted work is disclosed and recorded, and who reviews it.

A weak answer derives the examples from the candidate code, treats a pass on examples the assistant has seen as proof of behaviour beyond them, accepts a claim of testing, or keeps only the final version. A sound answer that shares reviewed examples is not weak for that reason: it should then add independently derived cases.

8. Intentionally flawed alternatives

These fragments are diagnostic alternatives, not files to compile.

8.1 “Reviewed by another assistant”

## Verification
Reviewed by a second assistant, which found no issues. All tests pass.

A second candidate opinion with the same weaknesses is not verification, and “all tests pass” does not say whose tests. Name the independent checks and the commands.

8.2 Accepting it because it compiled

$ javac AvailabilityFinder.java      # a newer JDK, no --release
$ echo $?
0

The project compiles with --release 17. A call that exists only from Java 21 compiled here and fails there.

8.3 Tests derived from the implementation

// "Generated from the code above": it asserts what the code does.
@Test void theSearchWindowIsInclusiveOfTheLastDay() {
    assertEquals(Optional.of(oct(22)), finder.earliestStart(oct(20), 2, 2));
}

The name states a requirement that nobody wrote. Examples come from the requirement.

8.4 A record that says nothing

## Task
Used AI for the finder.
## Verification
Reviewed and verified.
## Limits
None.

It passes the heading check and tells a reader nothing. The checker looks only for structure.

8.5 A prompt that carries the requirement and the secrets

Fix it so it matches the spec. Here is the spec (confidential) and our production config,
including the directory password.

The requirement belongs in your own documents, stated once; the assistant gets the minimum. Secrets do not go in a prompt.

9. Review answers and transfer solution

9.1 Concept answers

1. “I tested it with Java 17.” It is a sentence from a system that did not run your build, and it names no command or result. Two checks that are evidence: compile and test with the project’s own command (mvn --batch-mode test, which sets release 17) and read the result; and run javac --release 17 on the file.

2. Function and tests that pass. Tests derived from the code agree with it, including where it is wrong: the generated test theSearchWindowIsInclusiveOfTheLastDay passes on attempt 2 and fails on correct code. Examples written from the requirement beforehand would show it, and a fault experiment on the tests would show that they miss the window fault.

3. --release. --source and --target set the language level and the class-file target. --release also restricts the API to the one that release defines, and cannot be combined with --source or --target. On a JDK newer than the baseline, only --release 17 makes getFirst() a compile error under Java 17. On a JDK 17 itself, every compile command rejects it, because the API is not there.

4. A dependency that exists. You have learned that something is published under that coordinate. You have not learned that it is the library you meant, that it is safe or licensed for your use, that the version suits your build, or that you need it.

9.2 Applied diagnosis answers

5. “The method caches the result.” Options: read the class for a field that holds a result (there is none); look for a test that depends on caching, and for a test that depends on its absence (theFileIsReadAfreshOnEveryCall); trace two calls by hand. Write down the claim, the evidence and the verdict: contradicted, with the line or test.

6. A patch of four claims with one failing test. Apply the claims one at a time in scratch copies and run the suite after each; the failing claim is the one whose copy fails. The patch’s summary is a claim of the same kind as the code, and cannot tell you which of its own claims was the cause.

9.3 Transfer solution

ItemContent
Requirement and examplesThe validation rule stated precisely (what counts as a missing value, which ranges, what happens to a bad record). Examples on the boundaries, with synthetic records. Written first
Context to shareThe function signature, the record type, the constraints. Synthetic records only
Not to shareReal records, keys, tokens, the registry’s address, any real model or customer identifiers
Independent checksThe compiler under the project’s settings; the examples; a fault experiment on the tests; a trace on selected inputs
DependenciesLook up any suggested library; ask whether the standard library already does it; pin the version
RecordTask, tool and model, context, each attempt, decision, changes, verification, limits
What it does not show about the modelNothing here evaluates the model: its accuracy, fairness, drift or behaviour on data unlike the synthetic data. Validating the data a model receives is not validating the model
AI in the productThe model itself is a component with its own failure modes, contracts and tests. It is a different topic from using an assistant to write the code. In this book it is the borrower directory: Chapter 6 (contract), 7 (doubles), 8 (adapter), 13 (timeout and failure policy)

9.4 Quiz and revision pointers

None. This chapter adds no assessed content and creates no rubric. Whether and how AI tools may be used in assessments is the module leader’s decision.

10. Instructor notes

10.1 Suggested demonstration and review

Compile attempt 1 with javac --release 17, and, if a JDK of version 21 or later is available, with plain javac, and ask why the results differ. With only a JDK 17 the two commands agree, which is itself worth discussing. Run attempt 2 against its own tests, then against the acceptance examples, and ask the class to find the shared misunderstanding before showing the loop. Ask which of the three wrong claims in the VisitorGate explanation they would have believed without checking, and why.

Do not let the review turn into a debate about whether students should use assistants. The rules are the module leader’s. The skill is the same either way: to check a candidate against evidence it did not write.

Where students have no access to a live service, every activity works from the constructed output. Where they do, ask for the rejected attempts as well as the accepted one: the record of a rejection is the evidence that someone checked.

10.2 Do the tests detect faults?

Ten single faults were applied one at a time to the accepted AvailabilityFinder in a scratch copy and run with mvn test, once against the nine acceptance examples and once against the seven generated tests (of which the one that fails on correct code is excluded from the comparison, since it already fails).

FaultAcceptance examplesGenerated tests
The window loop runs one step too far (offset <= searchDays), which is attempt 2’s own faultCaught: EX4, EX5Survived
The window loop stops one step earlyCaught: EX3, EX5, EX6Survived
A stay of days occupies days - 1 daysCaught: EX2, EX9Caught: returnsEmptyWhenTheWindowIsFullyBooked, skipsALongStayThatDoesNotFit
The search skips from itselfCaught: EX3, EX4, EX5, EX6Caught: returnsFromWhenNothingIsBooked
noneMatch becomes allMatchCaught: EX1, EX2, EX3, EX5, EX6, EX8, EX9Caught: three tests
Overlap treats an end date as occupied (inclusive end)Caught: EX1, EX2, EX5, EX6, EX8Caught: findsTheFirstFreeDayAfterABooking, skipsALongStayThatDoesNotFit
days = 0 is acceptedSurvived (equivalent)Survived
searchDays = 0 is acceptedCaught: EX7Survived
The caller’s list is kept, not copiedCaught: EX8Survived
A null from is not rejected earlySurvived (equivalent)Survived

Totals: the acceptance examples caught 8 of 10; the generated tests caught 4 of 10. The two faults that survive the acceptance examples are equivalent to the correct code as far as the examples can tell. With days = 0 the finder’s own check is gone, but LoanPeriod’s constructor throws IllegalArgumentException on a period that does not begin before it ends. With a null from the early check is gone, but from.plusDays throws NullPointerException. Each example asserts only the exception type. The faults change where the exception is raised and not what a caller sees, which is the same kind of surviving equivalent mutant as in Chapters 7 and 11.

Notice the first row. The fault that attempt 2 actually contained, a window one step too long, is the one the generated tests cannot catch, because they were derived from code that contains it. The experiment is ten faults chosen by the author on one small class. It says nothing about assistants or tests in general.

The results establish the selected behaviours on the supplied inputs. They do not establish how a real assistant would behave on this task, or that the acceptance examples are the right examples.

References

The online references of the student chapter (sources 1–8) were opened on 13 October 2026; see the chapter for the source context.

  1. GitHub Docs, Application card: GitHub Copilot inline suggestions.
  2. Oracle, List (Java SE 21).
  3. Oracle, List (Java SE 17).
  4. Oracle, javac (Java SE 17).
  5. Apache Maven, maven-compiler-plugin: compile mojo.
  6. OWASP, Top 10 for LLM Applications.
  7. Spracklen, J. and others, We Have a Package for You!, arXiv 2406.10279.
  8. Maven Central, org/mockito/.

Appendix A Companion: Code, Explanations and Solutions

Code explanations and solutions for Appendix A Companion: Code, Explanations and Solutions.

Programming Readiness and Notation
OO Development and Agile Practices

1. Scope and execution

This file accompanies Appendix A, Programming Readiness and Notation. It supplies a small standalone Java 17 lab, explanations, the D1–D10 answers and the offline activity solution. Attempt the diagnostic before reading Section 4.

The lab is an isolated Book example, not a replacement for any lending-project version. StudentRules repeats the initial student numeric rule to demonstrate interface calls. It deliberately does not add staff or visitor policy, reservation storage or a directory.

Save the four complete listings in Section 2 into one new directory. They use the unnamed package to keep this syntax exercise small; the running application uses named packages. No Maven, JUnit or external dependency is needed.

From that directory, using a full Java 17 JDK:

javac --release 17 -Xlint:all -d out BorrowingPolicy.java StudentRules.java LoanPeriod.java ReadinessLab.java
java -cp out ReadinessLab

These commands work in a terminal on Windows, macOS or Linux when the JDK tools are on the path. The compiler creates out if needed. --release 17 restricts the language/API target; it does not switch a newer installed runtime to Java 17. Check java -version separately [1].

Expected output:

Policy boundary: true, false
Identity/value: false, true
Alias contents: [CAM1]
Snapshot/source: [CAM1] / [CAM1, CAM2]
Adjacent periods overlap: false
READINESS CHECKS PASSED: 27

The lab creates a temporary UTF-8 file, reads it and removes it. It also exercises a deliberately missing file in the same temporary directory. It does not access a network service. A failed check throws AssertionError and stops execution; the checks do not depend on enabling Java’s assert keyword.

2. Complete source files

2.1 BorrowingPolicy.java

public interface BorrowingPolicy {
    boolean mayRequest(int count, int days);
}

2.2 StudentRules.java

public final class StudentRules implements BorrowingPolicy {
    @Override
    public boolean mayRequest(int count, int days) {
        return count >= 0 && count < 3 && days >= 1 && days <= 7;
    }
}

2.3 LoanPeriod.java

import java.time.LocalDate;
import java.util.Objects;

public final class LoanPeriod {
    private final LocalDate start;
    private final LocalDate endExclusive;

    public LoanPeriod(LocalDate start, LocalDate endExclusive) {
        this.start = Objects.requireNonNull(start, "start");
        this.endExclusive = Objects.requireNonNull(endExclusive, "endExclusive");
        if (!start.isBefore(endExclusive)) {
            throw new IllegalArgumentException("End must be after start.");
        }
    }

    public LocalDate start() {
        return start;
    }

    public LocalDate endExclusive() {
        return endExclusive;
    }

    public boolean overlaps(LoanPeriod other) {
        Objects.requireNonNull(other, "other");
        return start.isBefore(other.endExclusive)
                && other.start.isBefore(endExclusive);
    }
}

2.4 ReadinessLab.java

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

public final class ReadinessLab {
    private static int checks;

    // These nested types keep the isolated demonstration in four source files.
    enum ItemKind { CAMERA, MICROPHONE }

    record Request(String borrowerId, int days) {
        Request {
            Objects.requireNonNull(borrowerId, "borrowerId");
            if (borrowerId.isBlank() || days < 1) {
                throw new IllegalArgumentException("A request needs an id and positive days.");
            }
        }
    }

    public static void main(String[] args) throws IOException {
        BorrowingPolicy policy = new StudentRules();
        check(policy.mayRequest(2, 7), "third reservation allowed");
        check(!policy.mayRequest(3, 7), "fourth reservation rejected");
        check(!policy.mayRequest(0, 0), "zero days rejected");
        check(!policy.mayRequest(-1, 2), "negative count rejected");
        check(policy.mayRequest(0, 1), "minimum duration allowed");
        check(!policy.mayRequest(0, 8), "overlong duration rejected");
        System.out.println("Policy boundary: " + policy.mayRequest(2, 7)
                + ", " + policy.mayRequest(3, 7));

        String left = new String("CAM1");
        String right = new String("CAM1");
        check(left != right, "distinct string objects");
        check(left.equals(right), "equal string values");
        System.out.println("Identity/value: " + (left == right) + ", " + left.equals(right));

        List<String> first = new ArrayList<>();
        List<String> second = first;
        second.add("CAM1");
        second = new ArrayList<>();
        check(first.equals(List.of("CAM1")), "alias mutation reaches first");
        check(second.isEmpty(), "reassignment selects another list");
        final List<String> mutable = new ArrayList<>();
        mutable.add("CAM1");
        check(mutable.size() == 1, "final reference permits object mutation");
        System.out.println("Alias contents: " + first);

        List<String> source = new ArrayList<>(List.of("CAM1"));
        List<String> snapshot = List.copyOf(source);
        source.add("CAM2");
        check(snapshot.equals(List.of("CAM1")), "snapshot does not grow");
        check(source.size() == 2, "source grows");
        expect(UnsupportedOperationException.class, snapshot::clear, "snapshot is unmodifiable");
        System.out.println("Snapshot/source: " + snapshot + " / " + source);

        StringBuilder name = new StringBuilder("CAM1");
        List<StringBuilder> shallow = List.copyOf(List.of(name));
        name.append("-serviced");
        check(shallow.get(0).toString().equals("CAM1-serviced"), "elements are not deep-copied");

        LocalDate start = LocalDate.of(2026, 10, 20);
        LoanPeriod period = new LoanPeriod(start, start.plusDays(2));
        LoanPeriod adjacent = new LoanPeriod(start.plusDays(2), start.plusDays(4));
        LoanPeriod crossing = new LoanPeriod(start.plusDays(1), start.plusDays(3));
        check(!period.overlaps(adjacent), "adjacent periods do not overlap");
        check(period.overlaps(crossing), "crossing periods overlap");
        check(period.overlaps(new LoanPeriod(start, start.plusDays(2))), "identical bounds overlap");
        expect(IllegalArgumentException.class, () -> new LoanPeriod(start, start), "empty period rejected");
        expect(NullPointerException.class, () -> new LoanPeriod(null, start), "missing date rejected");
        check(!period.equals(new LoanPeriod(start, start.plusDays(2))), "ordinary class has identity equality here");
        System.out.println("Adjacent periods overlap: " + period.overlaps(adjacent));

        long cameras = source.stream().filter(id -> id.startsWith("CAM")).count();
        check(cameras == 2, "stream counts selected elements");
        Optional<String> missing = Optional.empty();
        check(missing.orElse("No item").equals("No item"), "explicit absence has an alternative");
        check(new Request("B1", 2).equals(new Request("B1", 2)), "record has component equality");
        String label = switch (ItemKind.CAMERA) {
            case CAMERA -> "Camera";
            case MICROPHONE -> "Microphone";
        };
        check(label.equals("Camera"), "switch expression selects a result");

        Path directory = Files.createTempDirectory("se506-readiness-");
        Path file = directory.resolve("sample.txt");
        try {
            Files.writeString(file, "CAM1\nCAM2\n", StandardCharsets.UTF_8);
            check("CAM1".equals(readFirstLine(file)), "resource read returns first line");
            try {
                readFirstLine(directory.resolve("absent.txt"));
                throw new AssertionError("missing file must fail");
            } catch (IOException expected) {
                check(true, "checked file failure propagated");
            }
        } finally {
            Files.deleteIfExists(file);
            Files.deleteIfExists(directory);
        }
        System.out.println("READINESS CHECKS PASSED: " + checks);
    }

    static String readFirstLine(Path path) throws IOException {
        try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            return reader.readLine();
        }
    }

    private static void check(boolean condition, String description) {
        if (!condition) {
            throw new AssertionError(description);
        }
        checks++;
    }

    private static void expect(Class<? extends RuntimeException> type,
                               Runnable operation, String description) {
        try {
            operation.run();
        } catch (RuntimeException exception) {
            if (!type.isInstance(exception)) {
                throw new AssertionError(description + ": wrong exception", exception);
            }
            checks++;
            return;
        }
        throw new AssertionError(description + ": no exception");
    }
}

3. Worked explanation

The lab keeps each observation small. check reports a mismatch immediately. expect runs an operation that should throw a particular run-time exception, verifies the type, and fails if no exception occurs. The Runnable parameter and method reference are small functional examples; Chapter 7 introduces the testing framework used by the running project.

The missing-file check catches IOException specifically because its purpose is to observe a declared file-reading failure. It does not convert an outage into a successful domain lookup. The finally block cleans up this example’s temporary files. The try-with-resources statement inside readFirstLine closes the reader before that cleanup.

Two equality checks deliberately differ. The ordinary LoanPeriod class inherits identity-based equals, whereas Request is a record with generated component-based equality. The lab does not recommend one representation for every domain concept; it makes the actual behaviour visible.

The StringBuilder example is also intentional: it demonstrates why an unmodifiable collection and deeply immutable contents are separate properties. Do not copy that mutable element choice into the lending domain merely because it appears in a diagnostic.

A small fault experiment

In a separate copy of the four files, change count < 3 in StudentRules to count <= 3. Compile and run again. The check named fourth reservation rejected must fail. Restore the original comparison afterwards.

This experiment checks whether a specific boundary assertion detects its targeted fault. It does not establish complete correctness or a mutation score for the whole book.

4. Diagnostic answers

ItemAnswer and explanation
D1true, false, false, false. Using <= 3 allows a request when three already exist. The method checks two numbers; it does not check availability or store a reservation.
D2first contains CAM1. second.add mutates the shared list. Reassigning second then points that variable at a different, empty list; it does not undo the first mutation.
D3false, then true: two explicitly created objects, equal string contents. Matching text does not require reference identity.
D4Adding compiles; reassigning the final variable does not. The list object remains mutable.
D5Source: CAM1, CAM2; snapshot: CAM1. Clearing the snapshot throws UnsupportedOperationException. Mutable element state can still change through shared references.
D6Reject either missing date and an end equal to or before the start. The adjacent periods do not overlap; the 22nd belongs to the second period only.
D7Declared type: BorrowingPolicy; implementation: StudentRules. Interface syntax establishes callable operations, not proof of the policy’s correctness.
D8Missing declared method: compile-time error. Null receiver: run-time NullPointerException. Wrong comparison: a logical defect that can compile and execute normally.
D9No match is a completed search result; unavailable means the search could not establish an answer. A checked IOException must be handled or propagated according to Java’s rules.
D10Department-to-item: one department for each item, zero or more items for each department. Item-to-reservation: one item for each reservation, zero or more reservations for each item. The scenario specifies links and counts, not composite ownership/deletion semantics.

D10: one acceptable diagram

This is a scenario model, not a listing of the appendix lab or the current application’s implemented classes.

classDiagram
    direction TB
    class Department
    class EquipmentItem
    class Reservation {
        -String id
        -LocalDate start
        -LocalDate endExclusive
    }
    Department "1" -- "0..*" EquipmentItem : keeps
    EquipmentItem "1" -- "0..*" Reservation : reserved by

Both lines are ordinary associations. Navigation is unspecified because the scenario does not say which object stores a reference. The three attributes stand for a reservation identifier and the period bounds; a solution that models LoanPeriod explicitly is also reasonable if it explains the additional class.

A list of items in a department’s Java implementation would not prove that each item belongs to only one department. That inverse constraint needs a domain rule and enforcement. Likewise, deleting an item need not delete its reservation history. A filled diamond would add a lifecycle claim the scenario did not provide.

Relationship-reading practice

To practise the remaining symbols without enlarging D10, interpret these independent Mermaid relationship fragments. They are notation exercises, not complete diagrams or new project designs.

FragmentInterpretation
Pack *-- PagePack is the composite whole; Page is the part. The model must justify exclusive composite ownership and the intended lifecycle.
Pack o-- ArticlePack groups articles; explain what shared aggregation means in that model. A plain association may be sufficient.
Article <|-- ResearchArticleResearchArticle specialises Article; the triangle points to Article.
Printer <|.. PdfPrinterPdfPrinter implements Printer; the triangle points to the interface.
Pack ..> PrinterPack depends on Printer; for example, it calls a printer supplied to an operation.

Reading practice: sequence, state and flowchart fragments

Interpret these independent Mermaid fragments. They are notation exercises, not complete diagrams or project designs.

FragmentInterpretation
U->>R: mayRequest(2, 7) followed by R-->>U: trueU calls R, and R returns true. Nothing in the fragment says that a reservation was stored.
alt Count below the limit … else Count at the limit … endTwo alternatives. Exactly one happens in a given run.
AVAILABLE --> RESERVED: reserveWhen the event reserve happens in the state AVAILABLE, the object moves to RESERVED.
AVAILABLE --> RETIRED: retire [no open reservation]The same kind of transition, with a guard. It fires only if the condition holds.
RETIRED --> [*]RETIRED is an end state. No further event moves the object out of it.
B{"Count below the limit?"} with one exit labelled yes and one labelled noA decision with two labelled exits. Each path continues from the exit that matches the answer.
I --> A in a dependency viewCode in I may refer to code in A. It says nothing about which of them calls the other at run time.

Two further questions. In Figure A.3, what does the absence of an arrow from ON_LOAN to RETIRED mean? The model does not allow retire in that state; the diagram does not say what the program does if the event arrives anyway. Why does the arrow in Figure A.5 point from the storage adapter to the application although the application calls the adapter at run time? Because the arrow shows which code refers to which code in the source, and the adapter’s source refers to the contract that the application owns.

5. Offline AI-activity solution

The candidate in A.9 is constructed for teaching; it is not a captured model response.

Candidate claimVerdictCorrection and evidence
“The list is final, so callers cannot clear it.”IncorrectA final reference cannot be reassigned, but the referenced ArrayList supports mutation. Whether a caller can reach it is a separate encapsulation question.
“Copying a list makes all its elements immutable.”IncorrectA structural copy retains element references. The executable StringBuilder example changes what the snapshot’s element contains.
“Every object created in a constructor should have a composition diamond.”IncorrectConstruction location does not establish the UML ownership and lifecycle semantics. Use the domain contract, not the presence of new, to choose the relationship.

An adequate record names the claim and the actual check. “The assistant was wrong” is less useful than identifying the reassignment/mutation confusion and the counterexample.

6. Teaching and revision notes

Ask learners to predict a result before running it. When a prediction differs from the output, have them trace the references or the comparison boundary rather than memorise the printed answer. Learners new to Java may need A.2–A.3 before the Chapter 1 orientation; experienced Java programmers can use the diagnostic to identify only the sections they need.

D10 consolidates Chapter 2’s notation lesson. It should not become a requirement to study all UML before starting the book. Generalisation, realisation, association, aggregation, composition and multiplicity are reference topics here; the approved assessment material determines examination scope.

Do not add these checks to the running project’s test totals. Keep setup-tool details in Appendix B and version reconciliation in Appendix D. The lab’s fixed dates are example inputs, not a reading of the machine’s current date.

References

  1. Oracle, The javac Command, Java SE 17, checked 13 October 2026. Compilation target and command options. The Java, UML and Mermaid references that support the syntax explanations are listed in the main appendix.

Appendix B Companion: Code, Explanations and Solutions

Code explanations and solutions for Appendix B Companion: Code, Explanations and Solutions.

Tools and Reproducible Setup
OO Development and Agile Practices

1. Scope and use

This file accompanies Appendix B, Tools and Reproducible Setup. It supplies a complete small starter project, a scripted Git walk-through, explanations of each file and the answers to the activity in B.12.

The starter is an isolated Book example, not a lending-project version. Its only purpose is to show that the toolchain works: a Java 17 release target, Maven with pinned JUnit and Mockito, two Bash scripts and a hosted-check file. It does not change the project and its tests do not belong in the project’s test totals.

Save every listing in Section 2 at its path inside one new folder, for example toolchain-check. Use a Java 17 or later JDK, Git Bash or a Linux or macOS terminal, and Maven 3.9.11. From that folder:

bash scripts/check-setup.sh
bash scripts/check.sh

The first Maven build needs access to Maven Central. If your machine has no network access, build once somewhere that has it and copy the populated local Maven repository folder across, then run with the two variables below. CHAPTER_M2 names that folder and CHAPTER_OFFLINE=1 makes Maven use only what it holds:

CHAPTER_M2=/path/to/repository CHAPTER_OFFLINE=1 bash scripts/check.sh

If mvn is not on the path, give the full path to the Maven launcher in CHAPTER_MVN (on Windows it ends in mvn.cmd).

2. Complete starter files

2.1 pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>toolchain-check</artifactId>
  <version>0.1.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>5.20.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

2.2 src/main/java/edu/se506/setup/ToolchainReport.java

package edu.se506.setup;

/** Reports which Java runtime is executing and which release the code was compiled for. */
public final class ToolchainReport {
    /** The language and API level the build targets (see maven.compiler.release in pom.xml). */
    public static final int RELEASE_TARGET = 17;

    private ToolchainReport() {
    }

    public static int javaFeatureVersion() {
        return Runtime.version().feature();
    }

    /** Code compiled for a release runs on that Java version and on every later one. */
    public static boolean canRunOn(int runtimeFeature) {
        return runtimeFeature >= RELEASE_TARGET;
    }

    public static String summary() {
        return "Running on Java " + javaFeatureVersion()
                + "; compiled for release target " + RELEASE_TARGET;
    }

    public static void main(String[] args) {
        System.out.println(summary());
    }
}

2.3 src/test/java/edu/se506/setup/ToolchainReportTest.java

package edu.se506.setup;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class ToolchainReportTest {

    @Test
    void theRunningJavaCanRunCodeCompiledForTheTarget() {
        assertTrue(ToolchainReport.canRunOn(ToolchainReport.javaFeatureVersion()));
    }

    @Test
    void theSummaryNamesTheReleaseTarget() {
        assertTrue(ToolchainReport.summary().endsWith("compiled for release target 17"));
    }

    @ParameterizedTest
    @ValueSource(ints = {17, 21, 24})
    void javaVersionsAtOrAboveTheTargetCanRunTheCode(int runtimeFeature) {
        assertTrue(ToolchainReport.canRunOn(runtimeFeature));
    }

    @Test
    void aJavaVersionBelowTheTargetCannotRunTheCode() {
        assertFalse(ToolchainReport.canRunOn(16));
    }
}

2.4 src/test/java/edu/se506/setup/GreeterMockTest.java

package edu.se506.setup;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;

/** Checks that Mockito is configured. It tests the framework setup, not any domain behaviour. */
class GreeterMockTest {

    interface Greeter {
        String greet(String name);
    }

    @Test
    void aMockOfAnInterfaceAnswersWhatItIsTold() {
        Greeter greeter = mock(Greeter.class);
        when(greeter.greet("Ada")).thenReturn("Hello, Ada");

        assertEquals("Hello, Ada", greeter.greet("Ada"));
        verify(greeter).greet("Ada");
    }
}

2.5 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker

mock-maker-proxy

2.6 scripts/check-setup.sh

#!/usr/bin/env bash
# Reports whether the tools the book needs are on the PATH and recent enough.
# It changes nothing and downloads nothing. A line that starts with OK, MISSING or TOO-OLD
# is easy to search for; the exit status is 1 if any required tool is not usable.
# CHAPTER_MVN optionally names the Maven command (default: mvn).
set -u

MVN="${CHAPTER_MVN:-mvn}"
status=0

report() {
  printf '%-8s %s\n' "$1" "$2"
}

# Report the first line of the output of a tool's version command.
need() {
  local label="$1"
  shift
  if command -v "$1" >/dev/null 2>&1 || [ -f "$1" ]; then
    report "OK" "${label}: $("$@" 2>&1 | head -1)"
  else
    report "MISSING" "${label}: '$1' is not on the PATH"
    status=1
  fi
}

need "bash" bash --version
need "git" git --version
need "java" java -version
need "maven" "$MVN" --version

if command -v javac >/dev/null 2>&1; then
  feature="$(javac -version 2>&1 | sed -n 's/^javac \([0-9][0-9]*\).*/\1/p')"
  if [ "${feature:-0}" -ge 17 ]; then
    report "OK" "javac: compiler version ${feature} can compile with --release 17"
  else
    report "TOO-OLD" "javac: version '$(javac -version 2>&1)' cannot compile with --release 17; install a JDK of version 17 or later"
    status=1
  fi
else
  report "MISSING" "javac: a JDK (not only a JRE) is needed"
  status=1
fi

if [ "$status" -eq 0 ]; then
  echo "SETUP OK"
else
  echo "SETUP INCOMPLETE"
fi
exit "$status"

2.7 scripts/check.sh

#!/usr/bin/env bash
# The one command that decides whether the starter is healthy: compile, run every test, package.
# CHAPTER_MVN names the Maven command (default: mvn). CHAPTER_M2 optionally names a local Maven
# repository, and CHAPTER_OFFLINE=1 stops Maven from contacting any remote repository.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi
if [ "${CHAPTER_OFFLINE:-0}" = "1" ]; then
  ARGS+=(--offline)
fi

"$MVN" "${ARGS[@]}" verify
java -cp target/classes edu.se506.setup.ToolchainReport
echo "BUILD AND TESTS PASSED"

2.8 .gitignore

/target/
/build/
*.class

2.9 .gitattributes

*.sh text eol=lf

2.10 .github/workflows/verify.yml

name: Verify

on:
  push:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
          cache: maven
      - name: Build and test
        run: bash scripts/check.sh

2.11 README.md

# Toolchain check

A small project that shows the book's toolchain works: a Java 17 release target, Maven 3.9.11
with JUnit Jupiter 5.13.4 and Mockito 5.20.0, and the Bash scripts. It is not part of the lending project.

## Check the tools

```bash
bash scripts/check-setup.sh
```

Every line should start with `OK`, and the last line should be `SETUP OK`.

## Build and test

```bash
bash scripts/check.sh
```

Expect `BUILD SUCCESS`, seven test invocations (five test methods; one is parameterised with
three values), the line `Running on Java N; compiled for release target 17` and, last,
`BUILD AND TESTS PASSED`. A failing test must stop the script with a non-zero exit status.

Optional variables: `CHAPTER_MVN` names the Maven command, `CHAPTER_M2` names a local Maven
repository folder and `CHAPTER_OFFLINE=1` stops Maven from contacting any remote repository.

3. Worked explanation

3.1 The POM

maven.compiler.release set to 17 makes the Compiler plugin pass --release 17 to javac, so the build rejects any API newer than Java 17 even on a newer JDK (B.1). project.build.sourceEncoding fixes the source encoding so that the build does not depend on the machine’s default.

Both dependencies have the scope test: they are on the class path for the tests and absent from the packaged application. junit-jupiter is an aggregate artifact that brings the API, the parameterised-test support and the engine. Every plugin that the book relies on has an exact version, so a build today and a build next year resolve the same plugins. The Surefire setting failIfNoTests makes a build with no tests fail. Without it, a mistyped test location can produce an empty run that looks green, the same reason Chapter 13’s pipeline fails a stage that selects no tests.

3.2 The two scripts

check-setup.sh deliberately does not use set -e. It has to examine every tool and report all the problems in one run, so it records a status and exits with it at the end. The helper need runs a tool’s version command and prints the first line of its output; the extra test [ -f "$1" ] accepts a tool named by a full file path, which command -v does not always accept for a Windows .cmd launcher. The compiler gets its own check because a machine can have a Java runtime and no compiler, and because a compiler older than 17 cannot honour --release 17.

check.sh is the opposite: it is a gate. set -euo pipefail stops it at the first failing command and makes unset variables an error, so the success message at the end cannot appear unless Maven succeeded. The script changes into the project folder first, so it works from any directory. It reads three optional variables and builds the Maven argument list from them, which keeps the same file usable on a laptop, on a machine with no network and in a hosted check.

3.3 The tests

ToolchainReportTest has four methods and six invocations because one method is parameterised with three values. The first method checks that the Java running the test can run code built for the target. The summary test pins the exact wording that reports the target. The parameterised test checks the boundary at 17 from above, and the last method checks it from below with 16, the same two-sided reading you practised on the borrowing limit in Appendix A. GreeterMockTest creates a mock of an interface, tells it what to return and verifies the call. It checks that Mockito is configured, not any domain behaviour; do not copy that pattern into a design test.

3.4 Repository files

.gitignore keeps generated output out of the repository. .gitattributes keeps shell scripts in LF form on Windows. The workflow runs scripts/check.sh after installing Java 17, so the hosted run and the local run are the same command. The workflow’s action versions are those named in B.1; check the current versions before you reuse it, because tags and runner images change. It has no effect until it runs on a repository, and it can be started by hand through workflow_dispatch.

4. Expected output

The setup check on a machine where everything is in place (the version text will differ):

OK       bash: GNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)
OK       git: git version 2.54.0.windows.1
OK       java: java version "24.0.2" 2025-07-15
OK       maven: Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b)
OK       javac: compiler version 24 can compile with --release 17
SETUP OK

The build, reduced to its important lines:

[INFO] Compiling 1 source file with javac [debug release 17] to target\classes
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 ... in edu.se506.setup.GreeterMockTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 ... in edu.se506.setup.ToolchainReportTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Running on Java 24; compiled for release target 17
BUILD AND TESTS PASSED

The Java N figure is the runtime you used. Maven and Mockito may print the warnings described in B.4 and B.5 between these lines.

5. A small fault experiment

In a separate copy of the starter, make one change at a time, run the named command and then discard the copy. Each experiment checks that a particular gate notices a particular fault.

ChangeCommandWhat must happen
Name a Maven command that does not exist: CHAPTER_MVN=/nonexistent/mvnbash scripts/check-setup.shA MISSING maven line, SETUP INCOMPLETE and exit status 1.
Put a fake javac that prints javac 1.8.0_999 first on the pathbash scripts/check-setup.shA TOO-OLD javac line and exit status 1.
In ToolchainReport.canRunOn, change >= to >bash scripts/check.shOne failing invocation (the parameterised case for 17), BUILD FAILURE, a non-zero status, and no BUILD AND TESTS PASSED line.
The same changemvn --batch-mode -DskipTests verifyTests are skipped. and BUILD SUCCESS: the fault is not detected.
The same changemvn --batch-mode -Dmaven.test.failure.ignore=true verifyThe failure is printed, yet BUILD SUCCESS and exit status 0: the fault is hidden.

The last two rows show why a green build proves nothing unless you know what was run. They also correct two common habits: skipping tests is not a way to confirm that a project works, and telling Maven to ignore failures does not make failures go away. These experiments check that the gates catch the chosen faults. They do not establish that the starter is correct for every input or on every platform.

6. The Git walk-through

git-walkthrough.sh copies the starter into a work folder and then performs every command of B.8 in order: create the repository and a first commit, branch, inspect, stage, commit, discard an edit, unstage a file, revert a commit, merge, share through a bare repository and clone it again. The bare repository is a folder on the same machine, so no account and no network are involved. The author identity is set for the new repository only, and the script touches no global Git setting.

#!/usr/bin/env bash
# A complete Git walk in a throwaway folder: commit, branch, inspect, undo, merge and share.
# Usage: bash git-walkthrough.sh /path/to/starter /path/to/empty-work-folder
# It uses only local folders. The "remote" is a bare repository on this machine, so no account
# and no network are involved. The author identity is set for this repository only.
set -euo pipefail

STARTER="$1"
WORK="$2"
mkdir -p "$WORK"
cp -r "$STARTER/." "$WORK/project"
rm -rf "$WORK/project/target"
cd "$WORK/project"

step() { printf '\n--- %s\n' "$1"; }

step "1 create the repository and the first commit"
git init -b main
git config user.name "Example Student"
git config user.email "[email protected]"
git add README.md pom.xml src scripts .gitignore .gitattributes .github
git status --short
git commit -m "Add the toolchain starter"

step "2 a branch for one small change"
git switch -c feature/clearer-summary
FILE=src/main/java/edu/se506/setup/ToolchainReport.java
sed 's/Running on Java /Running on Java version /' "$FILE" > "$FILE.tmp"
mv "$FILE.tmp" "$FILE"
git status --short
git diff

step "3 stage it, compare staged and unstaged, commit"
git add "$FILE"
git diff --staged --stat
git diff --stat
git commit -m "Say 'version' in the runtime summary"

step "4 discard an edit, and unstage a file"
echo "/scratch/" >> .gitignore
git status --short
git restore .gitignore
git status --short
echo "notes" > NOTES.txt
git add NOTES.txt
git status --short
git restore --staged NOTES.txt
git status --short
rm NOTES.txt

step "5 reverse a commit by adding a new one"
git revert --no-edit HEAD
git log --oneline

step "6 merge back and look at the history"
git switch main
git merge feature/clearer-summary
git log --oneline --graph --decorate

step "7 share through a bare repository acting as the remote"
git init --bare -b main "$WORK/remote.git"
git remote add origin "$WORK/remote.git"
git push -u origin main
git push -u origin feature/clearer-summary
git branch -vv
git ls-remote --heads origin | cut -f2

step "8 a second clone sees the same history"
git clone "$WORK/remote.git" "$WORK/second-clone"
git -C "$WORK/second-clone" log --oneline | head -3

Run it from any folder:

bash git-walkthrough.sh /path/to/toolchain-check /path/to/new-empty-work-folder

The commit hashes, dates and folder paths in the output will differ. The important lines are these:

--- 2 a branch for one small change
Switched to a new branch 'feature/clearer-summary'
 M src/main/java/edu/se506/setup/ToolchainReport.java
...
--- 4 discard an edit, and unstage a file
 M .gitignore
A  NOTES.txt
?? NOTES.txt
--- 5 reverse a commit by adding a new one
[feature/clearer-summary <hash>] Revert "Say 'version' in the runtime summary"
--- 6 merge back and look at the history
Fast-forward
* <hash> (HEAD -> main, feature/clearer-summary) Revert "Say 'version' in the runtime summary"
* <hash> Say 'version' in the runtime summary
* <hash> Add the toolchain starter
--- 7 share through a bare repository acting as the remote
 * [new branch]      main -> main
 * [new branch]      feature/clearer-summary -> feature/clearer-summary

Read step 3 carefully: after staging the file, git diff --stat prints nothing and git diff --staged --stat prints the change. An empty git diff after staging does not mean there is nothing to commit. In step 4, a file that has been staged shows A in the first column, and after git restore --staged it shows ?? again because it is untracked. Step 5 adds a third commit; the history now shows the original change and the commit that reverses it, not a rewritten past. Step 6 is a fast-forward because main had no commits of its own; on a branch that has diverged, the same command creates a merge commit.

On Windows you will also see lines such as warning: in the working copy of '...', LF will be replaced by CRLF the next time Git touches it. They are warnings from line-ending conversion (B.8), not failures. The script is not a model of a pull request: the review, the comments and the merge button belong to the hosting platform.

7. Answers to the activity in B.12

  1. Setup check. SETUP OK. If a line starts MISSING or TOO-OLD, the named tool is the problem.
  2. First build. Seven invocations from five test methods: one in GreeterMockTest and six in ToolchainReportTest, because the parameterised method contributes three. A good setup note says “seven invocations”, not “seven tests”.
  3. Fault. With > the case 17 fails: expected: <true> but was: <false>. The script prints no success line and exits non-zero. Restoring >= makes it pass again.
  4. Branch and commit. git diff shows the edit; after git add, git diff is empty and git diff --staged shows it; git commit records it.
  5. Setup note. There is no single right answer. A useful note names the versions your machine reported (compare them with B.1), lists the commands run and records every difference from this appendix.

The constructed AI candidate

The candidate in B.12 is constructed for teaching; it is not a captured model response.

Candidate sentenceVerdictEvidence and correction
“Any Java installation will do because the code is plain Java.”IncorrectThe project needs a full JDK of version 17 or later: check-setup.sh reports a missing compiler and a compiler older than 17. A runtime alone cannot compile, and --release 17 needs a compiler that supports it.
“To confirm a project works, run mvn install -DskipTests.”Incorrect-DskipTests prints Tests are skipped. and the build succeeds even when a test would fail (Section 5). It confirms that the code compiles and packages, not that it works.
“Add -Dmaven.test.failure.ignore=true so the build always passes.”IncorrectThe failing test is still printed, but the build ends in BUILD SUCCESS with status 0 (Section 5). Hiding a failure is the opposite of checking.
“Share your work with git add . and git push --force.”Unsafegit add . stages everything, including files you did not mean to share; read git status and name the files. A forced push can overwrite commits other people have pulled. Push a branch normally and review it.

An adequate record names the claim, the check and the conclusion. “The assistant was wrong” is weaker than naming the experiment that shows it.

8. Teaching and revision notes

Ask learners to predict each fault-experiment result before running it. The -DskipTests and failure-ignore rows are worth running in class: they take a minute and change how students read a green build. Learners on machines they do not control may be unable to install tools; the offline route is to run check-setup.sh on a lab machine, read the output and do B.12 steps 3 and 4 on paper.

Keep setup detail here and in Appendix B, not in the design chapters. When a pinned version changes, edit the table in B.1, the POM listings and the version lines in this file’s README, and leave the design explanations alone. Do not add this starter’s test count to the lending project’s totals.

References

  1. Appendix B lists the official Maven, Git, JUnit, Mockito, PMD and GitHub Actions references that support the commands in this file.

Appendix C Companion: Template Kit and Checker

Code explanations and solutions for Appendix C Companion: Template Kit and Checker.

Engineering Templates
OO Development and Agile Practices

1. Scope and use

This file accompanies Appendix C. It supplies a small, complete template kit: thirteen blank templates, thirteen worked examples and a script that checks the structure of a filled-in document. Each template uses the headings of the record that the chapters themselves use, so a document made from the kit follows the chapter that introduced it:

TemplateFirst used inFollows
story-cardChapter 3The story-card fields of the Chapter 3 companion
acceptance-exampleChapter 3The given, when, then form of Section 3.7
definition-of-doneChapters 3 and 4The proposed Definition of Done and its Chapter 4 extension
decision-recordChapters 4, 10, 13, 14The headings of ADR-010, ADR-013 and ADR-014
iteration-planChapter 4The capacity and forecast tables of Section 4.4
team-agreementChapter 4The team agreement of Section 4.5
pull-requestChapters 2 and 4The problem, change and checks of a review description
evidence-noteChapter 4The evidence-note fields of the Chapter 4 companion
requirements-to-evidenceChapter 14docs/REQUIREMENTS-TO-EVIDENCE.md
known-issuesChapter 14docs/KNOWN-ISSUES.md
release-notesChapter 14docs/RELEASE-NOTES-0.14.0.md
retrospectiveChapter 14The observation, evidence, action and owner table of Section 14.6
ai-assistance-recordChapter 15The eight headings of Section 15.8, checked by the lab’s check-record.sh

The examples are constructed to illustrate. They belong to an invented team, are labelled as such in their first line and are not evidence of anything. Do not copy their contents into your own records.

Save every listing in Section 2 and Section 3 at its path inside one new folder, for example template-kit. Use Git Bash or a Linux or macOS terminal. No Java, Maven or network access is needed. From that folder:

bash scripts/check-templates.sh

Using a template well

  1. Start from the question, not from the form. Decide what the document must let a stranger do, such as re-run a check, understand a choice or find the evidence, and write for that person.
  2. Replace every {{placeholder}}. A placeholder left in a document is a promise you did not keep.
  3. Write “none yet” rather than invent. An empty evidence field is honest. A made-up run address or review comment is not. Say what is missing and what the next action is.
  4. Keep the headings. Other people and the script find things by heading. Add a heading if you need one; do not rename or delete one.
  5. Say what is constructed. An example, a sample or a model answer is labelled as one.
  6. Keep it with the work. Commit the document beside the code it describes, so that a change to one prompts a change to the other.
The script can checkOnly a reviewer can check
Every required heading is presentThat the answer under it is correct
No heading is emptyThat “reviewed and verified” describes a real review
No {{placeholder}} is leftThat the evidence named is the right evidence
An example is labelled as constructedThat a hosted run, a reviewer or a date exists

2. The templates

2.1 templates/story-card.md

# {{ID}}: {{short outcome}}

## Actor and value
{{Who needs this, and why. One or two sentences.}}

## Scope
{{What this item includes, and the boundary of the demonstration.}}

## Rules and assumptions
{{Confirmed decisions first. Then assumptions, each marked as temporary.}}

## Acceptance examples
{{Inputs, action, expected result and state effects. Link each to its acceptance-example file.}}

## Current support
{{Existing code or evidence, or "none".}}

## Dependencies and questions
{{Blocking decisions, related items, and who follows them up.}}

## Estimate
{{Optional: a relative size, its assumptions and the date it was discussed.}}

## Completion evidence
{{Links or notes for checks, review, integration and demonstration. Say "not started" until something exists.}}

2.2 templates/acceptance-example.md

# {{AC-ID}}: {{the criterion in one sentence}}

## Given
{{The starting state, with concrete values.}}

## When
{{The one action, with concrete values.}}

## Then
{{What the caller observes: a result, a message or an error.}}

## State afterwards
{{What stored data must be unchanged or changed. Write "unchanged" when it is.}}

## Evidence
{{The check that runs this example, and the run that executed it.}}

2.3 templates/definition-of-done.md

# Definition of Done: {{team or project}}

## Applies to
{{What counts as an increment, and the version or date of this definition.}}

## Conditions
1. {{A condition that another person could check.}}
2. {{Another condition.}}

## Not covered
{{What meeting this definition does not promise.}}

## Ready policy
{{Optional: what the team needs to know before it starts an item.}}

## Agreement and review
{{Who agreed it, when, and what would make the team revisit it.}}

2.4 templates/decision-record.md

# ADR-{{NNN}}: {{the question and the choice}}

Date: {{date}}
Status: {{proposed, accepted, or superseded by ADR-NNN}}

## Context
{{The forces at play: the problem, the constraints, and what must not change.}}

## Decision
{{What was decided, in full sentences.}}

## Alternatives considered
- {{An alternative, and why it was not chosen.}}

## Consequences
{{What becomes easier, what becomes harder, and what is still unsolved.}}

## Validation and review
{{How the decision was checked, and by whom. Do not claim a review that did not happen.}}

## Revisit triggers
{{The conditions that would reopen this decision.}}

2.5 templates/iteration-plan.md

# Iteration plan: {{team}}, {{dates}}

## Iteration goal
{{One sentence naming an outcome a user could observe.}}

## Capacity
| Person | Hours available |
|---|---:|
| {{name or initials}} | {{hours}} |

Reserved for coordination, integration and administration: {{hours}}.
Planning budget: {{hours}}.

## Forecast work
| Work | Person-hours |
|---|---:|
| {{task}} | {{hours}} |

Selected work: {{total}}. Unallocated: {{budget minus selected work}}.

## Calendar and skill constraints
{{Who is available when, who knows what, and when the review can happen.}}

## When to replan
{{The evidence that would make the team change the forecast, and who decides.}}

2.6 templates/team-agreement.md

# Team agreement: {{team}}

## Where things live
{{Where requirements, questions, decisions and evidence are kept.}}

## How work moves
{{The board columns, the limit on work in progress, and what blocked work does.}}

## Branches and review
{{How big a branch may be, who reviews, and what a reviewer looks at.}}

## Checks
{{Which check the author runs, when, and where its result is recorded.}}

## Gaps and unavailable evidence
{{What the team does when a review or a hosted run is not available.}}

## Review of this agreement
{{When the team rereads this, and how a change is recorded.}}

2.7 templates/pull-request.md

# {{short title of the change}}

## Problem
{{What is wrong or missing, and for whom.}}

## Change
{{What this change does, and what it deliberately does not do.}}

## Checks run
{{The commands, the results, and where the output is kept.}}

## Review focus
{{What the reviewer should look at first, and why it is the risk.}}

## Limits
{{What the checks do not show, and what was not run.}}

2.8 templates/evidence-note.md

# Evidence note: {{team}}, {{date}}

## Repository and baseline revision
{{The repository and the exact revision this note describes.}}

## Implemented behaviour
{{Only the behaviour present at that revision.}}

## Local environment and command
{{The Java version, the shell, and the command that was run.}}

## Local check result
{{The observed exit status and the relevant output.}}

## Hosted passing run
{{The run address or retained log, the revision it tested, and the date. Or "none yet" and the next action.}}

## Deliberate failure
{{The practice revision, the failed assertion, and the failed run. Or "not done yet".}}

## Restored pass
{{The restored revision and its passing run. Or "not done yet".}}

## Independent review
{{The actual reviewer, the finding, the correction and the follow-up. Or "none yet".}}

## Unresolved questions
{{Each with an owner, its impact and the next decision point.}}

## Known limitations
{{Scope boundaries that matter for reading the demonstration.}}

2.9 templates/requirements-to-evidence.md

# Requirements to evidence: {{project}}

## Table
| Requirement or example | Class that decides it | Test that checks it | Stage that runs it |
|---|---|---|---|
| {{ID and one line}} | {{class}} | {{test class and method}} | {{script or pipeline stage}} |

## How this table is kept true
{{The script or test that fails when a name in the table no longer exists.}}

## What this table cannot show
{{For example: that a named test is the right test for its requirement.}}

2.10 templates/known-issues.md

# Known issues: {{project}}

## Issues
| ID | What is wrong or missing | Effect | Decision | Owner | Review trigger |
|---|---|---|---|---|---|
| {{KI-n}} | {{description}} | {{who is affected, how}} | {{fix, accept or defer, and why}} | {{name}} | {{what would reopen it}} |

## How this list is kept true
{{Who reads it, and when. Every row needs a decision, not only a description.}}

2.11 templates/release-notes.md

# Release notes {{version}}

Date: {{date}}

## Summary
{{One or two sentences. Say whether production behaviour changed.}}

## Added
{{New behaviour, or "nothing".}}

## Changed
{{Changed behaviour, or "nothing".}}

## Fixed
{{Defects fixed, or "nothing".}}

## Behaviour you should know about
{{Behaviour a user or a maintainer could misread.}}

## Not verified
{{What was not run or not checked for this version.}}

2.12 templates/retrospective.md

# Retrospective: {{team}}, {{period}}

## Period and evidence brought
{{The dates, and the artefacts the team brought: board, runs, reviews, defects.}}

## What the evidence shows
| Observation | Evidence | Action | Owner and date |
|---|---|---|---|
| {{what the team says}} | {{the artefact that shows it}} | {{one change}} | {{name, next iteration}} |

## Process note
{{Which adaptations of the process helped, which hurt, and what to change.}}

## Actions agreed
{{At most three, each with an owner and a date.}}

## Last retrospective's actions
{{What happened to each action agreed last time.}}

2.13 templates/ai-assistance-record.md

# AI assistance record: {{task}}

## Task
{{What was asked, in one or two sentences, and who owns the requirement.}}

## Tool and model
{{The tool and model, or "none: constructed example", and the date. If no tool was available, how the exercise was done instead.}}

## Context given
{{What the assistant was shown, and what was left out and why. No secrets or personal data.}}

## Output received
{{Each candidate in order, marked accepted or rejected. Keep the rejected ones.}}

## Decision
{{What was accepted or rejected, and the reason for each.}}

## Changes made
{{What you changed by hand, and why.}}

## Verification
{{The checks, the commands, the results and the date. Say which checks are independent of the assistant.}}

## Limits
{{What the verification does not show, and what was not run.}}

3. The worked examples

3.1 examples/story-card.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# US-02: Inspect availability without reserving

## Actor and value
As an equipment officer, I want to see whether one item is free for a stated period, so that I can discuss a possible reservation without creating one.

## Scope
One item and one period per enquiry. No reservation is created or changed. Searching several items at once is outside this item.

## Rules and assumptions
Confirmed: a period includes its start day and excludes its end day (Chapter 2). Assumption, to be confirmed with the officer: whether a damaged item should appear available is a separate story, not this one.

## Acceptance examples
AQ-01 an empty store reports the item available. AQ-02 an overlapping reservation reports it unavailable. AQ-03 an adjacent reservation does not block it. AQ-04 another item's reservation does not block it. AQ-05 equal or reversed dates are reported as an invalid period. Every enquiry leaves the stored reservations unchanged.

## Current support
None. The only operation today is `reserve`, which stores a record.

## Dependencies and questions
How an invalid period is reported (an exception or a result value) must be decided before building. Owner: the pair that refines this item.

## Estimate
Medium, assuming no catalogue lookup. Discussed at the planning meeting.

## Completion evidence
Not started. The evidence will be the acceptance checks, a review note and the passing run on the integrated revision.

3.2 examples/acceptance-example.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# AC-02: An overlapping request is rejected and changes nothing

## Given
CAM1 is reserved for B1 from 20 October to 22 October (start included, end excluded).

## When
B2 requests CAM1 from 21 October for one day.

## Then
The request is rejected as unavailable.

## State afterwards
The original reservation is the only stored record. Nothing was added or changed.

## Evidence
The AC-02 check in `Chapter03AcceptanceExamples`, and the run of the check script that printed it.

3.3 examples/definition-of-done.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Definition of Done: equipment lending team

## Applies to
Each increment of the controlled teaching baseline, from the first iteration.

## Conditions
1. The relevant acceptance examples have been checked, including the important rejection paths and the state effects.
2. The code compiles and runs with the documented commands in the agreed environment.
3. Another team member has reviewed the change, and findings that block acceptance are resolved.
4. The work is integrated into the shared codebase, and the existing checks still pass.
5. Usage notes, domain decisions and known limitations are current.
6. Another team member can repeat the demonstration within its stated scope.
7. The agreed automated check has passed on the integrated revision, and the team can find its result.

## Not covered
Approval by a product owner, performance, and any grading rule of the module. Condition 7 applies only from the day the automated check exists.

## Ready policy
An item is ready to start when its scope, its example inputs and any blocking question are written down. It is a conversation aid, not an extra gate.

## Agreement and review
Proposed by the team for the controlled increment. Revisit it when the automated check or the review practice changes.

3.4 examples/decision-record.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# ADR-002: Keep reservations in memory for the kickoff baseline

Date: 13 October 2026
Status: Accepted for the kickoff baseline.

## Context
The kickoff demonstration judges reservation decisions in one session. The team has no storage requirement yet, and adding a database would hide the rules under test.

## Decision
Keep one in-memory coordinator. A restart loses every record, and identifiers start again from the beginning.

## Alternatives considered
- A file or database store: it would survive a restart, but no selected outcome needs that, and it adds setup and failure cases.
- Do nothing explicit: the behaviour would be a surprise to the first user who restarts the program.

## Consequences
The demonstration is simple and fast to run. The officer must be told that records do not persist. Any story that needs a restart to keep data will need a new decision record that supersedes this one.

## Validation and review
Discussed at the team meeting. No independent review has taken place.

## Revisit triggers
A selected outcome requires a session to survive a restart, or two users need the same data at once.

3.5 examples/iteration-plan.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Iteration plan: example team, next iteration

## Iteration goal
Let the officer inspect availability for one item and period without changing reservations.

## Capacity
| Person | Hours available |
|---|---:|
| P1 | 6 |
| P2 | 6 |
| P3 | 5 |
| P4 | 5 |

Reserved for coordination, integration and administration: 4.
Planning budget: 18 (22 available, less 4).

## Forecast work
| Work | Person-hours |
|---|---:|
| Resolve the query contract and update the examples | 2 |
| Implement the read-only enquiry | 4 |
| Add the thin interaction and the invalid-input response | 3 |
| Add and run the query checks beside the regression checks | 3 |
| Review, integrate, update notes and rehearse the demonstration | 2 |

Selected work: 14. Unallocated: 4, kept as room for uncertainty and not as a promise to add a feature.

## Calendar and skill constraints
Only P1 can explain the coordinator, and the reviewer is free only at the start. Hold an early pairing session on the query contract and agree a review window before the demonstration.

## When to replan
If an estimate rises by more than two hours, discuss the scope at the next meeting. Reduce an optional part before skipping a check. The team decides, and the product owner is told of any change to the goal.

3.6 examples/team-agreement.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Team agreement: example team

## Where things live
Stories and acceptance examples are in the shared project notes, with their identifiers kept when the wording changes. Questions are raised in the team channel and copied to the story if they block it.

## How work moves
Backlog, Ready, Building, Review, Done. Building plus Review holds at most two items, and a blocked item counts.

## Branches and review
A branch is small enough that a reviewer can explain its purpose and effects. Reviewers inspect behaviour, state effects and evidence, not only style.

## Checks
The author runs the check script before review and again after corrections. The result on the integrated branch is recorded in the evidence note.

## Gaps and unavailable evidence
If a review or a hosted run is not available, the team writes the gap and a next action in the evidence note. It never writes an invented result.

## Review of this agreement
Reread at each retrospective. A change is recorded with its reason in the project notes.

3.7 examples/pull-request.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Extract reservation confirmation formatting

## Problem
The request method mixes storing a reservation with assembling the confirmation text, so a change to the wording risks the storage.

## Change
Moves the text assembly into a private helper. The messages and the stored records are unchanged. It does not change any rule or any public method.

## Checks run
The check script before and after the change printed the same results. The output is attached to this request.

## Review focus
Confirm that the helper changes no state, and that the record is still added before the confirmation is returned.

## Limits
Only the supplied checks were run. No hosted check has run on this branch yet.

3.8 examples/evidence-note.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Evidence note: example team, week 2

## Repository and baseline revision
The team repository, at the revision tagged for the first iteration. The revision identifier is copied here when the note is committed.

## Implemented behaviour
US-01 only: an eligible request for an available item creates one stored reservation. No availability enquiry exists yet.

## Local environment and command
A full JDK of version 17 or later, Git Bash, and `bash scripts/check.sh`.

## Local check result
Exit status 0, ending with the success line of the script. Output saved with this note.

## Hosted passing run
None yet. The workflow file is in the repository but has not run. Next action: push the branch, open the run page, and keep its address and the tested revision.

## Deliberate failure
Not done yet. Planned for the next meeting on a practice branch.

## Restored pass
Not done yet.

## Independent review
None yet. A self-review was written for practice and is labelled as a self-review.

## Unresolved questions
How an invalid period is reported (owner: P2; decide before US-02 is built). Who may cancel a reservation (owner: product owner; blocks a later item).

## Known limitations
Records live in memory and vanish on restart. Requests are handled one at a time.

3.9 examples/requirements-to-evidence.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Requirements to evidence: example project

## Table
| Requirement or example | Class that decides it | Test that checks it | Stage that runs it |
|---|---|---|---|
| AC-01 an eligible request creates one reservation | `ReservationCoordinator` | `Chapter03AcceptanceExamples`, AC-01 | `scripts/check.sh` |
| AC-02 an overlapping request is rejected and changes nothing | `ReservationCoordinator`, `LoanPeriod` | `Chapter03AcceptanceExamples`, AC-02 | `scripts/check.sh` |

## How this table is kept true
A reviewer reads it at each release. A script that fails when a named class or test disappears is planned and does not exist yet.

## What this table cannot show
That each named test is the right evidence for its requirement. A name can exist and still check the wrong thing; only a reviewer can judge that.

3.10 examples/known-issues.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Known issues: example project

## Issues
| ID | What is wrong or missing | Effect | Decision | Owner | Review trigger |
|---|---|---|---|---|---|
| KI-1 | Reservations are kept in memory | A restart loses every record | Accept for the kickoff baseline (ADR-002) | P1 | A story needs data to survive a restart |
| KI-2 | The hosted workflow has never run | No hosted result exists for any revision | Defer until the team pushes to a hosted repository | P3 | The first push to the hosted repository |

## How this list is kept true
Read by the team at each retrospective. A row without a decision is not finished.

3.11 examples/release-notes.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Release notes 0.2.0

Date: 20 October 2026

## Summary
Adds a read-only availability enquiry. The existing reservation behaviour is unchanged.

## Added
An enquiry that reports whether one item is free for a stated period, without creating a reservation.

## Changed
Nothing.

## Fixed
Nothing.

## Behaviour you should know about
An item can be reported available even when a borrower cannot reserve it, because that borrower has reached the limit. Availability and eligibility are separate questions.

## Not verified
No hosted run of the workflow exists for this version. Behaviour on a Java 17 runtime was not run.

3.12 examples/retrospective.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# Retrospective: example team, first iteration

## Period and evidence brought
The first iteration. The team brought the board with its timestamps, the list of check runs and the review comments.

## What the evidence shows
| Observation | Evidence | Action | Owner and date |
|---|---|---|---|
| Reviews were slow | Two requests waited more than two days (board timestamps) | Review before starting a new item | P2, next iteration |
| The checks were unreliable | One of ten runs failed with no code change (run list) | Find and fix or isolate the unstable check | P3, next iteration |
| We rushed the end | Seven items moved to Done on the last day | Plan a smaller scope and freeze two days before the demonstration | Whole team |

## Process note
Our iterations end at the module's milestone weeks, not at a fixed interval. That helped us plan to a real date and hurt when the milestone fell in a short week. Next time we plan the short week with a smaller scope.

## Actions agreed
1. Review before starting a new item (P2, next iteration). 2. Fix or isolate the unstable check (P3, next iteration). 3. Plan a freeze two days before the demonstration (whole team).

## Last retrospective's actions
None. This was the first retrospective.

3.13 examples/ai-assistance-record.md

<!-- Example: constructed to illustrate. Not real evidence. -->
# AI assistance record: explain the overlap rule

## Task
Ask for an explanation of `LoanPeriod.overlaps`, so that a teammate can review it. The developer owns the rule: a period includes its start and excludes its end.

## Tool and model
None: constructed example. The candidate below was written by the author of this example and stands in for an assistant's answer.

## Context given
The source of `LoanPeriod` and one sentence about the half-open convention. The existing checks were not shown, so that they could be used to judge the answer.

## Output received
1. Candidate explanation: **rejected**. It said two periods overlap when the first one's end is on or after the second one's start, so adjacent periods overlap.

## Decision
Rejected. The sentence contradicts the convention that an end day is excluded.

## Changes made
Wrote the explanation by hand from the code: two periods overlap only when each starts before the other ends.

## Verification
Traced the periods 20 to 22 October and 22 to 24 October by hand: they do not overlap. Ran the Appendix A lab, whose check that adjacent periods do not overlap passes. Independent of the assistant: the hand trace and the lab check.

## Limits
Only the overlap rule was checked, on a few dates. Nothing here shows that the explanation covers every case.

4. The checker

4.1 scripts/check-templates.sh

#!/usr/bin/env bash
# Checks the structure of the template kit, or of one document you filled in.
#   bash scripts/check-templates.sh                      check every template and every example
#   bash scripts/check-templates.sh --file NAME PATH     check your own document against template NAME
# A template must keep every required heading and still show a {{placeholder}}. An example, or your own
# document, must have every required heading, a non-empty body under each, and no {{placeholder}} left.
# The script cannot tell whether what is written is true or useful; a reviewer does that.
# It fails, and says so, when there is nothing to check, so an empty set can never be reported as checked.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

NAMES=(story-card acceptance-example definition-of-done decision-record iteration-plan team-agreement
       pull-request evidence-note requirements-to-evidence known-issues release-notes retrospective
       ai-assistance-record)

# The required items of one template. "## X" is a heading; "X:" is a line that starts with X:.
required() {
  case "$1" in
    story-card) printf '%s\n' "## Actor and value" "## Scope" "## Rules and assumptions" "## Acceptance examples" \
      "## Current support" "## Dependencies and questions" "## Estimate" "## Completion evidence" ;;
    acceptance-example) printf '%s\n' "## Given" "## When" "## Then" "## State afterwards" "## Evidence" ;;
    definition-of-done) printf '%s\n' "## Applies to" "## Conditions" "## Not covered" "## Ready policy" "## Agreement and review" ;;
    decision-record) printf '%s\n' "Date:" "Status:" "## Context" "## Decision" "## Alternatives considered" \
      "## Consequences" "## Validation and review" "## Revisit triggers" ;;
    iteration-plan) printf '%s\n' "## Iteration goal" "## Capacity" "## Forecast work" \
      "## Calendar and skill constraints" "## When to replan" ;;
    team-agreement) printf '%s\n' "## Where things live" "## How work moves" "## Branches and review" "## Checks" \
      "## Gaps and unavailable evidence" "## Review of this agreement" ;;
    pull-request) printf '%s\n' "## Problem" "## Change" "## Checks run" "## Review focus" "## Limits" ;;
    evidence-note) printf '%s\n' "## Repository and baseline revision" "## Implemented behaviour" \
      "## Local environment and command" "## Local check result" "## Hosted passing run" "## Deliberate failure" \
      "## Restored pass" "## Independent review" "## Unresolved questions" "## Known limitations" ;;
    requirements-to-evidence) printf '%s\n' "## Table" "## How this table is kept true" "## What this table cannot show" ;;
    known-issues) printf '%s\n' "## Issues" "## How this list is kept true" ;;
    release-notes) printf '%s\n' "Date:" "## Summary" "## Added" "## Changed" "## Fixed" \
      "## Behaviour you should know about" "## Not verified" ;;
    retrospective) printf '%s\n' "## Period and evidence brought" "## What the evidence shows" "## Process note" \
      "## Actions agreed" "## Last retrospective's actions" ;;
    ai-assistance-record) printf '%s\n' "## Task" "## Tool and model" "## Context given" "## Output received" \
      "## Decision" "## Changes made" "## Verification" "## Limits" ;;
    *) return 1 ;;
  esac
}

problems=0
checked=0
fail() {
  echo "FAIL $*"
  problems=$((problems + 1))
}

# check_file KIND FILE NAME   (KIND is template, example or mine)
check_file() {
  local kind="$1" file="$2" name="$3" item body
  if [ ! -f "$file" ]; then
    fail "$file: no such file"
    return
  fi
  checked=$((checked + 1))
  while IFS= read -r item; do
    case "$item" in
      "## "*)
        if ! grep -qxF "$item" "$file"; then
          fail "$file: missing heading '${item#\#\# }'"
        elif [ "$kind" != template ]; then
          body="$(awk -v h="$item" '$0 == h {on=1; next} /^## / {on=0} on' "$file" | tr -d '[:space:]')"
          [ -n "$body" ] || fail "$file: heading '${item#\#\# }' is empty"
        fi
        ;;
      *:)
        if ! grep -q "^${item}" "$file"; then
          fail "$file: missing line '${item}'"
        elif [ "$kind" != template ] && ! grep -q "^${item} *[^ ]" "$file"; then
          fail "$file: '${item}' has no value"
        fi
        ;;
    esac
  done < <(required "$name")
  if [ "$kind" = template ]; then
    grep -q '{{' "$file" || fail "$file: a template must still show its {{placeholders}}"
  else
    ! grep -q '{{' "$file" || fail "$file: an unfilled {{placeholder}} is left"
  fi
  if [ "$kind" = example ]; then
    head -1 "$file" | grep -q '^<!-- Example: constructed to illustrate' || fail "$file: the first line must label it as a constructed example"
  fi
}

if [ "${1:-}" = "--file" ]; then
  [ "$#" -eq 3 ] || { echo "usage: check-templates.sh --file NAME PATH" >&2; exit 2; }
  required "$2" >/dev/null || { echo "unknown template name: $2" >&2; exit 2; }
  check_file mine "$3" "$2"
else
  for name in "${NAMES[@]}"; do
    check_file template "templates/${name}.md" "$name"
    check_file example "examples/${name}.md" "$name"
  done
fi

if [ "$checked" -eq 0 ]; then
  echo "FAIL nothing was checked"
  echo "TEMPLATE CHECK FAILED"
  exit 1
fi
if [ "$problems" -gt 0 ]; then
  echo "TEMPLATE CHECK FAILED: ${problems} problem(s) in ${checked} file(s)"
  exit 1
fi
echo "TEMPLATE CHECK PASSED: ${checked} file(s)"

The script has two modes. Without arguments it checks the whole kit: every template must keep its required headings and still show a {{placeholder}}; every example must have every heading, a non-empty body under each, no placeholder left and a first line that labels it as constructed. With --file NAME PATH it checks one document you filled in, against the headings of template NAME, without the label requirement:

bash scripts/check-templates.sh --file decision-record docs/ADR-015-example.md

It fails, and says so, when there is nothing to check, so an empty set can never be reported as checked. It cannot tell whether what is written is true.

Three checks against the book’s own records, run in Git Bash on the files as printed in the chapter companions: ADR-010, ADR-013 and ADR-014 each pass --file decision-record, and the Chapter 15 lab’s filled record AI-RECORD-earliest-start.md passes --file ai-assistance-record. That is what “follows the headings of the chapters” means in practice.

4.2 Expected output

TEMPLATE CHECK PASSED: 26 file(s)

4.3 A small fault experiment

In a separate copy of the kit, make one change at a time, run bash scripts/check-templates.sh and discard the copy.

ChangeWhat must happen
Delete the ## Limits heading from examples/ai-assistance-record.mdFAIL ... missing heading 'Limits', exit status 1
Empty the text under ## Problem in examples/pull-request.mdFAIL ... heading 'Problem' is empty
Replace a sentence in examples/evidence-note.md with {{Hosted run}}FAIL ... an unfilled {{placeholder}} is left
Delete the first line of examples/story-card.mdFAIL ... the first line must label it as a constructed example
Remove every {{ and }} from templates/retrospective.mdFAIL ... a template must still show its {{placeholders}}
Delete examples/known-issues.mdFAIL ... no such file, and 25 files checked
Empty the value after Status: in examples/decision-record.mdFAIL ... 'Status:' has no value
Delete ## Revisit triggers from templates/decision-record.mdFAIL ... missing heading 'Revisit triggers'
Delete the templates and examples foldersFAIL nothing was checked, exit status 1
--file pull-request with a path that does not existFAIL nothing was checked, exit status 1
--file nonsense ...unknown template name: nonsense, exit status 2

These experiments check that the script catches the chosen faults. They do not establish that it catches every possible fault, and they say nothing about whether a document is true.

5. Common mistakes, by template

TemplateMistakes to look for
Story cardA feature name with no actor and no value; guesses listed as confirmed rules; “Completion evidence” filled in before anything exists
Acceptance exampleA visible result without the state afterwards; vague values; two actions under “When”; “Evidence” that points at a check that does not run the example
Definition of DoneConditions nobody can check; claiming a condition that does not exist yet, such as a passing pipeline before there is one; weakening it to move a card
Decision recordRecording a decision after the fact without saying so; editing an old record; listing only the chosen option; “reviewed” when nobody reviewed it
Iteration planAllocating every hour to features; converting story points into hours; hiding an overrun by skipping checks; treating unallocated hours as a promise
Team agreementCopying another team’s agreement; an empty gaps section; never rereading it
Pull requestA description that restates the diff; mixing a functional change with a workflow change; quoting a result that was not run on this revision; an invented review exchange
Evidence notePlanned behaviour written as implemented; the result of one revision cited for another; guidance text left in place of an answer
Requirements to evidenceA test that exists but checks something else; a table nobody rereads
Known issuesA row with a description and no decision; deleting a row to shorten the list
Release notes“Various improvements”; saying production behaviour did not change without checking; an empty “Not verified”
RetrospectiveAn observation with no evidence; actions with no owner or date; naming a person as the cause
AI assistance recordWriting it after the details are forgotten; omitting what the assistant saw; secrets or personal data; “reviewed and verified” with nothing behind it

6. Answers to the activity

  1. Check the kit. TEMPLATE CHECK PASSED: 26 file(s).
  2. Fill in one template for a real change and run --file. Typical reports are a missing heading (renamed rather than kept), an empty section and a left-over placeholder.
  3. Break your file three ways. Each break gives a different message (Section 4.3); restoring the file gives a pass.
  4. Choose a template your team does not use. A good answer names the question the template forces, for example “what would make us reopen this decision?” for a decision record or “what was not verified?” for release notes.
  5. Fill in an evidence note, writing “none yet” where something does not exist.

The constructed AI candidate

The candidate in the activity is a complete evidence note that an assistant might produce when asked to “fill in the template”. It is constructed for teaching; it is not a captured model response. Save it as fixtures/invented-evidence-note.md:

<!-- Constructed to illustrate: an evidence note that is complete in shape and invented in content. Not real evidence. -->
# Evidence note: example team, week 6

## Repository and baseline revision
example-team/lending, at revision a3f9c21.

## Implemented behaviour
US-01 and US-02 are fully implemented and verified.

## Local environment and command
JDK 17.0.11, Git Bash, `bash scripts/check.sh`.

## Local check result
All checks passed.

## Hosted passing run
Run 1432 passed on revision a3f9c21 on 12 October.

## Deliberate failure
Introduced and fixed; all checks green.

## Restored pass
Restored at revision b81e0d4; run 1433 passed.

## Independent review
Reviewed and approved by the team.

## Unresolved questions
None.

## Known limitations
None.
bash scripts/check-templates.sh --file evidence-note fixtures/invented-evidence-note.md

The script reports TEMPLATE CHECK PASSED: 1 file(s). The note has every heading, every section is non-empty and no placeholder is left. It is also unverifiable and suspicious, and that is the point of the exercise.

EntryCould a reviewer verify it? How?Why it looks invented
“Revision a3f9c21”, “run 1432”, “run 1433”Yes. Open the repository and the run pages; compare the revision and the dateNothing links the run to the revision. A run number and a short hash, with no address, are easy to make up
“US-01 and US-02 are fully implemented and verified”Yes. Read the code and the checks for US-02“Fully” and “verified” are claims, and no check is named
“Introduced and fixed; all checks green”Yes, if a failed run and a restored run both existA deliberate failure should name the failed assertion and the failed run
“Reviewed and approved by the team”Only if a reviewer and the review text existNo named reviewer, no finding, no correction; it is the ritual version
“None” under both questions and limitationsNoA real project always has an open question and a limit; “none” suggests the headings were filled to pass

A script that checks shape passes this note, so a reviewer must check content. The honest evidence note in Section 3 shows the opposite habit: it says “none yet” and names the next action.

7. Teaching and revision notes

Ask learners to predict each fault-experiment result before they run it. The invented evidence note is worth ten minutes in class: have pairs mark every entry as verifiable, unverifiable or suspicious before they read the table in Section 6. A team without access to a hosted platform can still use every template; the evidence-note fields for a hosted run are filled with “none yet” and a next action, which is the honest outcome.

Keep the templates in the repository beside the work they describe. When a chapter changes the headings of its record, change the template and the list in check-templates.sh together, and run the script. Do not add the kit to the lending project’s test totals: it is documents and a script, and it has no Java tests.

References

Opened on 13 October 2026. The templates and examples are original; source text has not been reproduced.

  1. Schwaber, K. and Sutherland, J., The Scrum Guide, November 2020. The Definition of Done as a commitment of the increment; the purpose of the Sprint Retrospective.
  2. Nygard, M., Documenting Architecture Decisions, 15 November 2011. Short numbered records: title, context, decision, status, consequences; superseded and not deleted.
  3. GitHub Docs, Creating a pull request template for your repository. A file named pull_request_template.md in the repository root, docs or .github supplies a pull-request template.
  4. Keep a Changelog 1.1.0. Changelogs for humans, the change types and the dated entry for each version.

Appendix F Companion: The Risk Lab

Code explanations and solutions for Appendix F Companion: The Risk Lab.

Software Engineering for AI and Data Components
OO Development and Agile Practices

1. Scope and use

This file accompanies Appendix F, Software Engineering for AI and Data Components. It contains the risk lab, a small stand-alone Maven project, in two halves: the starting class and the acceptance tests for the exercise (Sections 2 and 3), and a model solution (Section 4). Attempt the exercise before you read Section 4.

The lab is an isolated Book example. It does not depend on the lending project and does not change it. It holds one question, “how risky is it to lend this item to this borrower?”, and puts a model behind it. There is no real model: the model is a local stub service, so that every test is deterministic.

Use a full Java 17 JDK and Maven 3.9.11. The lab uses only JUnit Jupiter 5.13.4, with the Compiler plugin 3.14.1 and the Surefire plugin 3.5.4, as in Appendix B. Save each listing at its path inside one new folder, for example risk-lab, and note that the two starter files and the tests are listed before the solution. From that folder:

bash scripts/check.sh

The script builds, runs every test, then runs the tests again without the integration group, so that the two counts can be reported separately. It accepts the same optional variables as the Appendix B starter: CHAPTER_MVN (the Maven command), CHAPTER_M2 (a local repository folder) and CHAPTER_OFFLINE=1.

The starting class and its probe need no Maven. From the folder starter:

javac --release 17 -d out UnsafeRiskScorer.java StarterProbe.java
java -cp out StarterProbe

The starter’s expected output, apart from the time, which varies:

good day            : true
confidence of 1.7   : true
service that is slow: true after 1502 ms
service that is down: false

2. The starting point

2.1 starter/UnsafeRiskScorer.java

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;

/**
 * The starting point of the exercise. It works on a good day. Do not copy its habits: read it and
 * list what can go wrong before you run StarterProbe.
 */
public class UnsafeRiskScorer {
    private final String serviceUrl;

    public UnsafeRiskScorer(String serviceUrl) {
        this.serviceUrl = serviceUrl;
    }

    public boolean approve(String borrowerId, String itemId) {
        try {
            HttpURLConnection connection = (HttpURLConnection)
                    URI.create(serviceUrl + "/risk?borrower=" + borrowerId + "&item=" + itemId).toURL().openConnection();
            BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            String line = in.readLine();
            String[] parts = line.split(" ");
            float score = Float.parseFloat(parts[1]);
            if (parts[0].equals("LOW")) {
                return score > 0.8f;
            }
            return false;
        } catch (Exception anything) {
            return false;
        }
    }
}

2.2 starter/StarterProbe.java

import com.sun.net.httpserver.HttpServer;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

/** Runs UnsafeRiskScorer against a small local service and prints what it does. */
public class StarterProbe {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        server.createContext("/risk", exchange -> {
            String query = exchange.getRequestURI().getQuery();
            String body = "LOW 0.95";
            if (query.contains("borrower=BADSCORE")) {
                body = "LOW 1.7";
            } else if (query.contains("borrower=SLOW")) {
                try {
                    Thread.sleep(1500);
                } catch (InterruptedException ignored) {
                    Thread.currentThread().interrupt();
                }
            }
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(200, bytes.length);
            exchange.getResponseBody().write(bytes);
            exchange.close();
        });
        server.start();
        String url = "http://127.0.0.1:" + server.getAddress().getPort();
        UnsafeRiskScorer scorer = new UnsafeRiskScorer(url);

        System.out.println("good day            : " + scorer.approve("B1", "CAM1"));
        System.out.println("confidence of 1.7   : " + scorer.approve("BADSCORE", "CAM1"));
        long began = System.nanoTime();
        System.out.println("service that is slow: " + scorer.approve("SLOW", "CAM1")
                + " after " + (System.nanoTime() - began) / 1_000_000 + " ms");
        server.stop(0);
        System.out.println("service that is down: " + scorer.approve("B1", "CAM1"));
    }
}

3. The acceptance tests

These are the tests the exercise must pass. Fixtures, FakeItemRiskAssessor and StubRiskService are test support. The tests will not compile until you write the main code that Appendix F, Section F.7, describes.

3.1 src/test/java/risk/support/Fixtures.java

package risk.support;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import risk.domain.RiskAssessment;
import risk.domain.RiskLevel;

/** Small shared test values. The clock is fixed so that no test depends on the time of day. */
public final class Fixtures {
    public static final Instant NOW = Instant.parse("2026-10-13T12:00:00Z");
    public static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);

    private Fixtures() {
    }

    public static RiskAssessment assessment(RiskLevel level, double confidence) {
        return new RiskAssessment(level, confidence, NOW);
    }
}

3.2 src/test/java/risk/support/FakeItemRiskAssessor.java

package risk.support;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;

/** A deterministic, hand-written assessor for tests: no network, no model, and it counts its calls. */
public final class FakeItemRiskAssessor implements ItemRiskAssessor {
    private final Map<String, RiskAssessment> answers = new HashMap<>();
    private RiskAssessment defaultAnswer = Fixtures.assessment(RiskLevel.LOW, 0.95);
    private boolean failing;
    private final AtomicInteger calls = new AtomicInteger();

    public FakeItemRiskAssessor answer(String borrowerId, RiskAssessment assessment) {
        answers.put(borrowerId, Objects.requireNonNull(assessment));
        return this;
    }

    public FakeItemRiskAssessor defaultAnswer(RiskAssessment assessment) {
        this.defaultAnswer = Objects.requireNonNull(assessment);
        return this;
    }

    public FakeItemRiskAssessor failing() {
        this.failing = true;
        return this;
    }

    public int calls() {
        return calls.get();
    }

    @Override
    public RiskAssessment assessRisk(String borrowerId, String itemId) {
        calls.incrementAndGet();
        if (failing) {
            throw new RiskAssessmentException("The fake was told to fail.");
        }
        return answers.getOrDefault(borrowerId, defaultAnswer);
    }
}

3.3 src/test/java/risk/support/StubRiskService.java

package risk.support;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * A controlled local risk service for tests: the JDK's own HTTP server on the loopback address and a
 * free port. Borrower LOWB answers {@code LOW 0.93}, MODB {@code MODERATE 0.70} and HIGHB
 * {@code HIGH 0.88}. BADSCORE answers a confidence of 1.7, BADLEVEL an unknown level, GARBAGE one field
 * and BROKEN status 500. SLOW sends nothing until the stub is closed, and SLOWBODY sends its headers and
 * the first byte and then holds the rest until the stub is closed. Everything else is 404. It is a
 * stand-in for a real service, not the service.
 */
public final class StubRiskService implements AutoCloseable {
    private final HttpServer server;
    private final CountDownLatch release = new CountDownLatch(1);
    private volatile String lastQuery = "";

    public StubRiskService() throws IOException {
        server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        server.createContext("/risk", this::handle);
        server.start();
    }

    public URI baseUri() {
        return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
    }

    /** The raw query of the last request, so a test can check what was sent. */
    public String lastQuery() {
        return lastQuery;
    }

    private void handle(HttpExchange exchange) throws IOException {
        String query = exchange.getRequestURI().getRawQuery();
        lastQuery = query == null ? "" : query;
        String borrower = "";
        for (String pair : lastQuery.split("&")) {
            if (pair.startsWith("borrower=")) {
                borrower = URLDecoder.decode(pair.substring("borrower=".length()), StandardCharsets.UTF_8);
            }
        }
        int status = 200;
        boolean stallBody = false;
        String body;
        switch (borrower) {
            case "LOWB" -> body = "LOW 0.93";
            case "MODB" -> body = "MODERATE 0.70";
            case "HIGHB" -> body = "HIGH 0.88";
            case "BADSCORE" -> body = "LOW 1.7";
            case "BADLEVEL" -> body = "EXTREME 0.50";
            case "GARBAGE" -> body = "only-one-field";
            case "BROKEN" -> { status = 500; body = "server error"; }
            case "SLOW" -> {
                awaitRelease();
                body = "LOW 0.93";
            }
            case "SLOWBODY" -> {
                stallBody = true;
                body = "LOW 0.93";
            }
            case "A B&C" -> body = "MODERATE 0.60";
            default -> { status = 404; body = "none"; }
        }
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        try {
            exchange.sendResponseHeaders(status, bytes.length);
            if (stallBody) {
                exchange.getResponseBody().write(bytes, 0, 1);
                exchange.getResponseBody().flush();
                awaitRelease();
                exchange.getResponseBody().write(bytes, 1, bytes.length - 1);
            } else {
                exchange.getResponseBody().write(bytes);
            }
        } catch (IOException clientGone) {
            // the client gave up (a timeout test); nothing more to send
        } finally {
            exchange.close();
        }
    }

    private void awaitRelease() {
        try {
            release.await(30, TimeUnit.SECONDS);
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void close() {
        release.countDown();
        server.stop(0);
    }
}

3.4 src/test/java/risk/domain/RiskAssessmentTest.java

package risk.domain;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import risk.support.Fixtures;

class RiskAssessmentTest {

    @ParameterizedTest
    @ValueSource(doubles = {-0.01, 1.01, Double.NaN, Double.POSITIVE_INFINITY})
    void confidenceOutsideZeroToOneIsRejected(double confidence) {
        assertThrows(IllegalArgumentException.class,
                () -> new RiskAssessment(RiskLevel.LOW, confidence, Fixtures.NOW));
    }

    @ParameterizedTest
    @ValueSource(doubles = {0.0, 0.5, 1.0})
    void confidenceAtTheEdgesIsAccepted(double confidence) {
        assertEquals(confidence, new RiskAssessment(RiskLevel.LOW, confidence, Fixtures.NOW).confidence());
    }

    @Test
    void aMissingLevelOrTimeIsRejected() {
        assertThrows(NullPointerException.class, () -> new RiskAssessment(null, 0.5, Fixtures.NOW));
        assertThrows(NullPointerException.class, () -> new RiskAssessment(RiskLevel.LOW, 0.5, (Instant) null));
    }

    @Test
    void automaticApprovalNeedsLowAndAtLeastTheThreshold() {
        assertTrue(Fixtures.assessment(RiskLevel.LOW, 0.80).permitsAutomaticApproval());
        assertFalse(Fixtures.assessment(RiskLevel.LOW, 0.79).permitsAutomaticApproval());
    }

    @ParameterizedTest
    @ValueSource(strings = {"MODERATE", "HIGH", "UNKNOWN"})
    void aLevelOtherThanLowNeverApprovesAutomatically(String level) {
        assertFalse(Fixtures.assessment(RiskLevel.valueOf(level), 1.0).permitsAutomaticApproval());
    }
}

3.5 src/test/java/risk/domain/ReservationScreenTest.java

package risk.domain;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import risk.support.FakeItemRiskAssessor;
import risk.support.Fixtures;

class ReservationScreenTest {

    @ParameterizedTest
    @CsvSource({
            "LOW,    0.95, AUTO_APPROVE",
            "LOW,    0.80, AUTO_APPROVE",
            "LOW,    0.79, SUPERVISOR_REVIEW",
            "MODERATE, 0.99, SUPERVISOR_REVIEW",
            "UNKNOWN, 1.00, SUPERVISOR_REVIEW",
            "HIGH,   0.10, REJECT",
            "HIGH,   0.99, REJECT"})
    void eachAssessmentIsRoutedByTheDomainsRules(String level, double confidence, String expected) {
        FakeItemRiskAssessor fake = new FakeItemRiskAssessor()
                .defaultAnswer(Fixtures.assessment(RiskLevel.valueOf(level), confidence));
        assertEquals(Route.valueOf(expected), new ReservationScreen(fake).route("B1", "CAM1"));
    }

    @Test
    void aFailedAssessmentIsNeverTurnedIntoAnApproval() {
        ReservationScreen screen = new ReservationScreen(new FakeItemRiskAssessor().failing());
        assertThrows(RiskAssessmentException.class, () -> screen.route("B1", "CAM1"));
    }

    @Test
    void eachBorrowerIsAssessedOnTheirOwn() {
        FakeItemRiskAssessor fake = new FakeItemRiskAssessor()
                .answer("RISKY", Fixtures.assessment(RiskLevel.HIGH, 0.9));
        ReservationScreen screen = new ReservationScreen(fake);
        assertEquals(Route.REJECT, screen.route("RISKY", "CAM1"));
        assertEquals(Route.AUTO_APPROVE, screen.route("B1", "CAM1"));
    }

    @Test
    void blankOrMissingIdentifiersAreRejectedBeforeAnyAssessment() {
        FakeItemRiskAssessor fake = new FakeItemRiskAssessor();
        ReservationScreen screen = new ReservationScreen(fake);
        assertThrows(IllegalArgumentException.class, () -> screen.route(" ", "CAM1"));
        assertThrows(IllegalArgumentException.class, () -> screen.route("B1", null));
        assertEquals(0, fake.calls());
    }
}

3.6 src/test/java/risk/adapter/RuleBasedRiskAssessorTest.java

package risk.adapter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;
import risk.support.Fixtures;

class RuleBasedRiskAssessorTest {

    private static RiskAssessment assess(int violations) {
        return new RuleBasedRiskAssessor(borrower -> violations, Fixtures.CLOCK).assessRisk("B1", "CAM1");
    }

    @ParameterizedTest
    @CsvSource({"0, LOW", "1, MODERATE", "2, MODERATE", "3, HIGH", "9, HIGH"})
    void theLevelFollowsTheNumberOfViolations(int violations, String expected) {
        assertEquals(RiskLevel.valueOf(expected), assess(violations).level());
    }

    @ParameterizedTest
    @CsvSource({"0, 1", "0, 3", "1, 2", "2, 3", "0, 8", "3, 4", "2, 9"})
    void moreViolationsNeverLowerTheRisk(int fewer, int more) {
        // a metamorphic property: it does not say what the level is, only how two answers relate
        assertTrue(assess(more).level().compareTo(assess(fewer).level()) >= 0);
    }

    @ParameterizedTest
    @CsvSource({"0", "1", "5"})
    void theCautiousFallbackNeverApprovesWithoutAPerson(int violations) {
        assertFalse(assess(violations).permitsAutomaticApproval());
    }

    @Test
    void aNegativeCountIsAFailureAndNotALowRisk() {
        assertThrows(RiskAssessmentException.class, () -> assess(-1));
    }
}

3.7 src/test/java/risk/adapter/HttpRiskPredictionAdapterContractTest.java

package risk.adapter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;
import risk.support.Fixtures;
import risk.support.StubRiskService;

/** The adapter against a real HTTP client and a service under our control: one test per outcome. */
@Tag("integration")
class HttpRiskPredictionAdapterContractTest {
    private static final Duration BOUND = Duration.ofMillis(300);

    private StubRiskService stub;
    private HttpRiskPredictionAdapter adapter;

    @BeforeEach
    void startStub() throws Exception {
        stub = new StubRiskService();
        adapter = new HttpRiskPredictionAdapter(stub.baseUri(), Duration.ofSeconds(1), BOUND, Fixtures.CLOCK);
    }

    @AfterEach
    void stopStub() {
        stub.close();
    }

    @Test
    void aGoodAnswerBecomesATypedAssessment() {
        RiskAssessment answer = adapter.assessRisk("LOWB", "CAM1");
        assertEquals(RiskLevel.LOW, answer.level());
        assertEquals(0.93, answer.confidence());
        assertEquals(Fixtures.NOW, answer.assessedAt());
    }

    @Test
    void theLevelAndConfidenceOfEachAnswerAreKept() {
        assertEquals(RiskLevel.MODERATE, adapter.assessRisk("MODB", "CAM1").level());
        assertEquals(RiskLevel.HIGH, adapter.assessRisk("HIGHB", "CAM1").level());
    }

    @Test
    void identifiersAreEncodedInTheRequest() {
        assertEquals(RiskLevel.MODERATE, adapter.assessRisk("A B&C", "CAM 1").level());
        assertTrue(stub.lastQuery().contains("borrower=A+B%26C"), stub.lastQuery());
        assertTrue(stub.lastQuery().contains("item=CAM+1"), stub.lastQuery());
    }

    @ParameterizedTest
    @ValueSource(strings = {"BADSCORE", "BADLEVEL", "GARBAGE", "BROKEN", "UNKNOWN-PERSON"})
    void everythingExceptAGoodAnswerIsAFailureAndNeverADefault(String borrower) {
        assertThrows(RiskAssessmentException.class, () -> adapter.assessRisk(borrower, "CAM1"));
    }

    @Test
    @Timeout(5)
    void aServiceThatSendsNothingIsGivenUpOnWithinTheBound() {
        long began = System.nanoTime();
        assertThrows(RiskAssessmentException.class, () -> adapter.assessRisk("SLOW", "CAM1"));
        assertTrue(elapsedMillis(began) < BOUND.toMillis() * 4, "waited too long");
    }

    @Test
    @Timeout(5)
    void aServiceThatStallsInTheMiddleOfTheBodyIsAlsoBounded() {
        long began = System.nanoTime();
        assertThrows(RiskAssessmentException.class, () -> adapter.assessRisk("SLOWBODY", "CAM1"));
        assertTrue(elapsedMillis(began) < BOUND.toMillis() * 4, "waited too long");
    }

    @Test
    void aServiceThatIsNotThereIsAFailure() {
        HttpRiskPredictionAdapter nobody = new HttpRiskPredictionAdapter(
                URI.create("http://127.0.0.1:1"), Duration.ofMillis(300), BOUND, Fixtures.CLOCK);
        assertThrows(RiskAssessmentException.class, () -> nobody.assessRisk("LOWB", "CAM1"));
    }

    private static long elapsedMillis(long beganNanos) {
        return (System.nanoTime() - beganNanos) / 1_000_000;
    }
}

3.8 src/test/java/risk/adapter/ResilientRiskAssessorTest.java

package risk.adapter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;
import risk.support.FakeItemRiskAssessor;
import risk.support.Fixtures;

class ResilientRiskAssessorTest {
    private static final Duration LIMIT = Duration.ofMillis(200);
    private static final RiskAssessment PRIMARY = Fixtures.assessment(RiskLevel.LOW, 0.95);
    private static final RiskAssessment CAUTIOUS = Fixtures.assessment(RiskLevel.MODERATE, 0.50);

    private static ItemRiskAssessor stalled(CountDownLatch release) {
        return (borrower, item) -> {
            try {
                release.await();
            } catch (InterruptedException interrupted) {
                Thread.currentThread().interrupt();
            }
            return PRIMARY;
        };
    }

    @Test
    void aTimelyPrimaryAnswerIsUsedAndTheFallbackIsNotTouched() {
        FakeItemRiskAssessor fallback = new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS);
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                new FakeItemRiskAssessor().defaultAnswer(PRIMARY), fallback, LIMIT)) {
            assertSame(PRIMARY, assessor.assessRisk("B1", "CAM1"));
            assertEquals(0, fallback.calls());
            assertEquals(0, assessor.fallbackCount());
        }
    }

    @Test
    @Timeout(5)
    void aStalledPrimaryIsGivenUpOnWithinTheLimitAndTheFallbackAnswers() {
        CountDownLatch never = new CountDownLatch(1);
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                stalled(never), new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS), LIMIT)) {
            long began = System.nanoTime();
            RiskAssessment answer = assessor.assessRisk("B1", "CAM1");
            long millis = (System.nanoTime() - began) / 1_000_000;
            assertSame(CAUTIOUS, answer);
            assertTrue(millis < LIMIT.toMillis() * 4, "waited " + millis + " ms");
            assertEquals(1, assessor.fallbackCount());
        } finally {
            never.countDown();
        }
    }

    @Test
    @Timeout(5)
    void aStalledPrimaryIsAskedToStop() throws InterruptedException {
        CountDownLatch askedToStop = new CountDownLatch(1);
        CountDownLatch never = new CountDownLatch(1);
        ItemRiskAssessor primary = (borrower, item) -> {
            try {
                never.await();
            } catch (InterruptedException interrupted) {
                askedToStop.countDown();
            }
            return PRIMARY;
        };
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                primary, new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS), LIMIT)) {
            assessor.assessRisk("B1", "CAM1");
            assertTrue(askedToStop.await(2, TimeUnit.SECONDS), "the stalled call was not asked to stop");
        } finally {
            never.countDown();
        }
    }

    @Test
    void anExpectedFailureOfThePrimaryLeadsToTheFallback() {
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                new FakeItemRiskAssessor().failing(), new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS), LIMIT)) {
            assertSame(CAUTIOUS, assessor.assessRisk("B1", "CAM1"));
            assertEquals(1, assessor.fallbackCount());
        }
    }

    @Test
    void aProgrammingErrorInThePrimaryIsNotHiddenByTheFallback() {
        ItemRiskAssessor buggy = (borrower, item) -> {
            throw new IllegalStateException("bug");
        };
        FakeItemRiskAssessor fallback = new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS);
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(buggy, fallback, LIMIT)) {
            assertThrows(IllegalStateException.class, () -> assessor.assessRisk("B1", "CAM1"));
            assertEquals(0, fallback.calls());
        }
    }

    @Test
    void ifTheFallbackAlsoFailsTheCallerSeesTheFailureAndNotAnApproval() {
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                new FakeItemRiskAssessor().failing(), new FakeItemRiskAssessor().failing(), LIMIT)) {
            assertThrows(RiskAssessmentException.class, () -> assessor.assessRisk("B1", "CAM1"));
        }
    }

    @Test
    void aPrimaryThatReturnsNullIsABugAndIsReportedAsOne() {
        ItemRiskAssessor returnsNull = (borrower, item) -> null;
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                returnsNull, new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS), LIMIT)) {
            assertThrows(NullPointerException.class, () -> assessor.assessRisk("B1", "CAM1"));
        }
    }

    @Test
    @Timeout(5)
    void anInterruptedCallerKeepsItsInterruptFlagAndGetsAFailure() {
        CountDownLatch never = new CountDownLatch(1);
        try (ResilientRiskAssessor assessor = new ResilientRiskAssessor(
                stalled(never), new FakeItemRiskAssessor().defaultAnswer(CAUTIOUS), Duration.ofSeconds(5))) {
            Thread.currentThread().interrupt();
            assertThrows(RiskAssessmentException.class, () -> assessor.assessRisk("B1", "CAM1"));
            assertTrue(Thread.interrupted(), "the interrupt flag must be restored");
        } finally {
            never.countDown();
        }
    }
}

3.9 src/test/java/risk/rollout/RolloutAssessorTest.java

package risk.rollout;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import risk.domain.RiskAssessment;
import risk.domain.RiskLevel;
import risk.support.FakeItemRiskAssessor;
import risk.support.Fixtures;

class RolloutAssessorTest {
    private static final RiskAssessment BASE = Fixtures.assessment(RiskLevel.MODERATE, 0.50);
    private static final RiskAssessment MODEL = Fixtures.assessment(RiskLevel.LOW, 0.95);

    private final List<ProvenanceRecord> records = new ArrayList<>();
    private final FakeItemRiskAssessor baseline = new FakeItemRiskAssessor().defaultAnswer(BASE);
    private final FakeItemRiskAssessor model = new FakeItemRiskAssessor().defaultAnswer(MODEL);

    private RolloutAssessor assessor(RolloutMode mode, int percent) {
        return new RolloutAssessor(baseline, model, mode, percent, "model-7", records::add);
    }

    @Test
    void whenOffTheBaselineDecidesAndTheModelIsNeverCalled() {
        assertSame(BASE, assessor(RolloutMode.OFF, 100).assessRisk("B1", "CAM1"));
        assertEquals(0, model.calls());
        assertTrue(records.isEmpty());
    }

    @Test
    void inShadowTheBaselineDecidesAndTheModelsAnswerIsOnlyRecorded() {
        assertSame(BASE, assessor(RolloutMode.SHADOW, 0).assessRisk("B1", "CAM1"));
        assertEquals(1, model.calls());
        ProvenanceRecord record = records.get(0);
        assertSame(MODEL, record.modelAnswer());
        assertSame(BASE, record.decision());
        assertFalse(record.modelDecided());
        assertEquals("model-7", record.modelVersion());
    }

    @Test
    void aShadowFailureNeverChangesTheDecisionAndIsRecordedAsNoAnswer() {
        FakeItemRiskAssessor broken = new FakeItemRiskAssessor().failing();
        RolloutAssessor shadow = new RolloutAssessor(baseline, broken, RolloutMode.SHADOW, 0, "model-7", records::add);
        assertSame(BASE, shadow.assessRisk("B1", "CAM1"));
        assertNull(records.get(0).modelAnswer());
    }

    @Test
    void whenOnTheModelDecidesAndTheRecordSaysSo() {
        assertSame(MODEL, assessor(RolloutMode.ON, 0).assessRisk("B1", "CAM1"));
        assertEquals(0, baseline.calls());
        assertTrue(records.get(0).modelDecided());
        assertSame(MODEL, records.get(0).decision());
    }

    @Test
    void aCanaryUsesTheModelForTheSameBorrowersEveryTime() {
        RolloutAssessor canary = assessor(RolloutMode.CANARY, 30);
        for (int i = 0; i < 200; i++) {
            String borrower = "B" + i;
            RiskAssessment expected = canary.inCanary(borrower) ? MODEL : BASE;
            assertSame(expected, canary.assessRisk(borrower, "CAM1"));
            assertSame(expected, canary.assessRisk(borrower, "CAM1"));
        }
    }

    @Test
    void aCanaryOfThirtyPercentCoversRoughlyThirtyPercent() {
        RolloutAssessor canary = assessor(RolloutMode.CANARY, 30);
        long inside = java.util.stream.IntStream.range(0, 1000).filter(i -> canary.inCanary("B" + i)).count();
        assertTrue(inside > 230 && inside < 370, "inside = " + inside);
    }

    @Test
    void zeroPercentIsNobodyAndOneHundredPercentIsEverybody() {
        RolloutAssessor none = assessor(RolloutMode.CANARY, 0);
        RolloutAssessor all = assessor(RolloutMode.CANARY, 100);
        assertTrue(java.util.stream.IntStream.range(0, 1000).noneMatch(i -> none.inCanary("B" + i)));
        assertTrue(java.util.stream.IntStream.range(0, 1000).allMatch(i -> all.inCanary("B" + i)));
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, 101})
    void aPercentageOutsideZeroToOneHundredIsRejected(int percent) {
        assertThrows(IllegalArgumentException.class, () -> assessor(RolloutMode.CANARY, percent));
    }

    @Test
    void whenTheModelDecidesAndFailsTheFailureIsNotHidden() {
        FakeItemRiskAssessor broken = new FakeItemRiskAssessor().failing();
        RolloutAssessor on = new RolloutAssessor(baseline, broken, RolloutMode.ON, 0, "model-7", records::add);
        assertThrows(risk.domain.RiskAssessmentException.class, () -> on.assessRisk("B1", "CAM1"));
        assertTrue(records.isEmpty());
    }
}

4. A model solution

4.1 src/main/java/risk/domain/RiskLevel.java

package risk.domain;

/**
 * How cautious to be about a request. UNKNOWN means that no trustworthy assessment exists; it is
 * not a level between the others and it is never a reason to approve.
 */
public enum RiskLevel {
    LOW,
    MODERATE,
    HIGH,
    UNKNOWN
}

4.2 src/main/java/risk/domain/RiskAssessment.java

package risk.domain;

import java.time.Instant;
import java.util.Objects;

/** One answer to the question “how risky is it to lend this item to this borrower?”. */
public record RiskAssessment(RiskLevel level, double confidence, Instant assessedAt) {

    /** The least confidence at which a LOW assessment may approve a request without a person. */
    public static final double AUTOMATIC_APPROVAL_CONFIDENCE = 0.80;

    public RiskAssessment {
        Objects.requireNonNull(level, "level");
        Objects.requireNonNull(assessedAt, "assessedAt");
        if (Double.isNaN(confidence) || confidence < 0.0 || confidence > 1.0) {
            throw new IllegalArgumentException("Confidence must be between 0.0 and 1.0: " + confidence);
        }
    }

    /** Only a LOW level held with enough confidence may skip a person. */
    public boolean permitsAutomaticApproval() {
        return level == RiskLevel.LOW && confidence >= AUTOMATIC_APPROVAL_CONFIDENCE;
    }
}

4.3 src/main/java/risk/domain/RiskAssessmentException.java

package risk.domain;

/** There is no trustworthy assessment: the service failed, did not answer in time or answered nonsense. */
public class RiskAssessmentException extends RuntimeException {
    public RiskAssessmentException(String message) {
        super(message);
    }

    public RiskAssessmentException(String message, Throwable cause) {
        super(message, cause);
    }
}

4.4 src/main/java/risk/domain/ItemRiskAssessor.java

package risk.domain;

/**
 * The domain's question about risk, in the domain's words. An implementation may be a model, a table
 * of rules or a test fake. The contract: the result is never null; if there is no trustworthy answer
 * the call throws {@link RiskAssessmentException}. It never returns a default that looks like an answer.
 */
public interface ItemRiskAssessor {
    RiskAssessment assessRisk(String borrowerId, String itemId);
}

4.5 src/main/java/risk/domain/Route.java

package risk.domain;

/** What happens to a request after it has been screened. */
public enum Route {
    AUTO_APPROVE,
    SUPERVISOR_REVIEW,
    REJECT
}

4.6 src/main/java/risk/domain/ReservationScreen.java

package risk.domain;

import java.util.Objects;

/**
 * Decides how a reservation request is routed, using only the domain contract. It knows nothing about
 * models, HTTP or floating-point scores. A failed assessment is not handled here: it propagates, so an
 * outage can never be turned into an approval by accident.
 */
public final class ReservationScreen {
    private final ItemRiskAssessor assessor;

    public ReservationScreen(ItemRiskAssessor assessor) {
        this.assessor = Objects.requireNonNull(assessor, "assessor");
    }

    public Route route(String borrowerId, String itemId) {
        requireText(borrowerId, "borrowerId");
        requireText(itemId, "itemId");
        RiskAssessment assessment = assessor.assessRisk(borrowerId, itemId);
        if (assessment.level() == RiskLevel.HIGH) {
            return Route.REJECT;
        }
        return assessment.permitsAutomaticApproval() ? Route.AUTO_APPROVE : Route.SUPERVISOR_REVIEW;
    }

    private static void requireText(String value, String name) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(name + " must not be blank.");
        }
    }
}

4.7 src/main/java/risk/adapter/ViolationHistory.java

package risk.adapter;

/** How many times a borrower has returned equipment late. */
public interface ViolationHistory {
    int overdueViolations(String borrowerId);
}

4.8 src/main/java/risk/adapter/RuleBasedRiskAssessor.java

package risk.adapter;

import java.time.Clock;
import java.util.Objects;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;

/**
 * A deterministic, deliberately cautious assessor. It is the fallback when the model cannot answer.
 * Its confidence is fixed below the automatic-approval threshold, so it can never approve a request
 * without a person, whatever the borrower's history.
 */
public final class RuleBasedRiskAssessor implements ItemRiskAssessor {
    static final double CONFIDENCE = 0.50;

    private final ViolationHistory history;
    private final Clock clock;

    public RuleBasedRiskAssessor(ViolationHistory history, Clock clock) {
        this.history = Objects.requireNonNull(history, "history");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    @Override
    public RiskAssessment assessRisk(String borrowerId, String itemId) {
        int violations = history.overdueViolations(borrowerId);
        if (violations < 0) {
            throw new RiskAssessmentException("The history reported a negative number of violations.");
        }
        RiskLevel level = violations == 0 ? RiskLevel.LOW : violations < 3 ? RiskLevel.MODERATE : RiskLevel.HIGH;
        return new RiskAssessment(level, CONFIDENCE, clock.instant());
    }
}

4.9 src/main/java/risk/adapter/HttpRiskPredictionAdapter.java

package risk.adapter;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;
import risk.domain.RiskLevel;

/**
 * Asks a risk service over HTTP: {@code GET <base>/risk?borrower=..&item=..}. A 200 answer carries one
 * line, a level and a confidence separated by a space, such as {@code LOW 0.93}. Everything else is a
 * failure: a timeout, a refused connection, any other status, a body that is not exactly two fields, an
 * unknown level and a confidence outside 0.0 to 1.0. One attempt, a bounded wait that covers the whole
 * answer including the body, no retry and no fallback: whether to fall back is the caller's policy.
 */
public final class HttpRiskPredictionAdapter implements ItemRiskAssessor {
    private final HttpClient http;
    private final URI base;
    private final Duration requestTimeout;
    private final Clock clock;

    public HttpRiskPredictionAdapter(URI base, Duration connectTimeout, Duration requestTimeout, Clock clock) {
        this.base = Objects.requireNonNull(base, "base");
        this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout");
        this.clock = Objects.requireNonNull(clock, "clock");
        this.http = HttpClient.newBuilder()
                .connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout"))
                .build();
    }

    @Override
    public RiskAssessment assessRisk(String borrowerId, String itemId) {
        URI uri = URI.create(base.toString().replaceAll("/+$", "") + "/risk?borrower="
                + URLEncoder.encode(borrowerId, StandardCharsets.UTF_8) + "&item="
                + URLEncoder.encode(itemId, StandardCharsets.UTF_8));
        HttpRequest request = HttpRequest.newBuilder(uri).timeout(requestTimeout).GET().build();
        CompletableFuture<HttpResponse<String>> pending =
                http.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        HttpResponse<String> response;
        try {
            response = pending.get(requestTimeout.toNanos(), TimeUnit.NANOSECONDS);
        } catch (TimeoutException late) {
            pending.cancel(true);
            throw new RiskAssessmentException(
                    "The risk service did not answer within " + requestTimeout.toMillis() + " ms.", late);
        } catch (InterruptedException interrupted) {
            pending.cancel(true);
            Thread.currentThread().interrupt();
            throw new RiskAssessmentException("The risk request was interrupted.", interrupted);
        } catch (ExecutionException failed) {
            Throwable cause = failed.getCause() == null ? failed : failed.getCause();
            throw new RiskAssessmentException("The risk service is unavailable: " + cause.getClass().getSimpleName(), cause);
        }
        if (response.statusCode() != 200) {
            throw new RiskAssessmentException("The risk service answered with status " + response.statusCode() + ".");
        }
        return parse(response.body());
    }

    private RiskAssessment parse(String body) {
        String[] fields = body.strip().split(" ", -1);
        if (fields.length != 2) {
            throw new RiskAssessmentException("The risk service answered with a malformed line.");
        }
        try {
            return new RiskAssessment(RiskLevel.valueOf(fields[0]), Double.parseDouble(fields[1]), clock.instant());
        } catch (IllegalArgumentException invalid) {
            throw new RiskAssessmentException("The risk service answered with an invalid value.", invalid);
        }
    }
}

4.10 src/main/java/risk/adapter/ResilientRiskAssessor.java

package risk.adapter;

import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;

/**
 * Wraps a primary assessor with a bounded wait and an explicit, cautious fallback. The wait is enforced:
 * the primary runs on its own thread and the caller waits for it for at most the limit. On a timeout the
 * wrapper asks the thread to stop and answers from the fallback. Asking is all it can do, because a
 * primary that ignores interruption keeps running; the caller does not depend on it, because the caller
 * has already stopped waiting. Only the expected failure, a {@link RiskAssessmentException}, leads to the
 * fallback. A programming error in the primary propagates, so that it is found and not hidden.
 */
public final class ResilientRiskAssessor implements ItemRiskAssessor, AutoCloseable {
    private static final Logger LOG = Logger.getLogger(ResilientRiskAssessor.class.getName());

    private final ItemRiskAssessor primary;
    private final ItemRiskAssessor fallback;
    private final Duration limit;
    private final ExecutorService executor;
    private final AtomicInteger fallbacks = new AtomicInteger();

    public ResilientRiskAssessor(ItemRiskAssessor primary, ItemRiskAssessor fallback, Duration limit) {
        this.primary = Objects.requireNonNull(primary, "primary");
        this.fallback = Objects.requireNonNull(fallback, "fallback");
        this.limit = Objects.requireNonNull(limit, "limit");
        this.executor = Executors.newCachedThreadPool(task -> {
            Thread thread = new Thread(task, "risk-primary");
            thread.setDaemon(true);
            return thread;
        });
    }

    /** How many times the fallback has answered; a monitor can watch this. */
    public int fallbackCount() {
        return fallbacks.get();
    }

    @Override
    public RiskAssessment assessRisk(String borrowerId, String itemId) {
        Future<RiskAssessment> pending = executor.submit(() -> primary.assessRisk(borrowerId, itemId));
        try {
            return Objects.requireNonNull(pending.get(limit.toNanos(), TimeUnit.NANOSECONDS),
                    "the primary assessor returned null");
        } catch (TimeoutException late) {
            pending.cancel(true);
            return fallBackBecause("no answer within " + limit.toMillis() + " ms", borrowerId, itemId);
        } catch (InterruptedException interrupted) {
            pending.cancel(true);
            Thread.currentThread().interrupt();
            throw new RiskAssessmentException("Interrupted while waiting for the risk service.", interrupted);
        } catch (ExecutionException failed) {
            Throwable cause = failed.getCause();
            if (cause instanceof RiskAssessmentException) {
                return fallBackBecause(cause.getMessage(), borrowerId, itemId);
            }
            if (cause instanceof RuntimeException runtime) {
                throw runtime;
            }
            if (cause instanceof Error error) {
                throw error;
            }
            throw new IllegalStateException("The primary assessor failed unexpectedly.", cause);
        }
    }

    private RiskAssessment fallBackBecause(String reason, String borrowerId, String itemId) {
        fallbacks.incrementAndGet();
        LOG.warning(() -> "Risk service unavailable (" + reason + "); using the cautious fallback.");
        return fallback.assessRisk(borrowerId, itemId);
    }

    @Override
    public void close() {
        executor.shutdownNow();
    }
}

4.11 src/main/java/risk/rollout/RolloutMode.java

package risk.rollout;

/** How far a new model has been rolled out. */
public enum RolloutMode {
    /** The baseline decides; the model is never called. */
    OFF,
    /** The baseline decides; the model is also called and its answer is only recorded. */
    SHADOW,
    /** The model decides for a stable share of borrowers; the baseline decides for the rest. */
    CANARY,
    /** The model decides for everyone. */
    ON
}

4.12 src/main/java/risk/rollout/ProvenanceRecord.java

package risk.rollout;

import java.util.Objects;
import risk.domain.RiskAssessment;

/**
 * What a decision rested on: the model version that was in use, the model's answer (null if it did not
 * answer), the assessment that was actually used, and whether the model decided.
 */
public record ProvenanceRecord(String borrowerId, String itemId, String modelVersion,
                               RiskAssessment modelAnswer, RiskAssessment decision, boolean modelDecided) {
    public ProvenanceRecord {
        Objects.requireNonNull(borrowerId, "borrowerId");
        Objects.requireNonNull(itemId, "itemId");
        Objects.requireNonNull(modelVersion, "modelVersion");
        Objects.requireNonNull(decision, "decision");
    }
}

4.13 src/main/java/risk/rollout/ProvenanceLog.java

package risk.rollout;

/** Where provenance records go. An append-only store in production; a list in tests. */
public interface ProvenanceLog {
    void record(ProvenanceRecord record);
}

4.14 src/main/java/risk/rollout/RolloutAssessor.java

package risk.rollout;

import java.util.Objects;
import risk.domain.ItemRiskAssessor;
import risk.domain.RiskAssessment;
import risk.domain.RiskAssessmentException;

/**
 * Decides, borrower by borrower, whether the model or the baseline answers, and records what each decision
 * rested on. The share of the canary is stable: a borrower is always in or always out for a given percentage.
 */
public final class RolloutAssessor implements ItemRiskAssessor {
    private final ItemRiskAssessor baseline;
    private final ItemRiskAssessor model;
    private final RolloutMode mode;
    private final int canaryPercent;
    private final String modelVersion;
    private final ProvenanceLog log;

    public RolloutAssessor(ItemRiskAssessor baseline, ItemRiskAssessor model, RolloutMode mode,
                           int canaryPercent, String modelVersion, ProvenanceLog log) {
        this.baseline = Objects.requireNonNull(baseline, "baseline");
        this.model = Objects.requireNonNull(model, "model");
        this.mode = Objects.requireNonNull(mode, "mode");
        this.modelVersion = Objects.requireNonNull(modelVersion, "modelVersion");
        this.log = Objects.requireNonNull(log, "log");
        if (canaryPercent < 0 || canaryPercent > 100) {
            throw new IllegalArgumentException("canaryPercent must be between 0 and 100: " + canaryPercent);
        }
        this.canaryPercent = canaryPercent;
    }

    /** A stable choice: the same borrower and percentage always give the same answer. */
    public boolean inCanary(String borrowerId) {
        return Math.floorMod(borrowerId.hashCode(), 100) < canaryPercent;
    }

    @Override
    public RiskAssessment assessRisk(String borrowerId, String itemId) {
        return switch (mode) {
            case OFF -> baseline.assessRisk(borrowerId, itemId);
            case SHADOW -> shadow(borrowerId, itemId);
            case CANARY -> inCanary(borrowerId) ? modelDecides(borrowerId, itemId) : baseline.assessRisk(borrowerId, itemId);
            case ON -> modelDecides(borrowerId, itemId);
        };
    }

    private RiskAssessment shadow(String borrowerId, String itemId) {
        RiskAssessment decision = baseline.assessRisk(borrowerId, itemId);
        RiskAssessment modelAnswer;
        try {
            modelAnswer = model.assessRisk(borrowerId, itemId);
        } catch (RiskAssessmentException unavailable) {
            modelAnswer = null;   // a shadow failure must never affect the decision
        }
        log.record(new ProvenanceRecord(borrowerId, itemId, modelVersion, modelAnswer, decision, false));
        return decision;
    }

    private RiskAssessment modelDecides(String borrowerId, String itemId) {
        RiskAssessment answer = model.assessRisk(borrowerId, itemId);
        log.record(new ProvenanceRecord(borrowerId, itemId, modelVersion, answer, answer, true));
        return answer;
    }
}

4.15 pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>edu.se506</groupId>
  <artifactId>risk-lab</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
        <configuration><failIfNoTests>true</failIfNoTests></configuration>
      </plugin>
    </plugins>
  </build>
</project>

4.16 scripts/check.sh

#!/usr/bin/env bash
# Builds the lab and runs every test, then the same tests without the integration group, so the two
# counts can be reported separately. CHAPTER_MVN names the Maven command (default: mvn). CHAPTER_M2
# optionally names a local Maven repository and CHAPTER_OFFLINE=1 stops Maven from contacting a remote one.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."

MVN="${CHAPTER_MVN:-mvn}"
ARGS=(--batch-mode)
if [ -n "${CHAPTER_M2:-}" ]; then
  ARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")
fi
if [ "${CHAPTER_OFFLINE:-0}" = "1" ]; then
  ARGS+=(--offline)
fi

echo "=== all tests"
"$MVN" "${ARGS[@]}" verify
echo "=== without the integration group"
"$MVN" "${ARGS[@]}" test -DexcludedGroups=integration
echo "RISK LAB PASSED"

4.17 .gitignore

/target/
/build/
*.class

4.18 .gitattributes

*.sh text eol=lf

5. Worked explanation

The contract. ItemRiskAssessor and RiskAssessment are the only things the domain knows about risk. The constructor of RiskAssessment validates the confidence, so an out-of-range value cannot exist. permitsAutomaticApproval() is the one place that holds the threshold. ReservationScreen routes by the domain’s rules and does not catch RiskAssessmentException, so an outage cannot become an approval.

The adapter. HttpRiskPredictionAdapter follows the pattern of Chapter 13’s HttpLegacyDirectoryClient: it sends the request asynchronously, waits for at most the request timeout for the whole answer, and cancels on a timeout. Every outcome other than a good answer is a RiskAssessmentException. The adapter makes no decision about a fallback.

The wrapper. ResilientRiskAssessor is the one place that decides to fall back. It runs the primary on a daemon thread, so that an abandoned call cannot keep the JVM alive, and it uses the same wait-then-cancel pattern. It falls back only for a RiskAssessmentException or a timeout, never for a programming error. It counts its fallbacks. It is AutoCloseable because it owns a thread pool.

The cautious fallback. RuleBasedRiskAssessor fixes its confidence at 0.50, below the 0.80 threshold, so a fallback answer can never approve a request without a person. That is a property of the fallback, and it is tested (theCautiousFallbackNeverApprovesWithoutAPerson).

The rollout. RolloutAssessor implements the four modes. In SHADOW a failure of the model is swallowed and recorded as “no answer”, because a shadow must never affect the decision; in ON and CANARY it propagates, because there the model’s answer is the decision. The canary’s share uses Math.floorMod(hashCode, 100), so that a negative hash code still gives a bucket from 0 to 99.

The stub. StubRiskService is the JDK’s own HTTP server on the loopback address and a free port. Its scenarios are chosen by the borrower identifier. SLOW sends nothing until the stub is closed; SLOWBODY sends its headers and one byte and then stalls.

6. Expected output

=== all tests
[INFO] Tests run: 67, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
=== without the integration group
[INFO] Tests run: 56, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
RISK LAB PASSED

The 67 invocations by class: RiskAssessmentTest 12, ReservationScreenTest 10, RuleBasedRiskAssessorTest 16, ResilientRiskAssessorTest 8, RolloutAssessorTest 10 and HttpRiskPredictionAdapterContractTest 11 (tagged integration). Parameterised tests count once for each value. Maven and the JDK may print warnings; the last lines decide.

7. A small fault experiment

In a separate copy of the lab, make one change at a time and run mvn --batch-mode test. Each experiment checks whether a particular test notices a particular fault.

FaultChangeWhat happens
E1the wrapper waits an hour instead of the limit2 invocation(s) end at the five-second deadline: aStalledPrimaryIsAskedToStop, aStalledPrimaryIsGivenUpOnWithinTheLimitAndTheFallbackAnswers
E2the wrapper does not ask a stalled call to stop1 invocation(s) fail: aStalledPrimaryIsAskedToStop
E3every runtime failure of the primary falls back1 invocation(s) fail: aProgrammingErrorInThePrimaryIsNotHiddenByTheFallback
E4the wrapper does not restore the caller’s interrupt flag1 invocation(s) fail: anInterruptedCallerKeepsItsInterruptFlagAndGetsAFailure
E5automatic approval needs more than the threshold (> for >=)2 invocation(s) fail: eachAssessmentIsRoutedByTheDomainsRules, automaticApprovalNeedsLowAndAtLeastTheThreshold
E6a confidence of exactly 1.0 is rejected5 invocation(s) error: eachAssessmentIsRoutedByTheDomainsRules, aLevelOtherThanLowNeverApprovesAutomatically, confidenceAtTheEdgesIsAccepted
E7three violations stay MODERATE (<= for <)1 invocation(s) fail: theLevelFollowsTheNumberOfViolations
E8HIGH is no longer rejected4 invocation(s) fail: eachAssessmentIsRoutedByTheDomainsRules, eachBorrowerIsAssessedOnTheirOwn
E9an outage becomes an approval1 invocation(s) fail: aFailedAssessmentIsNeverTurnedIntoAnApproval
E10identifiers are not encoded in the request1 invocation(s) error: identifiersAreEncodedInTheRequest
E11the adapter waits an hour instead of the limit1 invocation(s) end at the five-second deadline: aServiceThatStallsInTheMiddleOfTheBodyIsAlsoBounded
E12in shadow mode the model’s answer decides1 invocation(s) fail: inShadowTheBaselineDecidesAndTheModelsAnswerIsOnlyRecorded
E13a shadow failure escapes1 invocation(s) error: aShadowFailureNeverChangesTheDecisionAndIsRecordedAsNoAnswer
E14the canary includes one bucket too many (<= for <)1 invocation(s) fail: zeroPercentIsNobodyAndOneHundredPercentIsEverybody
E15a different but valid assignment of borrowers to the canary (Math.abs(hash % 100))No test fails.

Fourteen of the fifteen faults are noticed. The survivor, E15, is a different but valid way to assign borrowers to the canary: the contract promises a stable share, not a particular one, so no test should fail, and none does. It is an equivalent change in the sense of Chapter 15. The experiment checks that these tests catch these faults; it does not show that the lab is correct for every input.

E1 and E11 deserve a second look. They replace the limit with one hour, so a missing bound is not a failed assertion; it is a hang. The tests carry an outer deadline of five seconds (@Timeout(5)), which turns the hang into a failure. The same deadline is why E11 catches what the client’s own request timeout does not: only the stalled body is left unbounded, as Chapter 13 showed.

8. Answers to the exercise

Part A. The probe shows three failures: no limit on the wait, an outage that looks like a refusal and a nonsense score that is accepted. Reading the class finds more:

FailureWhereWhy it matters
No timeoutopenConnection() and getInputStream()A stalled service holds the caller for as long as it likes (the probe: 1,502 ms)
An outage looks like a refusalcatch (Exception anything) { return false; }The caller cannot tell “high risk” from “service down”
A nonsense score is acceptedFloat.parseFloat(parts[1]), no range checkA confidence of 1.7 was approved
A magic numberscore > 0.8fNo name and no owner; also > where the domain’s rule is >=
A level compared as textparts[0].equals("LOW")A new level or a misspelling is silently “no”
Identifiers are not encodedthe string concatenationA space or & in an identifier breaks or changes the request
Every exception is swallowedthe catch-allA programming error (NullPointerException on a missing line) looks like a refusal
The reader and the connection are never closedin, connectionA resource leak
No test is possible without the servicethe constructor takes a URLThere is no seam
No record of what was decidedthe whole methodNothing says which answer a decision rested on

Part F. Removing the limit (E1) makes aStalledPrimaryIsGivenUpOnWithinTheLimitAndTheFallbackAnswers and aStalledPrimaryIsAskedToStop fail at their deadline. It tells you that the bound is tested, and not only written.

The closing note. A good note lists at least eight failures from part A (the table has ten), gives the count that passes, names one fault and the test that caught it, and names a limit: for example that no test says the model is accurate, that the stub is not the real service, or that nothing was run on a Java 17 runtime.

9. Teaching and revision notes

The probe takes a minute and makes the case better than any argument. Ask learners to predict its four lines before they run it. The decision between failing and falling back (Appendix F, Section F.5) is worth a class discussion: ask learners when a fallback is safe, and have them name one case in the lending project where it would not be (the borrower’s category).

Keep the lab separate from the lending project. Do not add its tests to the lending project’s totals, and do not cite its counts as a measure of the quality of any model. The stub’s response format (LOW 0.93) is the lab’s own and is not a standard.

References

Appendix F lists the sources for this file. The wait-then-cancel pattern is that of Chapter 13, Section 13.2.

Modern Front-End Engineering - Back Cover