Java versions, incremental deltas, lab solutions, and instructor verification records
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:
Executable Java Code Listings: Production-quality Java classes and suites conforming to the running Campus Equipment Lending System.
Step-by-Step Code Walkthroughs: Line-by-line analyses of design decisions, type safety, invariants, and exception handling.
Lab Solutions & Exercises: Model answers for both required core labs and optional team extensions.
Verification Records & Fault Experiments: Documented test suite outputs (mvn verify), mutation/fault analysis records, and reproducible compiler checks.
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.
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 declaration
Meaning
public final class ReservationRules
Declares 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 activeReservations
Receives the existing count; it does not retrieve or update that count
int requestedDays
Receives the requested integer duration in days
activeReservations >= 0
Rejects an invalid negative count
activeReservations < 3
Allows another reservation only when the current count is below the limit
requestedDays >= 1
Rejects zero and negative durations
requestedDays <= 7
Includes 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.
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:
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.
publicclassChapter01Checks{publicstaticvoidmain(String[]args){ReservationRulesrules=newReservationRules();// 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){booleanexpected=example[2]==1;booleanactual=rules.mayRequest(example[0],example[1]);if(actual!=expected){thrownewAssertionError("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.
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
Call
Expected result
Reason
mayRequest(0, 1)
true
Count and duration are within the permitted ranges
mayRequest(2, 7)
true
Two existing reservations permit one more; seven days is inclusive
mayRequest(3, 1)
false
Three existing reservations already reach the limit
mayRequest(1, 8)
false
Eight days exceeds the student duration limit
mayRequest(-1, 1)
false
A negative count is explicitly rejected
mayRequest(1, 0)
false
The 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:
Category
Existing count
Days
Numeric-rule decision
Student
2
7
Allow
Student
3
1
Reject
Student
0
8
Reject
Staff
4
14
Allow
Staff
5
1
Reject
Staff
0
15
Reject
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
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.
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.
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.
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.
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.
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:
File
Role
ReservationRules.java
The unchanged numeric policy from Chapter 1
LoanPeriod.java
Valid dates and exclusive-end overlap semantics
Reservation.java
Immutable reservation details
ReservationCoordinator.java
The starting request workflow from Section 2.4
Chapter02Demo.java
Three requests illustrating success, overlap and adjacency
Chapter02Checks.java
Supplied 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.
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.
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.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.
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
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.
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalReservationRulesrules=newReservationRules();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!rules.mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("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()+").";}publicList<Reservation>reservations(){returnList.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.
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.
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=newReservationCoordinator();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");ReservationCoordinatorinvalid=newReservationCoordinator();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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(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.
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalReservationRulesrules=newReservationRules();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!rules.mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnconfirmationFor(reservation);}privateStringconfirmationFor(Reservationreservation){return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}publicList<Reservation>reservations(){returnList.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 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:
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
Call
Outcome
Stored count afterwards
Important step
B1 requests CAM1 from 20 October for two days
Confirmed as R1
1
Empty history has no conflict
B2 requests CAM1 from 21 October for one day
Rejected as unavailable
1
The period overlaps R1 for the same item
B2 requests CAM1 from 22 October for one day
Confirmed as R2
2
Equality 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.
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 scenario
Model decision
A kit holds one or more items; removing it does not delete them
Aggregation, with 1..* at the items end
An item belongs to at most one kit
0..1 at the kit end
A record belongs to exactly one item and has no meaning without it
Composition 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 later
Exporter 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
Each 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..*" Reservation
Each 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" LoanPeriod
Each 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 diagram
Why it is wrong
Correct notation
1
-Locker locker attribute in Technician
It repeats the association line, and it implies one locker per technician
As drawn, each locker has any number of technicians and each technician looks after exactly one locker
1 at the Technician end and 0..* at the Locker end
3
The same line has a filled diamond at Technician
It claims lockers are discarded with their technician, but the scenario says a locker can be reassigned
A 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
5
Locker ..> PlainTextPrinter and report(PlainTextPrinter printer)
The locker depends on the concrete class, so it cannot accept another format
Depend 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.
Accept
Do not accept without a justification
Hand-drawn or Mermaid diagrams, since the notation matters, not the tool
Multiplicities at the wrong end
A plain association with or without arrowheads where the description does not say who holds the reference
An arrowhead that implies navigation the description or code does not support
Alternative class names and sensible extra attributes
A composition diamond with no lifetime argument
Different but defensible choices, such as Tool as an attribute
Inheritance used for âhasâ or âusesâ
Omitting classes the description does not need
A 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.
Claim
Judgement
Evidence or qualification
Validates identifiers
Supported within scope
Rejects null or blank borrower/item identifiers; does not validate them against a catalogue
Counts a borrower’s stored reservations
Supported
Loops over stored records and compares borrower identifiers
Applies the numeric rule
Supported
Calls the unchanged ReservationRules.mayRequest
Checks conflicts for the same item
Supported
Requires matching item identifiers and an overlapping period
Adds before returning a confirmation
Supported
Successful path calls add before constructing or returning the message
Sends email
Contradicted as a description of this code
There is no sender or delivery call; the method returns a string
Persists to a database
Contradicted as a description of this code
Storage is an in-memory ArrayList; no database operation occurs
Final list means callers receive a deeply immutable copy
Incorrect explanation
final prevents field reassignment; List.copyOf protects the returned list structure and is shallow; the element types separately provide immutability
Concurrent booking is safe
Not established and not provided by this implementation
Checking 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
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.
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.
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.
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.
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.
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:
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.
Order
ID
Item
Type
Current support and remaining work
1
US-01
Record an eligible, non-conflicting student reservation
User story
Core behaviour exists; clarify the user interaction and assess the complete delivery evidence before marking Done
2
US-02
Inspect availability without reserving an item
User story
New query behaviour; must read current records without mutating them
3
EN-01
Automate the existing build and checks
Technical enabler
Local commands exist; an automated workflow is planned for the kickoff work
4
US-03
Inspect one borrower’s active reservations
User story
An all-records snapshot exists; a defined borrower-focused interaction and filter are still needed
5
US-04
Cancel a confirmed reservation under an agreed policy
User story
New lifecycle behaviour; cancellation authority and permitted states require clarification
6
US-05
Reject unknown equipment identifiers using an agreed catalogue
User story
Current 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.
Criterion
Example and expected observation
AC-01
With no records, B1 requests CAM1 from 20 October 2026 for two days; one reservation is recorded and its confirmation returned
AC-02
With 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-03
B2 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-04
With three active reservations for B1 across different items, a fourth is rejected and the count remains three
AC-05
One-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.
Criterion
Given and when
Expected observations
AQ-01
No stored reservations; inspect CAM1 for [20 Oct, 22 Oct)
Available within this record set; count and records unchanged
AQ-02
CAM1 is reserved for [20 Oct, 22 Oct); inspect it for [21 Oct, 23 Oct)
Unavailable; existing record unchanged
AQ-03
The same existing reservation; inspect CAM1 for [22 Oct, 23 Oct)
Available under the current adjacency convention; records unchanged
AQ-04
CAM1 is reserved; inspect a different known item MIC1 for the same period
Available if MIC1 has no conflict; records unchanged
AQ-05
Inspect an invalid period with equal or reversed dates
An 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:
Applicable acceptance examples are verified, including important rejection paths and state effects.
Source compiles and executes using the documented environment and commands.
A peer review has occurred and findings that prevent acceptance are resolved.
Work is integrated into the shared codebase, and existing checks still pass.
Usage notes, domain decisions and known limitations are updated where needed.
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 condition
Example
Question answered
Acceptance criterion
An enquiry leaves reservations unchanged
Does this behaviour satisfy the story?
Definition of Done
The integrated change has been reviewed and its relevant checks pass
Does the increment meet the shared quality standard?
Local Ready policy
Known scope, example inputs and identified blocking questions
Is 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 work
Example estimate
Assumption or uncertainty
US-02 local availability enquiry
3 points
Known sample identifiers, existing date convention, no external catalogue, narrow agreed interaction
US-03 inspect one borrower’s active records
2 points
Current definition of active remains unchanged; officer-operated controlled demonstration
US-04 cancellation
Not yet estimated
Authority, 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).
Suggestion
Disposition
Reason and next action
Refuse a request for the same item and an identical period
Accept
An identical period overlaps; this is a useful special case of AC-02
Refuse a request beginning exactly when an earlier period ends
Reject for this story
Contradicts the current exclusive-end convention and AC-03
Email every confirmation within thirty seconds
Reject for this story
Adds 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 unchanged
Accept
Matches existing state-preservation expectations, including AC-02 and AC-05
Specify how past start dates are treated
Clarify
The current policy does not establish a past-date restriction; do not invent one. Ask the stakeholder
Take a deposit payment before confirming
Reject for this story
Introduces a new business process and unapproved scope
Specify whether cancelled reservations count toward the limit
Clarify and link to US-04
Cancellation 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
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=newReservationCoordinator();// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=newReservationCoordinator();for(Stringitem:newString[]{"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.ReservationCoordinatordurations=newReservationCoordinator();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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(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
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
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.
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.
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.
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.
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.
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
Field
What to record
ID and title
Stable identifier and a short outcome
Actor and value
Who needs it and why
Scope
What this item includes and the boundaries of the demonstration
Rules and assumptions
Confirmed decisions separated from temporary assumptions
Acceptance examples
Inputs, action, expected result and relevant state effects
Current support
Existing code or evidence, if any
Dependencies and questions
Blocking decisions, related items and responsible follow-up
Estimate
Optional local relative size, assumptions and date of discussion
Completion evidence
Links or notes for checks, review, integration and demonstration
Decision record for a proposed criterion
Proposal
Accept, reject or clarify
Reason and evidence
Follow-up or related item
Write the proposed behaviour
Choose one
Identify the rule, scope boundary or missing decision
Record 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 ReservationRules
Chapter 3 runner
Chapter 2 runner
Original
All 5 acceptance examples passed
All 34 checks passed
First overlap comparison changed to !start.isAfter(other.endExclusive)
Failed at AC-03 request ending at an existing start
Failed at adjacency symmetric
Second overlap comparison changed to !other.start.isAfter(endExclusive)
Failed at AC-03 adjacent confirmation
Not run
activeReservations < 3 changed to <= 3
Failed at AC-04 fourth rejected
Not run
requestedDays <= 7 changed to < 7
Failed at AC-05 seven days
Not 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.
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.
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.
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
2.3 src/main/java/Reservation.java
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
2.4 src/main/java/ReservationCoordinator.java
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalReservationRulesrules=newReservationRules();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!rules.mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnconfirmationFor(reservation);}privateStringconfirmationFor(Reservationreservation){return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=newReservationCoordinator();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");ReservationCoordinatorinvalid=newReservationCoordinator();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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=newReservationCoordinator();// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=newReservationCoordinator();for(Stringitem:newString[]{"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.ReservationCoordinatordurations=newReservationCoordinator();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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(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 checkson:push:pull_request:workflow_dispatch:permissions:contents:readjobs:verify:runs-on:ubuntu-latesttimeout-minutes:5steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'- name:Compile and checkrun: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 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.
Obtain a passing baseline with bash scripts/check.sh.
Create a disposable practice branch, for example git switch -c practice/ci-failure.
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");
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.
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.
Restore == 1, commit the restoration on the practice branch and run the same checks again. Retain the restored passing run.
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
Field
D-01: storage
D-02: availability interaction
Question
Must kickoff preserve records across restarts?
What input expresses the query interval?
Proposed decision
Keep one in-memory coordinator
Use start and explicitly labelled exclusive end
Reason
Demonstration evaluates reservation decisions in one session
Makes the half-open interval visible during discussion
Consequence
Restart loses records and identifier sequence restarts
Officer needs clear date guidance; equal/reversed bounds must be reported
Review trigger
A selected outcome requires a session to survive restart
User walkthrough reveals persistent confusion or prefers duration
Status
Existing teaching baseline
Proposed 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
Group
Item
State and next action
Reservation decisions
US-01: eligible reservation
Core exists; collect actual delivery and review evidence
Reservation decisions
US-02: availability enquiry
Refine AQ-01âAQ-05; select as candidate next slice
Delivery support
EN-01: automated build/checks
Local gate supplied; obtain hosted run evidence
Record lifecycle
US-03: borrower record inspection
Defer filtering and interaction until selected
Record lifecycle
US-04: cancellation
Clarify authority and count effects before estimating
Equipment identity
US-05: unknown-item rejection
Clarify 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:
Field
Team entry
Repository and baseline revision
Fill with the actual repository and commit
Implemented behaviour
List only the behaviour present at that revision
Local environment and command
Record Java version, shell and command
Local check result
Record observed exit status and relevant output
Hosted passing run
Actual run URL or retained log, tested revision and date
Deliberate failure
Practice revision, failed assertion and failed run
Restored pass
Restored revision and passing run
Independent review
Actual reviewer, finding, correction and follow-up
Unresolved questions
Owner, impact and next decision point
Known limitations
Scope 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 proposal
Verdict
Reason and correction
Allocate all 22 hours to features
Reject
Coordination, review and uncertainty consume capacity; use the explicit budget
Deliver query, cancellation, mobile, database and email together
Reject
The selected goal is one query slice; several additions lack agreed requirements
Give each member one layer and integrate at the end
Revise
Ownership can help, but final-day integration delays learning; collaborate around the thin journey
Convert three story points into three hours
Reject
No valid conversion was established; estimate tasks using actual understanding
Call reserve and then delete the record to check availability
Reject
Query must not change state; deletion is not even a supplied operation; eligibility also differs
Defer failure cases
Reject
Invalid intervals and non-mutation are part of this outcome’s acceptance
Mark EN-01 complete when YAML is committed
Reject
Configuration must execute successfully and detect the intended failure
A green badge proves the whole story
Reject
The 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:
Dimension
Useful feedback question
Scope
Can the team explain the conditions under which its claim is valid?
Slice
Does the result support one observable outcome, including necessary failures?
Planning
Is review and uncertainty accounted for, and can people collaborate when needed?
Traceability
Does the cited check actually exercise the criterion?
Workflow
Is there real evidence of review, corrections and the integrated result?
Honesty
Are 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.
Schwaber, K. and Sutherland, J., The Scrum Guide, November 2020.
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.
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 directory
Meaning
Behaviour
before
Chapter 4 starting point
Existing student reservation rule and private confirmation method
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:
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.
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.
Section 4 supplies the extracted coordinator and formatter. Copy that formatter into after/src/main/java/ConfirmationFormatter.java too.
Section 5 supplies the remaining after files, including its changed student rules and updated gate.
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:
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:
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:
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
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
2.2 shared/src/main/java/Reservation.java
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=newReservationCoordinator();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");ReservationCoordinatorinvalid=newReservationCoordinator();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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=newReservationCoordinator();// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=newReservationCoordinator();for(Stringitem:newString[]{"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.ReservationCoordinatordurations=newReservationCoordinator();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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(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"?><rulesetname="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><ruleref="category/java/design.xml/CyclomaticComplexity"/><ruleref="category/java/design.xml/CognitiveComplexity"/><ruleref="category/java/design.xml/NPathComplexity"/><ruleref="category/java/design.xml/GodClass"/><ruleref="category/java/design.xml/DataClass"/><ruleref="category/java/design.xml/TooManyMethods"/><ruleref="category/java/design.xml/ExcessiveParameterList"/><ruleref="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 ];thenaux=(--aux-classpath build/classes)fiPMD_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.
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalReservationRulesrules=newReservationRules();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!rules.mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnconfirmationFor(reservation);}privateStringconfirmationFor(Reservationreservation){return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}publicList<Reservation>reservations(){returnList.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.
importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){Objects.requireNonNull(reservation,"reservation");return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalReservationRulesrules=newReservationRules();privatefinalConfirmationFormatterformatter=newConfirmationFormatter();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!rules.mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnformatter.format(reservation);}publicList<Reservation>reservations(){returnList.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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalBorrowingPolicystudentPolicy=newReservationRules();privatefinalBorrowingPolicystaffPolicy=newStaffReservationRules();privatefinalConfirmationFormatterformatter=newConfirmationFormatter();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policyFor(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnformatter.format(reservation);}privateBorrowingPolicypolicyFor(BorrowerCategorycategory){returnswitch(category){caseSTUDENT->studentPolicy;caseSTAFF->staffPolicy;};}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
importjava.time.LocalDate;importjava.util.List;publicfinalclassChapter05Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){BorrowingPolicystudent=newReservationRules();BorrowingPolicystaff=newStaffReservationRules();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");LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorc=newReservationCoordinator();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");ReservationCoordinatorlimits=newReservationCoordinator();for(inti=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");ReservationCoordinatordefaultStudent=newReservationCoordinator();ReservationCoordinatorexplicitStudent=newReservationCoordinator();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.ReservationCoordinatormixedFixture=newReservationCoordinator();for(inti=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.");}privatestaticvoidsharedPolicyContract(BorrowingPolicypolicy){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");booleanfirst=policy.mayRequest(1,2);policy.mayRequest(0,1);check(policy.mayRequest(1,2)==first,"repeat decision unchanged by intervening call");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(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
importjava.util.ArrayList;importjava.util.List;publicfinalclassLspExample{// Contract: accept any int, append it once, and preserve earlier values in order.staticclassIntHistory{privatefinalList<Integer>values=newArrayList<>();publicvoidadd(intvalue){values.add(value);}publicList<Integer>values(){returnList.copyOf(values);}}// INTENTIONALLY INVALID SUBTYPE: strengthens the precondition of add.staticfinalclassPositiveOnlyHistoryextendsIntHistory{@Overridepublicvoidadd(intvalue){if(value<=0)thrownewIllegalArgumentException("Positive values only.");super.add(value);}}// Separate type with an honest, narrower contract; not an IntHistory subtype.staticfinalclassPositiveHistory{privatefinalIntHistoryentries=newIntHistory();publicvoidaddPositive(intvalue){if(value<=0)thrownewIllegalArgumentException("Positive values only.");entries.add(value);}publicList<Integer>values(){returnentries.values();}}privatestaticvoidclient(IntHistoryhistory){history.add(5);history.add(-2);if(!history.values().equals(List.of(5,-2))){thrownewAssertionError("Client expects both entries in order.");}}publicstaticvoidmain(String[]args){client(newIntHistory());System.out.println("Base contract client passed.");booleancounterexampleObserved=false;try{client(newPositiveOnlyHistory());}catch(IllegalArgumentExceptionexpected){counterexampleObserved=true;System.out.println("Expected counterexample: "+expected.getMessage());}if(!counterexampleObserved){thrownewAssertionError("Expected the invalid subtype to reject -2.");}PositiveHistorypositive=newPositiveHistory();positive.addPositive(5);booleaninvalidRejected=false;try{positive.addPositive(-2);}catch(IllegalArgumentExceptionexpected){invalidRejected=true;}if(!invalidRejected||!positive.values().equals(List.of(5))){thrownewAssertionError("Narrower type must reject without changing entries.");}System.out.println("Separate positive-history contract passed.");}}
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
Element
Present responsibility
Relevant change pressure
Decision in this chapter
ReservationCoordinator
Sequence validation, eligibility, conflict detection and storage
Request workflow and collaborator selection
Preserve sequencing; delegate formatting and policy evaluation
ReservationRules
Student count/duration decision
Student borrowing limits
Preserve predicate; implement the shared policy contract
LoanPeriod
Valid interval and overlap semantics
Meaning of dates and boundaries
Leave unchanged
Reservation
Immutable accepted-record details
Data carried by a reservation
Leave unchanged
Confirmation expression
Render an accepted record as text
Wording/layout needs
Extract a concrete formatter
Staff eligibility
New count/duration variation
Explicit illustrative staff rule
Add 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.
Smell
Where it appears in reserve
Matches a change pressure?
Act now?
More than one reason to change
One method validates identifiers, counts the borrower’s records, applies eligibility, searches for a conflict, creates and stores the record and, through confirmationFor, produces the wording
Yes: wording and staff eligibility are listed pressures
Yes for wording (Stage A) and eligibility (Stage B); no for storage
Hidden construction
private final ReservationRules rules = new ReservationRules(); fixes the policy inside the coordinator
Yes: the staff variation needs another policy
Only as far as Stage B needs; full dependency choice is Chapter 6
Repeated or similar logic
Two loops over reservations differ only in the test (borrowerId count versus itemId overlap); rejections are returned as two plain strings
Weakly: no listed pressure asks for it
No: two short loops are clearer than a new abstraction here
Primitive values standing for concepts
borrowerId, itemId and requestedDays are strings and an integer; the rejection reasons are free text
No pressure in this chapter
No: 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.
Source
Finding
Group
Interpretation and action
PMD, CyclomaticComplexity
reserve has complexity 12 (threshold 10)
Useful
The 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, DataClass
Reservation is a suspected data class
Low-value here
It 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 reading
new ReservationRules() inside the coordinator
Missed by the tool
The 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 reasons
Missed by the tool
A caller must compare strings. Record it as a limitation; no pressure in this chapter
Manual reading
Two similar loops over reservations
Not reported by PMD at its default thresholds
Leave 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 set
Findings on before
What they were
Lab set (eight design rules)
2
The two above
rulesets/java/quickstart.xml
5
All NoPackage: the book uses the unnamed package until Chapter 10
category/java/codestyle.xml
34
MethodArgumentCouldBeFinal, LocalVariableCouldBeFinal, NoPackage, OnlyOneReturn and similar style rules
category/java/bestpractices.xml
4
SystemPrintln 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
Contract
Legitimate inputs/outcomes
Client may not assume
Common BorrowingPolicy
Every integer pair can be evaluated; invalid numeric inputs return false; decision is deterministic and free of the forbidden side effects
Every policy has student limits or proves availability
Student policy
Counts 0â2 and days 1â7 accepted; other pairs rejected
Fourteenth-day staff behaviour
Staff policy
Counts 0â4 and days 1â14 accepted; other pairs rejected
An authoritative staff identity check
Original reserve overload
Existing student eligibility and request workflow preserved
Selecting a category based on identifier text
Explicit-category overload
Uses the supplied non-null category and all existing records for that borrower
Persisted 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 change
Verdict
Concrete reason
Give every class an interface
Not justified as stated
No second role or client need is identified for immutable record and interval values; it adds naming and navigation
Add coordinator factory and abstract base
Defer
Two rules do not require a factory hierarchy; direct policy selection remains inspectable
Replace rejection strings with exceptions
Separate feature/API proposal
Existing callers and acceptance examples observe the current rejection results
Make staff rules extend student rules
Reject for this source and contract
ReservationRules is final; removing final would still leave staff approval incompatible with the exact student predicate
Remove all conditionals so no file ever changes
Reject the promise
An implementation must still be selected somewhere; moving or hiding that decision is not eliminating it
Shorten the extracted confirmation to âReservedâ
Reject during Stage A
The exact output changes, so the stage is no longer the promised extraction
Green build proves SOLID
Reject
Compilation 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:
Evidence
What to record
Starting point
Actual revision/version and passing original gate
Stage A
Relevant diff; original checks with unchanged expectations; observed result
Stage B
New rule table; policy contract; compatibility and staff-boundary results
LSP example
Valid base call sequence; subtype outcome; precise violated promise
Review
Actual reviewer or clearly labelled self-review; findings and corrections
Design rationale
Reason for each boundary; alternatives considered; remaining selector coupling
Hosted CI, if used
Actual 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 change
Separate policy classes
Configured numeric limits
Change staff days from 14 to another positive limit
Edit staff predicate
Edit staff configuration
Add a third pair of simple limits
Add another policy or share implementation
Supply another configuration
Introduce a qualitatively different eligibility rule
New implementation may express it directly
May need a broader design beyond two numbers
Trace the current rule in review
Read the concrete predicate
Read 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.
Score
Illustrative policy A: pass at 50
Illustrative policy B: pass at 60
49
REVIEW
REVIEW
50
PASS
REVIEW
59
PASS
REVIEW
60
PASS
PASS
100
PASS
PASS
-1 or 101
IllegalArgumentException
IllegalArgumentException
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.
Fault
Result
Staff count limit < 5 changed to <= 5
Caught: staff sixth rejected
Staff duration <= 14 changed to < 14
Caught: staff upper accepted boundaries
Staff duration <= 14 changed to <= 15
Caught: staff fifteen days rejected
Staff negative-count guard removed
Caught: negative count returns false
Student count limit < 3 changed to < 5
Caught: fourth rejected
Student duration <= 7 changed to <= 14
Caught: eight-day duration rejected
Selector arms swapped (STUDENT uses the staff policy)
Caught: fourth rejected
Old four-argument overload delegates with STAFF
Caught: fourth rejected
Confirmation text changed to Reserved:
Caught: exact first confirmation
Confirmation drops the end date
Caught: exact first confirmation
Storing the reservation skipped
Caught: one success stored
Count restricted to staff requests only
Caught: fourth rejected
Overlap check ignores the item
Caught: different item and seven-day limit allowed
Not 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.
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.
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.
Stage
Purpose
How to use it
Chapter 5 baseline
Policies and formatter, no notification output
Use the complete Chapter 5 after version
Coupled teaching version
Add local notification with direct provider construction
In a copy of Chapter 5, replace the coordinator with Section 2.2 and add the three indicated sender files
Chapter 6 result
Supply notification, formatting and policy mapping from outside
Reconstruct 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.
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.
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalBorrowingPolicystudentPolicy=newReservationRules();privatefinalBorrowingPolicystaffPolicy=newStaffReservationRules();privatefinalConfirmationFormatterformatter=newConfirmationFormatter();publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policyFor(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);returnformatter.format(reservation);}privateBorrowingPolicypolicyFor(BorrowerCategorycategory){returnswitch(category){caseSTUDENT->studentPolicy;caseSTAFF->staffPolicy;};}publicList<Reservation>reservations(){returnList.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.
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalBorrowingPolicystudentPolicy=newReservationRules();privatefinalBorrowingPolicystaffPolicy=newStaffReservationRules();privatefinalConfirmationFormatterformatter=newConfirmationFormatter();privatefinalNotificationSendernotifications=newConsoleNotificationSender(System.out,newConfirmationFormatter());publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policyFor(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}privateBorrowingPolicypolicyFor(BorrowerCategorycategory){returnswitch(category){caseSTUDENT->studentPolicy;caseSTAFF->staffPolicy;};}publicList<Reservation>reservations(){returnList.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
publicenumBorrowerCategory{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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
3.5 after/src/main/java/LoanPeriod.java
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
3.6 after/src/main/java/Reservation.java
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){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.
*/@FunctionalInterfacepublicinterfaceNotificationSender{voidsend(Reservationreservation);}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
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
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorinvalid=ReservationApplication.create(newRecordingNotificationSender());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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(Stringitem:newString[]{"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.ReservationCoordinatordurations=ReservationApplication.create(newRecordingNotificationSender());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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}}}
4.3 after/src/test/java/Chapter05Checks.java
importjava.time.LocalDate;importjava.util.List;publicfinalclassChapter05Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){BorrowingPolicystudent=newReservationRules();BorrowingPolicystaff=newStaffReservationRules();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");LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(inti=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");ReservationCoordinatordefaultStudent=ReservationApplication.create(newRecordingNotificationSender());ReservationCoordinatorexplicitStudent=ReservationApplication.create(newRecordingNotificationSender());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.ReservationCoordinatormixedFixture=ReservationApplication.create(newRecordingNotificationSender());for(inti=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.");}privatestaticvoidsharedPolicyContract(BorrowingPolicypolicy){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");booleanfirst=policy.mayRequest(1,2);policy.mayRequest(0,1);check(policy.mayRequest(1,2)==first,"repeat decision unchanged by intervening call");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
4.4 after/src/test/java/Chapter06Checks.java
importjava.io.ByteArrayOutputStream;importjava.io.OutputStream;importjava.io.PrintStream;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.time.LocalDate;importjava.util.EnumMap;importjava.util.List;importjava.util.Map;publicfinalclassChapter06Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorc=ReservationApplication.create(inbox);Stringfirst=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");FailingSenderfailure=newFailingSender();ReservationCoordinatorfailed=ReservationApplication.create(failure);StringfailureResult=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");ReservationCoordinatorunexpected=ReservationApplication.create(newBrokenSender());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,()->newReservationCoordinator(null,newConfirmationFormatter(),inbox),"null policy map");expect(IllegalArgumentException.class,()->newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules()),newConfirmationFormatter(),inbox),"missing staff policy");expect(NullPointerException.class,()->newReservationCoordinator(configured,null,inbox),"null formatter");expect(NullPointerException.class,()->newReservationCoordinator(configured,newConfirmationFormatter(),null),"null sender");Map<BorrowerCategory,BorrowingPolicy>mutable=newEnumMap<>(configured);ReservationCoordinatorcopied=newReservationCoordinator(mutable,newConfirmationFormatter(),newRecordingNotificationSender());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=newEnumMap<>(configured);restrictive.put(BorrowerCategory.STUDENT,(count,days)->false);RecordingNotificationSenderrestrictedInbox=newRecordingNotificationSender();ReservationCoordinatorrestricted=newReservationCoordinator(restrictive,newConfirmationFormatter(),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");ByteArrayOutputStreambytes=newByteArrayOutputStream();ConsoleNotificationSenderconsole=newConsoleNotificationSender(newPrintStream(bytes,true,StandardCharsets.UTF_8),newConfirmationFormatter());console.send(c.reservations().get(0));check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: "+first+System.lineSeparator()),"console implementation uses supplied stream");PrintStreambrokenOutput=newPrintStream(newOutputStream(){@Overridepublicvoidwrite(intvalue)throwsIOException{thrownewIOException("Expected output failure");}});ConsoleNotificationSenderbrokenConsole=newConsoleNotificationSender(brokenOutput,newConfirmationFormatter());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.");}privatestaticMap<BorrowerCategory,BorrowingPolicy>policies(){returnMap.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());}privatestaticfinalclassFailingSenderimplementsNotificationSender{privateintcalls;@Overridepublicvoidsend(Reservationreservation){calls++;thrownewNotificationException("Expected teaching failure");}}// A programming defect, not an expected output failure: it must not be swallowed.privatestaticfinalclassBrokenSenderimplementsNotificationSender{@Overridepublicvoidsend(Reservationreservation){thrownewIllegalStateException("Unexpected programming failure");}}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(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.
importjava.time.LocalDate;importjava.util.HashMap;importjava.util.HashSet;importjava.util.Map;importjava.util.Objects;importjava.util.Set;importjava.util.function.Supplier;publicfinalclassContainerWalkthrough{// Educational, single-threaded container: one lazy instance per registered type.// No reflection, annotations, scopes, resource cleanup or production guarantees.staticfinalclassDemoContainer{privatefinalMap<Class<?>,Supplier<?>>providers=newHashMap<>();privatefinalMap<Class<?>,Object>instances=newHashMap<>();privatefinalSet<Class<?>>constructing=newHashSet<>();public<T>voidbind(Class<T>type,Supplier<?extendsT>provider){Objects.requireNonNull(type,"type");Objects.requireNonNull(provider,"provider");if(providers.containsKey(type)){thrownewIllegalArgumentException("Duplicate binding: "+type.getSimpleName());}providers.put(type,provider);}public<T>Tget(Class<T>type){if(instances.containsKey(type))returntype.cast(instances.get(type));Supplier<?>provider=providers.get(type);if(provider==null)thrownewIllegalArgumentException("Missing binding: "+type.getSimpleName());if(!constructing.add(type))thrownewIllegalStateException("Dependency cycle: "+type.getSimpleName());try{Tinstance=type.cast(Objects.requireNonNull(provider.get(),"provider result"));instances.put(type,instance);returninstance;}finally{constructing.remove(type);}}}publicstaticvoidmain(String[]args){DemoContainercontainer=newDemoContainer();container.bind(ConfirmationFormatter.class,ConfirmationFormatter::new);container.bind(NotificationSender.class,RecordingNotificationSender::new);container.bind(ReservationCoordinator.class,()->newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules()),container.get(ConfirmationFormatter.class),container.get(NotificationSender.class)));ReservationCoordinatorfirst=container.get(ReservationCoordinator.class);ReservationCoordinatoragain=container.get(ReservationCoordinator.class);if(first!=again)thrownewAssertionError("Expected one coordinator instance.");System.out.println(first.reserve("B1","CAM1",LocalDate.of(2026,10,20),2));if(again.reservations().size()!=1)thrownewAssertionError("Expected shared state.");System.out.println("Repeated resolution shares the same coordinator and one reservation.");DemoContainermissing=newDemoContainer();try{missing.get(NotificationSender.class);thrownewAssertionError("Expected missing-binding failure.");}catch(IllegalArgumentExceptionexpected){System.out.println(expected.getMessage());}DemoContainercyclic=newDemoContainer();cyclic.bind(NotificationSender.class,()->cyclic.get(NotificationSender.class));try{cyclic.get(NotificationSender.class);thrownewAssertionError("Expected cycle failure.");}catch(IllegalStateExceptionexpected){System.out.println(expected.getMessage());}}}
6.2 Run and trace it
From inside after, first run the normal gate, then:
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
Client
Needed role
Deliberately excluded capability
Reservation coordinator
Notify an accepted reservation through configured output
Provider administration, output history inspection, identity management
Demonstration/check code
Inspect recorded notices
No need to add that operation to the coordinator’s role
Application entry point
Select implementations and supply configuration
No reservation acceptance decisions duplicated here
Console sender
Write to its supplied stream and format a notice
No 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 change
Verdict
Reason
Type the field as NotificationSender
Useful but insufficient alone
It expresses a role, but concrete construction can remain in the coordinator
Read provider settings inside the coordinator
Reject for this boundary
Environmental configuration still changes the reservation workflow’s source
Construct the console sender inside the coordinator
Reject for the final design
The caller cannot select a recorder without editing the client
Add configure/export/delete operations to the sender role
Reject without a client need
They couple this client to unrelated capabilities
Catch every runtime exception and return ordinary confirmation
Reject
It hides programming defects and makes expected output failure indistinguishable from ordinary success
Inject a global container for future lookups
Reject for this example
Real required collaborators become hidden behind a registry dependency
Declare DIP and DI complete because an interface exists
Reject
Contract 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 concern
Evidence worth inspecting
Role is client-specific
Coordinator uses only the sender operation and application-domain record
Provider choice is external
Concrete output construction appears in assembly code, not the coordinator
Valid configuration
Null and missing-policy construction failures occur before request processing
Mapping is stable
Editing the caller’s original map does not rewire an existing coordinator
Existing behaviour
Migrated setup with unchanged reservation assertions passes
Feature effects
Accepted record supplied once; rejection causes no send; expected failure retains state
Lifetime
Repeated container resolution shares the same coordinator in the demonstration
Claims are bounded
No 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 example
Conversion state
Notification state
Valid synthetic input converts successfully
One expected output produced
One completion result submitted
Conversion itself fails before output completion
No successful completion reported
No completion notice submitted
Conversion succeeds but notifier reports expected failure
Successful output retained
Failure 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.
Fault
Result
Notification sent before the record is stored
Caught: notification failure retains accepted record
catch (NotificationException) widened to catch (RuntimeException)
Caught: unexpected sender failure is not swallowed
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.
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
Tool
Tested selection
Role
Java
Java 17 language/API target
Application and test execution
Maven
3.9.11
Build and dependency resolution
Compiler Plugin
3.14.1
Java compilation
Surefire Plugin
3.5.4
JUnit Platform execution
JUnit Jupiter
5.13.4
Assertions, test discovery and parameterised cases
Mockito
5.20.0
Interface 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.
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.
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
publicenumBorrowerCategory{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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
2.5 after/src/main/java/LoanPeriod.java
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
2.6 after/src/main/java/Reservation.java
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){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.
*/@FunctionalInterfacepublicinterfaceNotificationSender{voidsend(Reservationreservation);}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));if(hasConflict(itemId,requested)){return"Rejected: item unavailable.";}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}LoanPeriodrequested=newLoanPeriod(start,endExclusive);return!hasConflict(itemId,requested);}privatebooleanhasConflict(StringitemId,LoanPeriodrequested){for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){returntrue;}}returnfalse;}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
importjava.time.LocalDate;importjava.util.Objects;/** Immutable loan state. A null returnedOn means this loan is active. */publicrecordLoan(StringreservationId,StringborrowerId,StringitemId,LocalDateborrowedOn,LocalDatereturnedOn){publicLoan{if(reservationId==null||reservationId.isBlank()||borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Loan identifiers must not be blank.");}Objects.requireNonNull(borrowedOn,"borrowedOn");if(returnedOn!=null&&returnedOn.isBefore(borrowedOn)){thrownewIllegalArgumentException("Return cannot precede checkout.");}}publicbooleanactive(){returnreturnedOn==null;}}
2.15 after/src/main/java/LoanService.java
importjava.time.Clock;importjava.time.LocalDate;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;/** One service per coordinator/session; sequential, in-memory teaching flow. */publicfinalclassLoanService{privatefinalReservationCoordinatorreservations;privatefinalClockclock;privatefinalMap<String,Loan>loans=newLinkedHashMap<>();publicLoanService(ReservationCoordinatorreservations,Clockclock){this.reservations=Objects.requireNonNull(reservations,"reservations");this.clock=Objects.requireNonNull(clock,"clock");}publicLoancheckOut(StringreservationId){Reservationreservation=reservations.reservations().stream().filter(r->r.id().equals(reservationId)).findFirst().orElseThrow(()->newIllegalArgumentException("Unknown reservation."));if(loans.containsKey(reservationId)){thrownewIllegalStateException("Reservation already used for a loan.");}LocalDatetoday=LocalDate.now(clock);if(today.isBefore(reservation.period().start())||!today.isBefore(reservation.period().endExclusive())){thrownewIllegalStateException("Checkout must be within the reservation period.");}booleanitemAlreadyOut=loans.values().stream().anyMatch(loan->loan.active()&&loan.itemId().equals(reservation.itemId()));if(itemAlreadyOut)thrownewIllegalStateException("Item is already on loan.");Loanloan=newLoan(reservation.id(),reservation.borrowerId(),reservation.itemId(),today,null);loans.put(reservationId,loan);returnloan;}publicLoanreturnLoan(StringreservationId){Loancurrent=loans.get(reservationId);if(current==null||!current.active()){thrownewIllegalStateException("No active loan for this reservation.");}Loanreturned=newLoan(current.reservationId(),current.borrowerId(),current.itemId(),current.borrowedOn(),LocalDate.now(clock));loans.put(reservationId,returned);returnreturned;}publicList<Loan>loans(){returnList.copyOf(loans.values());}}
importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;publicfinalclassChapter07Demo{publicstaticvoidmain(String[]args){RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorreservations=ReservationApplication.create(inbox);LocalDateday=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)));LoanServiceloans=newLoanService(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
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorinvalid=ReservationApplication.create(newRecordingNotificationSender());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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(Stringitem:newString[]{"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.ReservationCoordinatordurations=ReservationApplication.create(newRecordingNotificationSender());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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}}}
3.3 after/src/test/java/Chapter05Checks.java
importjava.time.LocalDate;importjava.util.List;publicfinalclassChapter05Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){BorrowingPolicystudent=newReservationRules();BorrowingPolicystaff=newStaffReservationRules();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");LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(inti=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");ReservationCoordinatordefaultStudent=ReservationApplication.create(newRecordingNotificationSender());ReservationCoordinatorexplicitStudent=ReservationApplication.create(newRecordingNotificationSender());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.ReservationCoordinatormixedFixture=ReservationApplication.create(newRecordingNotificationSender());for(inti=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.");}privatestaticvoidsharedPolicyContract(BorrowingPolicypolicy){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");booleanfirst=policy.mayRequest(1,2);policy.mayRequest(0,1);check(policy.mayRequest(1,2)==first,"repeat decision unchanged by intervening call");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
3.4 after/src/test/java/Chapter06Checks.java
importjava.io.ByteArrayOutputStream;importjava.io.OutputStream;importjava.io.PrintStream;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.time.LocalDate;importjava.util.EnumMap;importjava.util.List;importjava.util.Map;publicfinalclassChapter06Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorc=ReservationApplication.create(inbox);Stringfirst=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");FailingSenderfailure=newFailingSender();ReservationCoordinatorfailed=ReservationApplication.create(failure);StringfailureResult=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");ReservationCoordinatorunexpected=ReservationApplication.create(newBrokenSender());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,()->newReservationCoordinator(null,newConfirmationFormatter(),inbox),"null policy map");expect(IllegalArgumentException.class,()->newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules()),newConfirmationFormatter(),inbox),"missing staff policy");expect(NullPointerException.class,()->newReservationCoordinator(configured,null,inbox),"null formatter");expect(NullPointerException.class,()->newReservationCoordinator(configured,newConfirmationFormatter(),null),"null sender");Map<BorrowerCategory,BorrowingPolicy>mutable=newEnumMap<>(configured);ReservationCoordinatorcopied=newReservationCoordinator(mutable,newConfirmationFormatter(),newRecordingNotificationSender());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=newEnumMap<>(configured);restrictive.put(BorrowerCategory.STUDENT,(count,days)->false);RecordingNotificationSenderrestrictedInbox=newRecordingNotificationSender();ReservationCoordinatorrestricted=newReservationCoordinator(restrictive,newConfirmationFormatter(),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");ByteArrayOutputStreambytes=newByteArrayOutputStream();ConsoleNotificationSenderconsole=newConsoleNotificationSender(newPrintStream(bytes,true,StandardCharsets.UTF_8),newConfirmationFormatter());console.send(c.reservations().get(0));check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: "+first+System.lineSeparator()),"console implementation uses supplied stream");PrintStreambrokenOutput=newPrintStream(newOutputStream(){@Overridepublicvoidwrite(intvalue)throwsIOException{thrownewIOException("Expected output failure");}});ConsoleNotificationSenderbrokenConsole=newConsoleNotificationSender(brokenOutput,newConfirmationFormatter());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.");}privatestaticMap<BorrowerCategory,BorrowingPolicy>policies(){returnMap.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());}privatestaticfinalclassFailingSenderimplementsNotificationSender{privateintcalls;@Overridepublicvoidsend(Reservationreservation){calls++;thrownewNotificationException("Expected teaching failure");}}// A programming defect, not an expected output failure: it must not be swallowed.privatestaticfinalclassBrokenSenderimplementsNotificationSender{@Overridepublicvoidsend(Reservationreservation){thrownewIllegalStateException("Unexpected programming failure");}}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTddTest{@TestvoidoverlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying(){// Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(inbox);LocalDatestart=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.booleanavailable=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
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.ValueSource;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);privatefinalRecordingNotificationSenderinbox=newRecordingNotificationSender();privatefinalReservationCoordinatorc=ReservationApplication.create(inbox);@TestvoidemptyScheduleIsAvailableWithoutAnySideEffect(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(2)));assertTrue(c.reservations().isEmpty());assertTrue(inbox.received().isEmpty());}@TestvoidadjacentPeriodAndDifferentItemAreAvailable(){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})voidinvalidPeriodFailsWithoutChangingState(intendOffset){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());}@TestvoidmalformedIdentifiersAndNullDatesAreRejected(){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());}@TestvoidqueryDoesNotAllocateAnIdentifier(){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));}@TestvoidavailabilityDoesNotMeanBorrowerEligibility(){for(Stringitem: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());}@TestvoidqueryCanSpanMoreDaysThanAStudentMayReserve(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(30)));assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,30));}}
importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.mockito.ArgumentCaptor;import staticorg.junit.jupiter.api.Assertions.*;import staticorg.mockito.Mockito.*;classNotificationMockitoTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);@TestvoidacceptedReservationIsSentOnceWithItsDomainDetails(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);c.reserve("B1","CAM1",day,2);ArgumentCaptor<Reservation>captured=ArgumentCaptor.forClass(Reservation.class);verify(sender).send(captured.capture());Reservationsent=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()));}@TestvoidrejectedDurationDoesNotNotify(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,8));assertTrue(c.reservations().isEmpty());verifyNoInteractions(sender);}@TestvoidexpectedNotificationFailureRetainsReservationWithoutRetry(){NotificationSendersender=mock(NotificationSender.class);doThrow(newNotificationException("Expected test failure")).when(sender).send(any(Reservation.class));ReservationCoordinatorc=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.
#!/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
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}newLoanPeriod(start,endExclusive);returntrue;// INTENTIONALLY INCOMPLETE: red-stage teaching implementation.}publicList<Reservation>reservations(){returnList.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
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){return"Rejected: item unavailable.";}}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}LoanPeriodrequested=newLoanPeriod(start,endExclusive);for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){returnfalse;}}returntrue;}publicList<Reservation>reservations(){returnList.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:
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.
Fault
Caught by
isAvailable always returns true
AvailabilityTddTest.overlappingEnquiryâŚ
isAvailable always returns false
AvailabilityTest empty-schedule, adjacency and eligibility examples
Conflict check ignores the item identifier
AvailabilityTest adjacency/different-item example, and the Chapter 2 bridge
Inclusive end boundary in LoanPeriod (Section 6)
AvailabilityTest.adjacentPeriodAndDifferentItemAreAvailable, and both older bridges
Real system clock used instead of the supplied one
three LoanServiceTest methods
Clock zone ignored
clockZoneDeterminesTheCheckoutDate
Return date recorded as the checkout date
checkoutAndReturnUseTheSuppliedClock and the backward-clock test
Loan no longer rejects a return before checkout
backwardClockCannotRecordReturnBeforeCheckout
Sender called twice on success
NotificationMockitoTest, 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.
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.
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 claim
Verdict
Independent check or correction
Query by reserve-and-remove
Reject
Calling 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 conflicts
Reject
The agreed interval is half-open; [20,22) and [22,23) do not overlap
No exception is sufficient
Reject
An always-true or always-false method can return normally and be wrong
Generated implementation and tests mutually prove correctness
Reject
Both may share the same mistaken convention; compare with reviewed examples
Coverage proves correctness
Reject
Execution does not validate expected values or assert sensitivity to defects
Use real date and wait for tomorrow
Reject
Supply 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
Document
Useful content
Scope note
Selected query and loan rules; synthetic inputs; lifecycle limitations
TDD record
Actual red message, same test at green, refactoring diff and regression result
Acceptance mapping
Named tests tied to concrete examples
Double rationale
Which collaborator is replaced, why, and what observation it enables
Mutation record
Isolated faulty comparison, selected failure and restored pass
Build result
Executed revision, tool versions and actual report
Demonstration
Reproducible steps from empty state through reserve/checkout/return
Review and retrospective
Actual 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. */publicclassMailClient{privatefinalStringhost;privatefinalintport;publicMailClient(Stringhost,intport){this.host=host;this.port=port;}publicvoidsend(Stringto,Stringsubject,Stringbody){thrownewIllegalStateException("No mail server is reachable here: "+host+":"+port);}}
// OverdueReminder.java (the class students are given)importjava.time.LocalDate;publicclassOverdueReminder{publicbooleanremind(StringborrowerEmail,LocalDatedueDate,StringitemName){if(LocalDate.now().isAfter(dueDate)){MailClientclient=newMailClient("smtp.campus.example",25);client.send(borrowerEmail,"Overdue: "+itemName,"Please return "+itemName+".");System.out.println("Reminder sent to "+borrowerEmail);returntrue;}returnfalse;}}
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:
// MailReminderSender.javaimportjava.util.Objects;/** Adapts the supplied MailClient to the application's ReminderSender role. */publicfinalclassMailReminderSenderimplementsReminderSender{privatefinalMailClientclient;publicMailReminderSender(MailClientclient){this.client=Objects.requireNonNull(client,"client");}@Overridepublicvoidsend(Stringto,Stringsubject,Stringbody){client.send(to,subject,body);System.out.println("Reminder sent to "+to);}}
// OverdueReminder.java (refactored)importjava.time.Clock;importjava.time.LocalDate;importjava.util.Objects;publicfinalclassOverdueReminder{privatefinalClockclock;privatefinalReminderSendersender;publicOverdueReminder(Clockclock,ReminderSendersender){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. */publicbooleanremind(StringborrowerEmail,LocalDatedueDate,StringitemName){if(!LocalDate.now(clock).isAfter(dueDate)){returnfalse;}sender.send(borrowerEmail,"Overdue: "+itemName,"Please return "+itemName+".");returntrue;}}
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.javaimportjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importjava.util.ArrayList;importjava.util.List;importorg.junit.jupiter.api.Test;import staticorg.junit.jupiter.api.Assertions.*;classOverdueReminderTest{privatestaticfinalLocalDateDUE=LocalDate.of(2026,10,20);privatefinalSpySendersender=newSpySender();privateOverdueReminderreminderOn(Stringinstant){returnnewOverdueReminder(Clock.fixed(Instant.parse(instant),ZoneOffset.UTC),sender);}@TestvoidnoReminderOnTheDueDateItself(){assertFalse(reminderOn("2026-10-20T23:00:00Z").remind("[email protected]",DUE,"CAM1"));assertTrue(sender.calls.isEmpty());}@TestvoidnoReminderBeforeTheDueDate(){assertFalse(reminderOn("2026-10-19T12:00:00Z").remind("[email protected]",DUE,"CAM1"));assertTrue(sender.calls.isEmpty());}@TestvoidexactlyOneReminderTheDayAfterTheDueDate(){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. */privatestaticfinalclassSpySenderimplementsReminderSender{privatefinalList<String>calls=newArrayList<>();@Overridepublicvoidsend(Stringto,Stringsubject,Stringbody){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.
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.
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.
Translate provider codes, null and declared failure
Yes
DirectoryReservationService.java
Look up borrower, then invoke existing reservation behaviour
Yes
Chapter08Demo.java
Reproducible map-backed integration demonstration
Yes
patterns/PatternExamples.java
Four separate pattern exercises
No application wiring
DirectoryAdapterTest.java
Translation and application-boundary tests
Test source
patterns/PatternExamplesTest.java
Construction, immutability and decorator-order tests
Test 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
importjava.util.Objects;publicrecordBorrower(Stringid,BorrowerCategorycategory){publicBorrower{if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");Objects.requireNonNull(category,"category");}}
3.2 after/src/main/java/BorrowerCategory.java
publicenumBorrowerCategory{STUDENT,STAFF}
3.3 after/src/main/java/BorrowerDirectory.java
importjava.util.Optional;/** Empty means absent or inactive, never a provider outage. */@FunctionalInterfacepublicinterfaceBorrowerDirectory{Optional<Borrower>find(StringborrowerId);}
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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;publicfinalclassChapter07Demo{publicstaticvoidmain(String[]args){RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorreservations=ReservationApplication.create(inbox);LocalDateday=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)));LoanServiceloans=newLoanService(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());}}
importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){Objects.requireNonNull(reservation,"reservation");return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}}
importjava.time.LocalDate;importjava.util.Objects;/** Directory-backed entry point; this does not authenticate the caller. */publicfinalclassDirectoryReservationService{privatefinalBorrowerDirectorydirectory;privatefinalReservationCoordinatorcoordinator;publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator){this.directory=Objects.requireNonNull(directory,"directory");this.coordinator=Objects.requireNonNull(coordinator,"coordinator");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");varfound=directory.find(borrowerId);if(found.isEmpty())return"Rejected: borrower not found or inactive.";Borrowerborrower=found.get();if(!borrowerId.equals(borrower.id())){thrownewDirectoryException("Directory returned a different borrower");}returncoordinator.reserve(borrower.id(),itemId,start,days,borrower.category());}}
/** Illustrative provider contract, not an actual university API or network client. */@FunctionalInterfacepublicinterfaceLegacyDirectoryClient{// null means no row; codes are exact and case-sensitive.Personlookup(Stringkey);recordPerson(Stringkey,Stringkind,Stringstatus){}finalclassFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicFailure(Stringmessage){super(message);}}}
3.15 after/src/main/java/Loan.java
importjava.time.LocalDate;importjava.util.Objects;/** Immutable loan state. A null returnedOn means this loan is active. */publicrecordLoan(StringreservationId,StringborrowerId,StringitemId,LocalDateborrowedOn,LocalDatereturnedOn){publicLoan{if(reservationId==null||reservationId.isBlank()||borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Loan identifiers must not be blank.");}Objects.requireNonNull(borrowedOn,"borrowedOn");if(returnedOn!=null&&returnedOn.isBefore(borrowedOn)){thrownewIllegalArgumentException("Return cannot precede checkout.");}}publicbooleanactive(){returnreturnedOn==null;}}
3.16 after/src/main/java/LoanPeriod.java
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
3.17 after/src/main/java/LoanService.java
importjava.time.Clock;importjava.time.LocalDate;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;/** One service per coordinator/session; sequential, in-memory teaching flow. */publicfinalclassLoanService{privatefinalReservationCoordinatorreservations;privatefinalClockclock;privatefinalMap<String,Loan>loans=newLinkedHashMap<>();publicLoanService(ReservationCoordinatorreservations,Clockclock){this.reservations=Objects.requireNonNull(reservations,"reservations");this.clock=Objects.requireNonNull(clock,"clock");}publicLoancheckOut(StringreservationId){Reservationreservation=reservations.reservations().stream().filter(r->r.id().equals(reservationId)).findFirst().orElseThrow(()->newIllegalArgumentException("Unknown reservation."));if(loans.containsKey(reservationId)){thrownewIllegalStateException("Reservation already used for a loan.");}LocalDatetoday=LocalDate.now(clock);if(today.isBefore(reservation.period().start())||!today.isBefore(reservation.period().endExclusive())){thrownewIllegalStateException("Checkout must be within the reservation period.");}booleanitemAlreadyOut=loans.values().stream().anyMatch(loan->loan.active()&&loan.itemId().equals(reservation.itemId()));if(itemAlreadyOut)thrownewIllegalStateException("Item is already on loan.");Loanloan=newLoan(reservation.id(),reservation.borrowerId(),reservation.itemId(),today,null);loans.put(reservationId,loan);returnloan;}publicLoanreturnLoan(StringreservationId){Loancurrent=loans.get(reservationId);if(current==null||!current.active()){thrownewIllegalStateException("No active loan for this reservation.");}Loanreturned=newLoan(current.reservationId(),current.borrowerId(),current.itemId(),current.borrowedOn(),LocalDate.now(clock));loans.put(reservationId,returned);returnreturned;}publicList<Loan>loans(){returnList.copyOf(loans.values());}}
/**
* 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.
*/@FunctionalInterfacepublicinterfaceNotificationSender{voidsend(Reservationreservation);}
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));if(hasConflict(itemId,requested)){return"Rejected: item unavailable.";}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}LoanPeriodrequested=newLoanPeriod(start,endExclusive);return!hasConflict(itemId,requested);}privatebooleanhasConflict(StringitemId,LoanPeriodrequested){for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){returntrue;}}returnfalse;}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
packagepatterns;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;/** Isolated teaching examples; none is wired into the lending application. */publicfinalclassPatternExamples{privatePatternExamples(){}// Factory Method: inherited workflow invokes an overridable creation hook.publicinterfaceExporter{Stringexport(Stringvalue);}publicabstractstaticclassExportJob{protectedabstractExportercreateExporter();publicfinalStringrun(Stringvalue){Objects.requireNonNull(value,"value");returncreateExporter().export(value);}}publicstaticfinalclassPlainJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"TEXT: "+value;}}publicstaticfinalclassHtmlJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"<p>"+escape(value)+"</p>";}}// Simple factory: useful, but no overridden creation hook.publicstaticExportersimpleExporter(booleanhtml){returnhtml?value->"<p>"+escape(value)+"</p>":value->"TEXT: "+value;}// Abstract Factory: two product roles, supplied as a related family.publicinterfaceHeading{Stringrender(Stringtext);}publicinterfaceBody{Stringrender(Stringtext);}publicinterfaceReceiptFamily{Headingheading();Bodybody();}publicstaticfinalclassPlainFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->text+"\n";}publicBodybody(){returntext->text+"\n";}}publicstaticfinalclassHtmlFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->"<h1>"+escape(text)+"</h1>";}publicBodybody(){returntext->"<p>"+escape(text)+"</p>";}}publicstaticStringreceipt(ReceiptFamilyfamily,Stringtitle,Stringdetail){Objects.requireNonNull(family,"family");returnfamily.heading().render(title)+family.body().render(detail);}privatestaticStringescape(Stringtext){returnObjects.requireNonNull(text,"text").replace("&","&").replace("<","<").replace(">",">");}// Fluent builder variant: staged configuration, one immutable product.publicstaticfinalclassReportRequest{privatefinalStringborrowerId;privatefinalLocalDatefrom;privatefinalLocalDatetoExclusive;privatefinalbooleanincludeReturned;privatefinalList<String>columns;privateReportRequest(Builderbuilder){if((builder.from==null)!=(builder.toExclusive==null)){thrownewIllegalArgumentException("Both dates or neither are required");}if(builder.from!=null&&!builder.from.isBefore(builder.toExclusive)){thrownewIllegalArgumentException("Positive interval required");}borrowerId=builder.borrowerId;from=builder.from;toExclusive=builder.toExclusive;includeReturned=builder.includeReturned;columns=List.copyOf(builder.columns);}publicStringborrowerId(){returnborrowerId;}publicLocalDatefrom(){returnfrom;}publicLocalDatetoExclusive(){returntoExclusive;}publicbooleanincludeReturned(){returnincludeReturned;}publicList<String>columns(){returncolumns;}publicstaticBuilderforBorrower(Stringid){returnnewBuilder(id);}publicstaticfinalclassBuilder{privatefinalStringborrowerId;privateLocalDatefrom;privateLocalDatetoExclusive;privatebooleanincludeReturned;privatefinalList<String>columns=newArrayList<>();privateBuilder(Stringid){if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");borrowerId=id;}publicBuilderduring(LocalDatefrom,LocalDatetoExclusive){Objects.requireNonNull(from,"from");Objects.requireNonNull(toExclusive,"toExclusive");this.from=from;this.toExclusive=toExclusive;returnthis;}publicBuilderincludeReturned(booleanvalue){includeReturned=value;returnthis;}publicBuildercolumn(Stringvalue){if(value==null||value.isBlank())thrownewIllegalArgumentException("Column required");columns.add(value);returnthis;}publicReportRequestbuild(){returnnewReportRequest(this);}}}// Decorators: same interface, explicit order and expected failure type.publicinterfaceSender{voidsend(Stringmessage);}publicstaticfinalclassDeliveryFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicDeliveryFailure(Stringmessage){super(message);}}publicstaticfinalclassAuditedSenderimplementsSender{privatefinalSenderdelegate;privatefinalList<String>events;publicAuditedSender(Senderdelegate,List<String>events){this.delegate=Objects.requireNonNull(delegate,"delegate");this.events=Objects.requireNonNull(events,"events");}publicvoidsend(Stringmessage){events.add("attempt");try{delegate.send(message);}catch(DeliveryFailurefailure){events.add("failed");throwfailure;}events.add("sent");}}publicstaticfinalclassRetryOnceSenderimplementsSender{privatefinalSenderdelegate;publicRetryOnceSender(Senderdelegate){this.delegate=Objects.requireNonNull(delegate,"delegate");}publicvoidsend(Stringmessage){try{delegate.send(message);}catch(DeliveryFailurefailure){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
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTddTest{@TestvoidoverlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying(){// Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(inbox);LocalDatestart=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.booleanavailable=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
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.ValueSource;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);privatefinalRecordingNotificationSenderinbox=newRecordingNotificationSender();privatefinalReservationCoordinatorc=ReservationApplication.create(inbox);@TestvoidemptyScheduleIsAvailableWithoutAnySideEffect(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(2)));assertTrue(c.reservations().isEmpty());assertTrue(inbox.received().isEmpty());}@TestvoidadjacentPeriodAndDifferentItemAreAvailable(){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})voidinvalidPeriodFailsWithoutChangingState(intendOffset){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());}@TestvoidmalformedIdentifiersAndNullDatesAreRejected(){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());}@TestvoidqueryDoesNotAllocateAnIdentifier(){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));}@TestvoidavailabilityDoesNotMeanBorrowerEligibility(){for(Stringitem: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());}@TestvoidqueryCanSpanMoreDaysThanAStudentMayReserve(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(30)));assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,30));}}
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorinvalid=ReservationApplication.create(newRecordingNotificationSender());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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(Stringitem:newString[]{"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.ReservationCoordinatordurations=ReservationApplication.create(newRecordingNotificationSender());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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}}}
4.6 after/src/test/java/Chapter05Checks.java
importjava.time.LocalDate;importjava.util.List;publicfinalclassChapter05Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){BorrowingPolicystudent=newReservationRules();BorrowingPolicystaff=newStaffReservationRules();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");LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(inti=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");ReservationCoordinatordefaultStudent=ReservationApplication.create(newRecordingNotificationSender());ReservationCoordinatorexplicitStudent=ReservationApplication.create(newRecordingNotificationSender());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.ReservationCoordinatormixedFixture=ReservationApplication.create(newRecordingNotificationSender());for(inti=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.");}privatestaticvoidsharedPolicyContract(BorrowingPolicypolicy){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");booleanfirst=policy.mayRequest(1,2);policy.mayRequest(0,1);check(policy.mayRequest(1,2)==first,"repeat decision unchanged by intervening call");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
4.7 after/src/test/java/Chapter06Checks.java
importjava.io.ByteArrayOutputStream;importjava.io.OutputStream;importjava.io.PrintStream;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.time.LocalDate;importjava.util.EnumMap;importjava.util.List;importjava.util.Map;publicfinalclassChapter06Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorc=ReservationApplication.create(inbox);Stringfirst=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");FailingSenderfailure=newFailingSender();ReservationCoordinatorfailed=ReservationApplication.create(failure);StringfailureResult=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");ReservationCoordinatorunexpected=ReservationApplication.create(newBrokenSender());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,()->newReservationCoordinator(null,newConfirmationFormatter(),inbox),"null policy map");expect(IllegalArgumentException.class,()->newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules()),newConfirmationFormatter(),inbox),"missing staff policy");expect(NullPointerException.class,()->newReservationCoordinator(configured,null,inbox),"null formatter");expect(NullPointerException.class,()->newReservationCoordinator(configured,newConfirmationFormatter(),null),"null sender");Map<BorrowerCategory,BorrowingPolicy>mutable=newEnumMap<>(configured);ReservationCoordinatorcopied=newReservationCoordinator(mutable,newConfirmationFormatter(),newRecordingNotificationSender());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=newEnumMap<>(configured);restrictive.put(BorrowerCategory.STUDENT,(count,days)->false);RecordingNotificationSenderrestrictedInbox=newRecordingNotificationSender();ReservationCoordinatorrestricted=newReservationCoordinator(restrictive,newConfirmationFormatter(),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");ByteArrayOutputStreambytes=newByteArrayOutputStream();ConsoleNotificationSenderconsole=newConsoleNotificationSender(newPrintStream(bytes,true,StandardCharsets.UTF_8),newConfirmationFormatter());console.send(c.reservations().get(0));check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: "+first+System.lineSeparator()),"console implementation uses supplied stream");PrintStreambrokenOutput=newPrintStream(newOutputStream(){@Overridepublicvoidwrite(intvalue)throwsIOException{thrownewIOException("Expected output failure");}});ConsoleNotificationSenderbrokenConsole=newConsoleNotificationSender(brokenOutput,newConfirmationFormatter());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.");}privatestaticMap<BorrowerCategory,BorrowingPolicy>policies(){returnMap.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());}privatestaticfinalclassFailingSenderimplementsNotificationSender{privateintcalls;@Overridepublicvoidsend(Reservationreservation){calls++;thrownewNotificationException("Expected teaching failure");}}// A programming defect, not an expected output failure: it must not be swallowed.privatestaticfinalclassBrokenSenderimplementsNotificationSender{@Overridepublicvoidsend(Reservationreservation){thrownewIllegalStateException("Unexpected programming failure");}}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
4.8 after/src/test/java/DirectoryAdapterTest.java
import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;importorg.junit.jupiter.params.provider.NullAndEmptySource;importorg.junit.jupiter.params.provider.ValueSource;classDirectoryAdapterTest{privatefinalLocalDatestart=LocalDate.of(2026,10,20);@ParameterizedTest@CsvSource({"S,STUDENT","T,STAFF"})voidmapsKnownActiveCategories(Stringcode,BorrowerCategoryexpected){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,code,"Y"));assertEquals(newBorrower("B1",expected),adapter.find("B1").orElseThrow());}@TestvoidabsenceAndInactiveAreEmpty(){assertTrue(newLegacyDirectoryAdapter(id->null).find("B1").isEmpty());assertTrue(newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,"S","N")).find("B1").isEmpty());}@ParameterizedTest@CsvSource({"X,Y","S,X","s,Y","S,y"})voidunknownCodesAreErrors(Stringkind,Stringstatus){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,kind,status));assertThrows(DirectoryException.class,()->adapter.find("B1"));}@TestvoidmalformedRowsAreErrors(){for(varrow:newLegacyDirectoryClient.Person[]{newLegacyDirectoryClient.Person("OTHER","S","Y"),newLegacyDirectoryClient.Person(null,"S","Y"),newLegacyDirectoryClient.Person("B1",null,"Y"),newLegacyDirectoryClient.Person("B1","S",null),newLegacyDirectoryClient.Person("B1","X","N")}){assertThrows(DirectoryException.class,()->newLegacyDirectoryAdapter(id->row).find("B1"));}}@ParameterizedTest@NullAndEmptySource@ValueSource(strings={" "})voidinvalidIdentifierDoesNotReachProvider(Stringid){varcalls=newAtomicInteger();varadapter=newLegacyDirectoryAdapter(key->{calls.incrementAndGet();returnnull;});assertThrows(IllegalArgumentException.class,()->adapter.find(id));assertEquals(0,calls.get());}@TestvoidtranslatesExpectedFailureAndKeepsCause(){varoriginal=newLegacyDirectoryClient.Failure("offline");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});varresult=assertThrows(DirectoryException.class,()->adapter.find("B1"));assertSame(original,result.getCause());}@TestvoidunexpectedProgrammingFailureIsNotDisguisedAsAbsence(){varoriginal=newIllegalStateException("bug");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});assertSame(original,assertThrows(IllegalStateException.class,()->adapter.find("B1")));}@TestvoidcategoryFromDirectoryControlsReservationPolicy(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);vardirectory=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,id.equals("STAFF1")?"T":"S","Y"));varservice=newDirectoryReservationService(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());}@TestvoidabsenceAndOutageDoNotStoreNotifyOrConsumeId(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);varmissing=newDirectoryReservationService(id->Optional.empty(),coordinator);assertEquals("Rejected: borrower not found or inactive.",missing.reserve("B1","CAM1",start,2));varfailing=newDirectoryReservationService(newLegacyDirectoryAdapter(id->{thrownewLegacyDirectoryClient.Failure("offline");}),coordinator);assertThrows(DirectoryException.class,()->failing.reserve("B1","CAM1",start,2));assertTrue(coordinator.reservations().isEmpty());assertTrue(notices.received().isEmpty());varvalid=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.STUDENT)),coordinator);assertTrue(valid.reserve("B1","CAM1",start,2).startsWith("Confirmed: R1"));}@TestvoidmismatchedDomainBorrowerIsRejectedBeforeMutation(){varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->Optional.of(newBorrower("OTHER",BorrowerCategory.STAFF)),coordinator);assertThrows(DirectoryException.class,()->service.reserve("B1","CAM1",start,10));assertTrue(coordinator.reservations().isEmpty());}@TestvoidlocalArgumentErrorsPrecedeLookup(){varcalls=newAtomicInteger();varservice=newDirectoryReservationService(id->{calls.incrementAndGet();returnOptional.empty();},ReservationApplication.create(newRecordingNotificationSender()));assertThrows(IllegalArgumentException.class,()->service.reserve("B1"," ",start,2));assertThrows(NullPointerException.class,()->service.reserve("B1","CAM1",null,2));assertEquals(0,calls.get());}}
importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.mockito.ArgumentCaptor;import staticorg.junit.jupiter.api.Assertions.*;import staticorg.mockito.Mockito.*;classNotificationMockitoTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);@TestvoidacceptedReservationIsSentOnceWithItsDomainDetails(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);c.reserve("B1","CAM1",day,2);ArgumentCaptor<Reservation>captured=ArgumentCaptor.forClass(Reservation.class);verify(sender).send(captured.capture());Reservationsent=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()));}@TestvoidrejectedDurationDoesNotNotify(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,8));assertTrue(c.reservations().isEmpty());verifyNoInteractions(sender);}@TestvoidexpectedNotificationFailureRetainsReservationWithoutRetry(){NotificationSendersender=mock(NotificationSender.class);doThrow(newNotificationException("Expected test failure")).when(sender).send(any(Reservation.class));ReservationCoordinatorc=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));}}
#!/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
Promise
Executable evidence
Known active codes map to the correct category
mapsKnownActiveCategories: two parameter rows
Missing and valid inactive rows yield absence
absenceAndInactiveAreEmpty
Unknown and differently cased codes are not defaulted
unknownCodesAreErrors: four parameter rows
Null fields, identity mismatch and malformed inactive rows are errors
malformedRowsAreErrors
Invalid lookup identifiers never reach the provider
Directory category selects the inherited duration policy
categoryFromDirectoryControlsReservationPolicy
Absence/outage leave records and notices unchanged and preserve R1
absenceAndOutageDoNotStoreNotifyOrConsumeId
A different returned domain identity cannot be reserved
mismatchedDomainBorrowerIsRejectedBeforeMutation
Local argument failures precede lookup
localArgumentErrorsPrecedeLookup
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:
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.BorrowerCategorycategory="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(RuntimeExceptionfailure){returnOptional.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.
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:
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.
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.
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 claim
Diagnosis and correction
Evidence or decision
Renaming static create makes Factory Method
Names do not create a subclass hook
Compare simpleExporter with ExportJob.createExporter
One product role requires Abstract Factory
No related set has been established
Retain simple assembly until a family requirement exists
Catch every exception and return staff
Hides failure and grants an unsupported category
Unknown-code and exception-cause tests
Wrap all senders with retry
Changes the one-attempt running contract
Keep retry isolated; review any proposed policy change
Decorator order does not matter
Changes which layer observes each attempt
The two exact audit-history tests
A builder guarantees immutable products
Mutable aliases can remain after build
Snapshot 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.
Area
Faults tried
Result
Adapter
Unknown 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 dropped
All eight caught by DirectoryAdapterTest
Directory service
Directory category ignored; absent borrower gets a student default; returned identity unchecked
All three caught
Builder
Shared mutable column list (7.3); interval not validated
Both caught
Decorators
Retry on any RuntimeException; retry twice; no failed audit event; audit swallows the failure
All four caught
Abstract Factory and escaping
HTML heading uses the plain renderer; angle brackets not escaped
Both caught
Factory Method
ExportJob.run skips its null check
Caught 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.
Gamma, E., Helm, R., Johnson, R. and Vlissides, J., Design Patterns: Elements of Reusable Object-Oriented Software. Publisher catalogue.
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.
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
File
Purpose
NotificationPublisher.java
Optional Observer-based sender; nested callback and subscription contracts
Command.java
Result-bearing action interface
ReserveEquipmentCommand.java
Captured request invoking the existing service
Chapter09Demo.java
Explicitly composed publisher and command demonstration
behaviour/LifecycleExamples.java
Separate proposed lifecycle; conditional starting point and State completion
BehaviourStrategyTest.java
Compare existing strategies with a small conditional and an independent table
behaviour/LifecycleExamplesTest.java
All 20 status/action pairs plus independent-context test
NotificationPublisherTest.java
Dispatch, subscription and partial-failure behaviour
ReserveEquipmentCommandTest.java
Lazy 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
importjava.util.Objects;publicrecordBorrower(Stringid,BorrowerCategorycategory){publicBorrower{if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");Objects.requireNonNull(category,"category");}}
3.2 after/src/main/java/BorrowerCategory.java
publicenumBorrowerCategory{STUDENT,STAFF}
3.3 after/src/main/java/BorrowerDirectory.java
importjava.util.Optional;/** Empty means absent or inactive, never a provider outage. */@FunctionalInterfacepublicinterfaceBorrowerDirectory{Optional<Borrower>find(StringborrowerId);}
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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;publicfinalclassChapter07Demo{publicstaticvoidmain(String[]args){RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorreservations=ReservationApplication.create(inbox);LocalDateday=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)));LoanServiceloans=newLoanService(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());}}
importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){Objects.requireNonNull(reservation,"reservation");return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}}
importjava.time.LocalDate;importjava.util.Objects;/** Directory-backed entry point; this does not authenticate the caller. */publicfinalclassDirectoryReservationService{privatefinalBorrowerDirectorydirectory;privatefinalReservationCoordinatorcoordinator;publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator){this.directory=Objects.requireNonNull(directory,"directory");this.coordinator=Objects.requireNonNull(coordinator,"coordinator");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");varfound=directory.find(borrowerId);if(found.isEmpty())return"Rejected: borrower not found or inactive.";Borrowerborrower=found.get();if(!borrowerId.equals(borrower.id())){thrownewDirectoryException("Directory returned a different borrower");}returncoordinator.reserve(borrower.id(),itemId,start,days,borrower.category());}}
/** Illustrative provider contract, not an actual university API or network client. */@FunctionalInterfacepublicinterfaceLegacyDirectoryClient{// null means no row; codes are exact and case-sensitive.Personlookup(Stringkey);recordPerson(Stringkey,Stringkind,Stringstatus){}finalclassFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicFailure(Stringmessage){super(message);}}}
3.17 after/src/main/java/Loan.java
importjava.time.LocalDate;importjava.util.Objects;/** Immutable loan state. A null returnedOn means this loan is active. */publicrecordLoan(StringreservationId,StringborrowerId,StringitemId,LocalDateborrowedOn,LocalDatereturnedOn){publicLoan{if(reservationId==null||reservationId.isBlank()||borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Loan identifiers must not be blank.");}Objects.requireNonNull(borrowedOn,"borrowedOn");if(returnedOn!=null&&returnedOn.isBefore(borrowedOn)){thrownewIllegalArgumentException("Return cannot precede checkout.");}}publicbooleanactive(){returnreturnedOn==null;}}
3.18 after/src/main/java/LoanPeriod.java
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
3.19 after/src/main/java/LoanService.java
importjava.time.Clock;importjava.time.LocalDate;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;/** One service per coordinator/session; sequential, in-memory teaching flow. */publicfinalclassLoanService{privatefinalReservationCoordinatorreservations;privatefinalClockclock;privatefinalMap<String,Loan>loans=newLinkedHashMap<>();publicLoanService(ReservationCoordinatorreservations,Clockclock){this.reservations=Objects.requireNonNull(reservations,"reservations");this.clock=Objects.requireNonNull(clock,"clock");}publicLoancheckOut(StringreservationId){Reservationreservation=reservations.reservations().stream().filter(r->r.id().equals(reservationId)).findFirst().orElseThrow(()->newIllegalArgumentException("Unknown reservation."));if(loans.containsKey(reservationId)){thrownewIllegalStateException("Reservation already used for a loan.");}LocalDatetoday=LocalDate.now(clock);if(today.isBefore(reservation.period().start())||!today.isBefore(reservation.period().endExclusive())){thrownewIllegalStateException("Checkout must be within the reservation period.");}booleanitemAlreadyOut=loans.values().stream().anyMatch(loan->loan.active()&&loan.itemId().equals(reservation.itemId()));if(itemAlreadyOut)thrownewIllegalStateException("Item is already on loan.");Loanloan=newLoan(reservation.id(),reservation.borrowerId(),reservation.itemId(),today,null);loans.put(reservationId,loan);returnloan;}publicLoanreturnLoan(StringreservationId){Loancurrent=loans.get(reservationId);if(current==null||!current.active()){thrownewIllegalStateException("No active loan for this reservation.");}Loanreturned=newLoan(current.reservationId(),current.borrowerId(),current.itemId(),current.borrowedOn(),LocalDate.now(clock));loans.put(reservationId,returned);returnreturned;}publicList<Loan>loans(){returnList.copyOf(loans.values());}}
importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;/** Sequential, in-process Observer example. No retries or durable delivery. */publicfinalclassNotificationPublisherimplementsNotificationSender{@FunctionalInterfacepublicinterfaceObserver{voidonConfirmed(Reservationreservation);}@FunctionalInterfacepublicinterfaceSubscriptionextendsAutoCloseable{@Overridevoidclose();}privatestaticfinalclassEntry{privatefinalObserverobserver;privateEntry(Observerobserver){this.observer=observer;}}privatefinalList<Entry>entries=newArrayList<>();privatebooleanpublishing;publicSubscriptionsubscribe(Observerobserver){Objects.requireNonNull(observer,"observer");if(entries.stream().anyMatch(entry->entry.observer==observer)){thrownewIllegalArgumentException("Observer already subscribed");}Entryentry=newEntry(observer);entries.add(entry);return()->entries.remove(entry);}@Overridepublicvoidsend(Reservationreservation){Objects.requireNonNull(reservation,"reservation");if(publishing)thrownewIllegalStateException("Reentrant publication is not supported");publishing=true;try{NotificationExceptionaggregate=null;for(Entryentry:List.copyOf(entries)){try{entry.observer.onConfirmed(reservation);}catch(NotificationExceptionfailure){if(aggregate==null)aggregate=newNotificationException("One or more observers failed");aggregate.addSuppressed(failure);}}if(aggregate!=null)throwaggregate;}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.
*/@FunctionalInterfacepublicinterfaceNotificationSender{voidsend(Reservationreservation);}
importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalList<Reservation>reservations=newArrayList<>();privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:reservations){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));if(hasConflict(itemId,requested)){return"Rejected: item unavailable.";}Reservationreservation=newReservation("R"+(reservations.size()+1),borrowerId,itemId,requested);reservations.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}LoanPeriodrequested=newLoanPeriod(start,endExclusive);return!hasConflict(itemId,requested);}privatebooleanhasConflict(StringitemId,LoanPeriodrequested){for(Reservationexisting:reservations){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){returntrue;}}returnfalse;}publicList<Reservation>reservations(){returnList.copyOf(reservations);}}
importjava.time.LocalDate;importjava.util.Objects;/** Captures a request and receiver; constructing it performs no reservation. */publicrecordReserveEquipmentCommand(DirectoryReservationServicereceiver,StringborrowerId,StringitemId,LocalDatestart,intdays)implementsCommand<String>{publicReserveEquipmentCommand{Objects.requireNonNull(receiver,"receiver");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");}@OverridepublicStringexecute(){returnreceiver.reserve(borrowerId,itemId,start,days);}}
/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
packagebehaviour;/** Proposed lifecycle only: not integrated with ReservationCoordinator or LoanService. */publicfinalclassLifecycleExamples{privateLifecycleExamples(){}publicenumStatus{REQUESTED,CONFIRMED,ON_LOAN,RETURNED,CANCELLED}publicinterfaceLifecycle{Statusstatus();voidconfirm();voidcheckOut();voidreturnItem();voidcancel();}// Starting design: operation-specific switches repeat state decisions.publicstaticfinalclassConditionalReservationimplementsLifecycle{privateStatusstatus=Status.REQUESTED;publicStatusstatus(){returnstatus;}publicvoidconfirm(){switch(status){caseREQUESTED->status=Status.CONFIRMED;default->throwrejected("confirm",status);}}publicvoidcheckOut(){switch(status){caseCONFIRMED->status=Status.ON_LOAN;default->throwrejected("check out",status);}}publicvoidreturnItem(){switch(status){caseON_LOAN->status=Status.RETURNED;default->throwrejected("return",status);}}publicvoidcancel(){switch(status){caseREQUESTED,CONFIRMED->status=Status.CANCELLED;default->throwrejected("cancel",status);}}}// Completed State design: context delegates to the current state object.publicstaticfinalclassStateReservationimplementsLifecycle{privatePhasephase=Phase.REQUESTED;publicStatusstatus(){returnphase.status;}publicvoidconfirm(){phase=phase.confirm();}publicvoidcheckOut(){phase=phase.checkOut();}publicvoidreturnItem(){phase=phase.returnItem();}publicvoidcancel(){phase=phase.cancel();}}privateenumPhase{REQUESTED(Status.REQUESTED){@OverridePhaseconfirm(){returnCONFIRMED;}@OverridePhasecancel(){returnCANCELLED;}},CONFIRMED(Status.CONFIRMED){@OverridePhasecheckOut(){returnON_LOAN;}@OverridePhasecancel(){returnCANCELLED;}},ON_LOAN(Status.ON_LOAN){@OverridePhasereturnItem(){returnRETURNED;}},RETURNED(Status.RETURNED),CANCELLED(Status.CANCELLED);finalStatusstatus;Phase(Statusstatus){this.status=status;}Phaseconfirm(){throwrejected("confirm",status);}PhasecheckOut(){throwrejected("check out",status);}PhasereturnItem(){throwrejected("return",status);}Phasecancel(){throwrejected("cancel",status);}}privatestaticIllegalStateExceptionrejected(Stringaction,Statusstatus){returnnewIllegalStateException("Cannot "+action+" in "+status);}}
packagepatterns;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;/** Isolated teaching examples; none is wired into the lending application. */publicfinalclassPatternExamples{privatePatternExamples(){}// Factory Method: inherited workflow invokes an overridable creation hook.publicinterfaceExporter{Stringexport(Stringvalue);}publicabstractstaticclassExportJob{protectedabstractExportercreateExporter();publicfinalStringrun(Stringvalue){Objects.requireNonNull(value,"value");returncreateExporter().export(value);}}publicstaticfinalclassPlainJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"TEXT: "+value;}}publicstaticfinalclassHtmlJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"<p>"+escape(value)+"</p>";}}// Simple factory: useful, but no overridden creation hook.publicstaticExportersimpleExporter(booleanhtml){returnhtml?value->"<p>"+escape(value)+"</p>":value->"TEXT: "+value;}// Abstract Factory: two product roles, supplied as a related family.publicinterfaceHeading{Stringrender(Stringtext);}publicinterfaceBody{Stringrender(Stringtext);}publicinterfaceReceiptFamily{Headingheading();Bodybody();}publicstaticfinalclassPlainFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->text+"\n";}publicBodybody(){returntext->text+"\n";}}publicstaticfinalclassHtmlFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->"<h1>"+escape(text)+"</h1>";}publicBodybody(){returntext->"<p>"+escape(text)+"</p>";}}publicstaticStringreceipt(ReceiptFamilyfamily,Stringtitle,Stringdetail){Objects.requireNonNull(family,"family");returnfamily.heading().render(title)+family.body().render(detail);}privatestaticStringescape(Stringtext){returnObjects.requireNonNull(text,"text").replace("&","&").replace("<","<").replace(">",">");}// Fluent builder variant: staged configuration, one immutable product.publicstaticfinalclassReportRequest{privatefinalStringborrowerId;privatefinalLocalDatefrom;privatefinalLocalDatetoExclusive;privatefinalbooleanincludeReturned;privatefinalList<String>columns;privateReportRequest(Builderbuilder){if((builder.from==null)!=(builder.toExclusive==null)){thrownewIllegalArgumentException("Both dates or neither are required");}if(builder.from!=null&&!builder.from.isBefore(builder.toExclusive)){thrownewIllegalArgumentException("Positive interval required");}borrowerId=builder.borrowerId;from=builder.from;toExclusive=builder.toExclusive;includeReturned=builder.includeReturned;columns=List.copyOf(builder.columns);}publicStringborrowerId(){returnborrowerId;}publicLocalDatefrom(){returnfrom;}publicLocalDatetoExclusive(){returntoExclusive;}publicbooleanincludeReturned(){returnincludeReturned;}publicList<String>columns(){returncolumns;}publicstaticBuilderforBorrower(Stringid){returnnewBuilder(id);}publicstaticfinalclassBuilder{privatefinalStringborrowerId;privateLocalDatefrom;privateLocalDatetoExclusive;privatebooleanincludeReturned;privatefinalList<String>columns=newArrayList<>();privateBuilder(Stringid){if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");borrowerId=id;}publicBuilderduring(LocalDatefrom,LocalDatetoExclusive){Objects.requireNonNull(from,"from");Objects.requireNonNull(toExclusive,"toExclusive");this.from=from;this.toExclusive=toExclusive;returnthis;}publicBuilderincludeReturned(booleanvalue){includeReturned=value;returnthis;}publicBuildercolumn(Stringvalue){if(value==null||value.isBlank())thrownewIllegalArgumentException("Column required");columns.add(value);returnthis;}publicReportRequestbuild(){returnnewReportRequest(this);}}}// Decorators: same interface, explicit order and expected failure type.publicinterfaceSender{voidsend(Stringmessage);}publicstaticfinalclassDeliveryFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicDeliveryFailure(Stringmessage){super(message);}}publicstaticfinalclassAuditedSenderimplementsSender{privatefinalSenderdelegate;privatefinalList<String>events;publicAuditedSender(Senderdelegate,List<String>events){this.delegate=Objects.requireNonNull(delegate,"delegate");this.events=Objects.requireNonNull(events,"events");}publicvoidsend(Stringmessage){events.add("attempt");try{delegate.send(message);}catch(DeliveryFailurefailure){events.add("failed");throwfailure;}events.add("sent");}}publicstaticfinalclassRetryOnceSenderimplementsSender{privatefinalSenderdelegate;publicRetryOnceSender(Senderdelegate){this.delegate=Objects.requireNonNull(delegate,"delegate");}publicvoidsend(Stringmessage){try{delegate.send(message);}catch(DeliveryFailurefailure){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
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTddTest{@TestvoidoverlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying(){// Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(inbox);LocalDatestart=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.booleanavailable=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
importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.ValueSource;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);privatefinalRecordingNotificationSenderinbox=newRecordingNotificationSender();privatefinalReservationCoordinatorc=ReservationApplication.create(inbox);@TestvoidemptyScheduleIsAvailableWithoutAnySideEffect(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(2)));assertTrue(c.reservations().isEmpty());assertTrue(inbox.received().isEmpty());}@TestvoidadjacentPeriodAndDifferentItemAreAvailable(){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})voidinvalidPeriodFailsWithoutChangingState(intendOffset){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());}@TestvoidmalformedIdentifiersAndNullDatesAreRejected(){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());}@TestvoidqueryDoesNotAllocateAnIdentifier(){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));}@TestvoidavailabilityDoesNotMeanBorrowerEligibility(){for(Stringitem: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());}@TestvoidqueryCanSpanMoreDaysThanAStudentMayReserve(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(30)));assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,30));}}
import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.Map;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;classBehaviourStrategyTest{// Starting conditional design retained only for comparison in this exercise.privatebooleanconditional(BorrowerCategorycategory,intcount,intdays){if(category==BorrowerCategory.STUDENT){returncount>=0&&count<3&&days>=1&&days<=7;}returncount>=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"})voidstrategiesAndConditionalMatchIndependentExamples(BorrowerCategorycategory,intcount,intdays,booleanexpected){varpolicies=Map.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());assertEquals(expected,conditional(category,count,days));assertEquals(expected,policies.get(category).mayRequest(count,days));}@TestvoidinjectedPolicyChangesDecisionWithoutChangingCoordinator(){BorrowingPolicytemporaryStaffRule=(count,days)->count>=0&&count<1&&days>=1&&days<=2;varcoordinator=newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,temporaryStaffRule),newConfirmationFormatter(),newRecordingNotificationSender());varstart=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"));}}
importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorinvalid=ReservationApplication.create(newRecordingNotificationSender());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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(Stringitem:newString[]{"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.ReservationCoordinatordurations=ReservationApplication.create(newRecordingNotificationSender());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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}}}
4.7 after/src/test/java/Chapter05Checks.java
importjava.time.LocalDate;importjava.util.List;publicfinalclassChapter05Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){BorrowingPolicystudent=newReservationRules();BorrowingPolicystaff=newStaffReservationRules();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");LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(inti=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");ReservationCoordinatordefaultStudent=ReservationApplication.create(newRecordingNotificationSender());ReservationCoordinatorexplicitStudent=ReservationApplication.create(newRecordingNotificationSender());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.ReservationCoordinatormixedFixture=ReservationApplication.create(newRecordingNotificationSender());for(inti=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.");}privatestaticvoidsharedPolicyContract(BorrowingPolicypolicy){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");booleanfirst=policy.mayRequest(1,2);policy.mayRequest(0,1);check(policy.mayRequest(1,2)==first,"repeat decision unchanged by intervening call");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
4.8 after/src/test/java/Chapter06Checks.java
importjava.io.ByteArrayOutputStream;importjava.io.OutputStream;importjava.io.PrintStream;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.time.LocalDate;importjava.util.EnumMap;importjava.util.List;importjava.util.Map;publicfinalclassChapter06Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorc=ReservationApplication.create(inbox);Stringfirst=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");FailingSenderfailure=newFailingSender();ReservationCoordinatorfailed=ReservationApplication.create(failure);StringfailureResult=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");ReservationCoordinatorunexpected=ReservationApplication.create(newBrokenSender());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,()->newReservationCoordinator(null,newConfirmationFormatter(),inbox),"null policy map");expect(IllegalArgumentException.class,()->newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules()),newConfirmationFormatter(),inbox),"missing staff policy");expect(NullPointerException.class,()->newReservationCoordinator(configured,null,inbox),"null formatter");expect(NullPointerException.class,()->newReservationCoordinator(configured,newConfirmationFormatter(),null),"null sender");Map<BorrowerCategory,BorrowingPolicy>mutable=newEnumMap<>(configured);ReservationCoordinatorcopied=newReservationCoordinator(mutable,newConfirmationFormatter(),newRecordingNotificationSender());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=newEnumMap<>(configured);restrictive.put(BorrowerCategory.STUDENT,(count,days)->false);RecordingNotificationSenderrestrictedInbox=newRecordingNotificationSender();ReservationCoordinatorrestricted=newReservationCoordinator(restrictive,newConfirmationFormatter(),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");ByteArrayOutputStreambytes=newByteArrayOutputStream();ConsoleNotificationSenderconsole=newConsoleNotificationSender(newPrintStream(bytes,true,StandardCharsets.UTF_8),newConfirmationFormatter());console.send(c.reservations().get(0));check(bytes.toString(StandardCharsets.UTF_8).equals("Notice: "+first+System.lineSeparator()),"console implementation uses supplied stream");PrintStreambrokenOutput=newPrintStream(newOutputStream(){@Overridepublicvoidwrite(intvalue)throwsIOException{thrownewIOException("Expected output failure");}});ConsoleNotificationSenderbrokenConsole=newConsoleNotificationSender(brokenOutput,newConfirmationFormatter());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.");}privatestaticMap<BorrowerCategory,BorrowingPolicy>policies(){returnMap.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());}privatestaticfinalclassFailingSenderimplementsNotificationSender{privateintcalls;@Overridepublicvoidsend(Reservationreservation){calls++;thrownewNotificationException("Expected teaching failure");}}// A programming defect, not an expected output failure: it must not be swallowed.privatestaticfinalclassBrokenSenderimplementsNotificationSender{@Overridepublicvoidsend(Reservationreservation){thrownewIllegalStateException("Unexpected programming failure");}}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition)thrownewAssertionError(label);checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
4.9 after/src/test/java/DirectoryAdapterTest.java
import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;importorg.junit.jupiter.params.provider.NullAndEmptySource;importorg.junit.jupiter.params.provider.ValueSource;classDirectoryAdapterTest{privatefinalLocalDatestart=LocalDate.of(2026,10,20);@ParameterizedTest@CsvSource({"S,STUDENT","T,STAFF"})voidmapsKnownActiveCategories(Stringcode,BorrowerCategoryexpected){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,code,"Y"));assertEquals(newBorrower("B1",expected),adapter.find("B1").orElseThrow());}@TestvoidabsenceAndInactiveAreEmpty(){assertTrue(newLegacyDirectoryAdapter(id->null).find("B1").isEmpty());assertTrue(newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,"S","N")).find("B1").isEmpty());}@ParameterizedTest@CsvSource({"X,Y","S,X","s,Y","S,y"})voidunknownCodesAreErrors(Stringkind,Stringstatus){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,kind,status));assertThrows(DirectoryException.class,()->adapter.find("B1"));}@TestvoidmalformedRowsAreErrors(){for(varrow:newLegacyDirectoryClient.Person[]{newLegacyDirectoryClient.Person("OTHER","S","Y"),newLegacyDirectoryClient.Person(null,"S","Y"),newLegacyDirectoryClient.Person("B1",null,"Y"),newLegacyDirectoryClient.Person("B1","S",null),newLegacyDirectoryClient.Person("B1","X","N")}){assertThrows(DirectoryException.class,()->newLegacyDirectoryAdapter(id->row).find("B1"));}}@ParameterizedTest@NullAndEmptySource@ValueSource(strings={" "})voidinvalidIdentifierDoesNotReachProvider(Stringid){varcalls=newAtomicInteger();varadapter=newLegacyDirectoryAdapter(key->{calls.incrementAndGet();returnnull;});assertThrows(IllegalArgumentException.class,()->adapter.find(id));assertEquals(0,calls.get());}@TestvoidtranslatesExpectedFailureAndKeepsCause(){varoriginal=newLegacyDirectoryClient.Failure("offline");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});varresult=assertThrows(DirectoryException.class,()->adapter.find("B1"));assertSame(original,result.getCause());}@TestvoidunexpectedProgrammingFailureIsNotDisguisedAsAbsence(){varoriginal=newIllegalStateException("bug");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});assertSame(original,assertThrows(IllegalStateException.class,()->adapter.find("B1")));}@TestvoidcategoryFromDirectoryControlsReservationPolicy(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);vardirectory=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,id.equals("STAFF1")?"T":"S","Y"));varservice=newDirectoryReservationService(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());}@TestvoidabsenceAndOutageDoNotStoreNotifyOrConsumeId(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);varmissing=newDirectoryReservationService(id->Optional.empty(),coordinator);assertEquals("Rejected: borrower not found or inactive.",missing.reserve("B1","CAM1",start,2));varfailing=newDirectoryReservationService(newLegacyDirectoryAdapter(id->{thrownewLegacyDirectoryClient.Failure("offline");}),coordinator);assertThrows(DirectoryException.class,()->failing.reserve("B1","CAM1",start,2));assertTrue(coordinator.reservations().isEmpty());assertTrue(notices.received().isEmpty());varvalid=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.STUDENT)),coordinator);assertTrue(valid.reserve("B1","CAM1",start,2).startsWith("Confirmed: R1"));}@TestvoidmismatchedDomainBorrowerIsRejectedBeforeMutation(){varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->Optional.of(newBorrower("OTHER",BorrowerCategory.STAFF)),coordinator);assertThrows(DirectoryException.class,()->service.reserve("B1","CAM1",start,10));assertTrue(coordinator.reservations().isEmpty());}@TestvoidlocalArgumentErrorsPrecedeLookup(){varcalls=newAtomicInteger();varservice=newDirectoryReservationService(id->{calls.incrementAndGet();returnOptional.empty();},ReservationApplication.create(newRecordingNotificationSender()));assertThrows(IllegalArgumentException.class,()->service.reserve("B1"," ",start,2));assertThrows(NullPointerException.class,()->service.reserve("B1","CAM1",null,2));assertEquals(0,calls.get());}}
importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.mockito.ArgumentCaptor;import staticorg.junit.jupiter.api.Assertions.*;import staticorg.mockito.Mockito.*;classNotificationMockitoTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);@TestvoidacceptedReservationIsSentOnceWithItsDomainDetails(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);c.reserve("B1","CAM1",day,2);ArgumentCaptor<Reservation>captured=ArgumentCaptor.forClass(Reservation.class);verify(sender).send(captured.capture());Reservationsent=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()));}@TestvoidrejectedDurationDoesNotNotify(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,8));assertTrue(c.reservations().isEmpty());verifyNoInteractions(sender);}@TestvoidexpectedNotificationFailureRetainsReservationWithoutRetry(){NotificationSendersender=mock(NotificationSender.class);doThrow(newNotificationException("Expected test failure")).when(sender).send(any(Reservation.class));ReservationCoordinatorc=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));}}
#!/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
Behaviour
New test evidence
Independent strategy boundary decisions
Eight rows of strategiesAndConditionalMatchIndependentExamples
The two execution-state tests in ReserveEquipmentCommandTest
Unhidden receiver failure
receiverFailurePropagatesWithoutMutation
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:publicvoidsetStatus(StatusrequestedStatus){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.@OverridePhasecancel(){returnCANCELLED;}
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(Entryentry: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.
Area
Faults tried
Result
Observer
No 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 accepted
All ten caught by NotificationPublisherTest
Command
Reserves while constructing (7.4); borrower and item swapped
Both caught
Command
Construction validation removed
Caught 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
Command
Executes with one extra day
Caught 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
Lifecycle
On-loan reservation may be cancelled (7.2, in State and in the conditional); a returned reservation may be cancelled; progress shared between contexts
All 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.
Gamma, E., Helm, R., Johnson, R. and Vlissides, J., Design Patterns: Elements of Reusable Object-Oriented Software. Publisher catalogue.
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.
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:
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:
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.
Existing top-level patterns and behaviour packages
Earlier isolated examples and their tests, unchanged
New top-level examples.structure
PeriodValue 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 package
Permitted local targets
Domain
Domain
Application ports
Domain and application ports
Application services
Domain and application, including ports
Infrastructure
Domain, application and infrastructure
Presentation
Domain, application and presentation
Bootstrap
All 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.
Origin
Preserved item
Location in this version
Chapter 3
The 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 6
BrokenSender: an unexpected sender failure is not swallowed and the record is stored first (28 checks)
regression/Chapter06Checks.java
Chapters 6â8
serialVersionUID on NotificationException, DirectoryException, LegacyDirectoryClient.Failure and PatternExamples.DeliveryFailure
those four files
Chapter 7
assertThrows(UnsupportedOperationException.class, snapshot::clear) on the loan snapshot
Command construction rejects a null receiver, a blank borrower, a null item and a null start, and the confirmation is compared exactly, period included
regression/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.
packagebehaviour;/** Proposed lifecycle only: not integrated with ReservationCoordinator or LoanService. */publicfinalclassLifecycleExamples{privateLifecycleExamples(){}publicenumStatus{REQUESTED,CONFIRMED,ON_LOAN,RETURNED,CANCELLED}publicinterfaceLifecycle{Statusstatus();voidconfirm();voidcheckOut();voidreturnItem();voidcancel();}// Starting design: operation-specific switches repeat state decisions.publicstaticfinalclassConditionalReservationimplementsLifecycle{privateStatusstatus=Status.REQUESTED;publicStatusstatus(){returnstatus;}publicvoidconfirm(){switch(status){caseREQUESTED->status=Status.CONFIRMED;default->throwrejected("confirm",status);}}publicvoidcheckOut(){switch(status){caseCONFIRMED->status=Status.ON_LOAN;default->throwrejected("check out",status);}}publicvoidreturnItem(){switch(status){caseON_LOAN->status=Status.RETURNED;default->throwrejected("return",status);}}publicvoidcancel(){switch(status){caseREQUESTED,CONFIRMED->status=Status.CANCELLED;default->throwrejected("cancel",status);}}}// Completed State design: context delegates to the current state object.publicstaticfinalclassStateReservationimplementsLifecycle{privatePhasephase=Phase.REQUESTED;publicStatusstatus(){returnphase.status;}publicvoidconfirm(){phase=phase.confirm();}publicvoidcheckOut(){phase=phase.checkOut();}publicvoidreturnItem(){phase=phase.returnItem();}publicvoidcancel(){phase=phase.cancel();}}privateenumPhase{REQUESTED(Status.REQUESTED){@OverridePhaseconfirm(){returnCONFIRMED;}@OverridePhasecancel(){returnCANCELLED;}},CONFIRMED(Status.CONFIRMED){@OverridePhasecheckOut(){returnON_LOAN;}@OverridePhasecancel(){returnCANCELLED;}},ON_LOAN(Status.ON_LOAN){@OverridePhasereturnItem(){returnRETURNED;}},RETURNED(Status.RETURNED),CANCELLED(Status.CANCELLED);finalStatusstatus;Phase(Statusstatus){this.status=status;}Phaseconfirm(){throwrejected("confirm",status);}PhasecheckOut(){throwrejected("check out",status);}PhasereturnItem(){throwrejected("return",status);}Phasecancel(){throwrejected("cancel",status);}}privatestaticIllegalStateExceptionrejected(Stringaction,Statusstatus){returnnewIllegalStateException("Cannot "+action+" in "+status);}}
packageedu.se506.lending.application;/** Executes synchronously on the caller's thread; no queue or retry is implied. */@FunctionalInterfacepublicinterfaceCommand<R>{Rexecute();}
packageedu.se506.lending.application;importedu.se506.lending.domain.Reservation;importjava.util.Objects;publicfinalclassConfirmationFormatter{publicStringformat(Reservationreservation){Objects.requireNonNull(reservation,"reservation");return"Confirmed: "+reservation.id()+" for "+reservation.borrowerId()+" on "+reservation.itemId()+" ["+reservation.period().start()+", "+reservation.period().endExclusive()+").";}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.BorrowerDirectory;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.domain.Borrower;importjava.time.LocalDate;importjava.util.Objects;/** Directory-backed entry point; this does not authenticate the caller. */publicfinalclassDirectoryReservationService{privatefinalBorrowerDirectorydirectory;privatefinalReservationCoordinatorcoordinator;publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator){this.directory=Objects.requireNonNull(directory,"directory");this.coordinator=Objects.requireNonNull(coordinator,"coordinator");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");varfound=directory.find(borrowerId);if(found.isEmpty())return"Rejected: borrower not found or inactive.";Borrowerborrower=found.get();if(!borrowerId.equals(borrower.id())){thrownewDirectoryException("Directory returned a different borrower");}returncoordinator.reserve(borrower.id(),itemId,start,days,borrower.category());}}
packageedu.se506.lending.application;importedu.se506.lending.domain.Loan;importedu.se506.lending.domain.Reservation;importjava.time.Clock;importjava.time.LocalDate;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;/** One service per coordinator/session; sequential, in-memory teaching flow. */publicfinalclassLoanService{privatefinalReservationCoordinatorreservations;privatefinalClockclock;privatefinalMap<String,Loan>loans=newLinkedHashMap<>();publicLoanService(ReservationCoordinatorreservations,Clockclock){this.reservations=Objects.requireNonNull(reservations,"reservations");this.clock=Objects.requireNonNull(clock,"clock");}publicLoancheckOut(StringreservationId){Reservationreservation=reservations.reservations().stream().filter(r->r.id().equals(reservationId)).findFirst().orElseThrow(()->newIllegalArgumentException("Unknown reservation."));if(loans.containsKey(reservationId)){thrownewIllegalStateException("Reservation already used for a loan.");}LocalDatetoday=LocalDate.now(clock);if(today.isBefore(reservation.period().start())||!today.isBefore(reservation.period().endExclusive())){thrownewIllegalStateException("Checkout must be within the reservation period.");}booleanitemAlreadyOut=loans.values().stream().anyMatch(loan->loan.active()&&loan.itemId().equals(reservation.itemId()));if(itemAlreadyOut)thrownewIllegalStateException("Item is already on loan.");Loanloan=newLoan(reservation.id(),reservation.borrowerId(),reservation.itemId(),today,null);loans.put(reservationId,loan);returnloan;}publicLoanreturnLoan(StringreservationId){Loancurrent=loans.get(reservationId);if(current==null||!current.active()){thrownewIllegalStateException("No active loan for this reservation.");}Loanreturned=newLoan(current.reservationId(),current.borrowerId(),current.itemId(),current.borrowedOn(),LocalDate.now(clock));loans.put(reservationId,returned);returnreturned;}publicList<Loan>loans(){returnList.copyOf(loans.values());}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.NotificationException;importedu.se506.lending.application.port.NotificationSender;importedu.se506.lending.application.port.ReservationRepository;importedu.se506.lending.domain.BorrowerCategory;importedu.se506.lending.domain.BorrowingPolicy;importedu.se506.lending.domain.LoanPeriod;importedu.se506.lending.domain.Reservation;importjava.time.LocalDate;importjava.util.List;importjava.util.Objects;importjava.util.Map;publicfinalclassReservationCoordinator{privatefinalReservationRepositoryrepository;privatefinalMap<BorrowerCategory,BorrowingPolicy>policies;privatefinalConfirmationFormatterformatter;privatefinalNotificationSendernotifications;publicReservationCoordinator(Map<BorrowerCategory,BorrowingPolicy>policies,ConfirmationFormatterformatter,NotificationSendernotifications,ReservationRepositoryrepository){this.policies=Map.copyOf(Objects.requireNonNull(policies,"policies"));for(BorrowerCategorycategory:BorrowerCategory.values()){if(!this.policies.containsKey(category)){thrownewIllegalArgumentException("Missing policy: "+category);}}this.formatter=Objects.requireNonNull(formatter,"formatter");this.notifications=Objects.requireNonNull(notifications,"notifications");this.repository=Objects.requireNonNull(repository,"repository");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays){returnreserve(borrowerId,itemId,start,requestedDays,BorrowerCategory.STUDENT);}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intrequestedDays,BorrowerCategorycategory){Objects.requireNonNull(category,"category");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");intactiveCount=0;for(Reservationexisting:repository.findAll()){if(existing.borrowerId().equals(borrowerId)){activeCount++;}}if(!policies.get(category).mayRequest(activeCount,requestedDays)){return"Rejected: borrower limit or duration.";}LoanPeriodrequested=newLoanPeriod(start,start.plusDays(requestedDays));if(hasConflict(itemId,requested)){return"Rejected: item unavailable.";}Reservationreservation=newReservation("R"+(repository.findAll().size()+1),borrowerId,itemId,requested);repository.add(reservation);Stringconfirmation=formatter.format(reservation);try{notifications.send(reservation);}catch(NotificationExceptionfailure){returnconfirmation+" Notification failed; reservation retained.";}returnconfirmation;}publicbooleanisAvailable(StringitemId,LocalDatestart,LocalDateendExclusive){if(itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Item identifier must not be blank.");}LoanPeriodrequested=newLoanPeriod(start,endExclusive);return!hasConflict(itemId,requested);}privatebooleanhasConflict(StringitemId,LoanPeriodrequested){for(Reservationexisting:repository.findAll()){if(existing.itemId().equals(itemId)&&existing.period().overlaps(requested)){returntrue;}}returnfalse;}publicList<Reservation>reservations(){returnrepository.findAll();}}
packageedu.se506.lending.application;importjava.time.LocalDate;importjava.util.Objects;/** Captures a request and receiver; constructing it performs no reservation. */publicrecordReserveEquipmentCommand(DirectoryReservationServicereceiver,StringborrowerId,StringitemId,LocalDatestart,intdays)implementsCommand<String>{publicReserveEquipmentCommand{Objects.requireNonNull(receiver,"receiver");if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");}@OverridepublicStringexecute(){returnreceiver.reserve(borrowerId,itemId,start,days);}}
packageedu.se506.lending.application.port;importedu.se506.lending.domain.Borrower;importjava.util.Optional;/** Empty means absent or inactive, never a provider outage. */@FunctionalInterfacepublicinterfaceBorrowerDirectory{Optional<Borrower>find(StringborrowerId);}
packageedu.se506.lending.application.port;importedu.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.
*/@FunctionalInterfacepublicinterfaceNotificationSender{voidsend(Reservationreservation);}
packageedu.se506.lending.application.port;importedu.se506.lending.domain.Reservation;importjava.util.List;/** Minimal append-only store contract for one sequential session. */publicinterfaceReservationRepository{/** Stable unmodifiable snapshot, in insertion order. Values are immutable. */List<Reservation>findAll();/** Add a non-null reservation; duplicate IDs reject without changing the store. */voidadd(Reservationreservation);}
packageedu.se506.lending.bootstrap;importedu.se506.lending.application.LoanService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.domain.Loan;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;publicfinalclassChapter07Demo{publicstaticvoidmain(String[]args){RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorreservations=ReservationApplication.create(inbox);LocalDateday=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)));LoanServiceloans=newLoanService(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());}}
packageedu.se506.lending.domain;importjava.util.Objects;publicrecordBorrower(Stringid,BorrowerCategorycategory){publicBorrower{if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");Objects.requireNonNull(category,"category");}}
packageedu.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.
*/publicinterfaceBorrowingPolicy{booleanmayRequest(intactiveReservations,intrequestedDays);}
packageedu.se506.lending.domain;importjava.time.LocalDate;importjava.util.Objects;/** Immutable loan state. A null returnedOn means this loan is active. */publicrecordLoan(StringreservationId,StringborrowerId,StringitemId,LocalDateborrowedOn,LocalDatereturnedOn){publicLoan{if(reservationId==null||reservationId.isBlank()||borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Loan identifiers must not be blank.");}Objects.requireNonNull(borrowedOn,"borrowedOn");if(returnedOn!=null&&returnedOn.isBefore(borrowedOn)){thrownewIllegalArgumentException("Return cannot precede checkout.");}}publicbooleanactive(){returnreturnedOn==null;}}
packageedu.se506.lending.domain;importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
packageedu.se506.lending.domain;importjava.util.Objects;publicfinalclassReservation{privatefinalStringid;privatefinalStringborrowerId;privatefinalStringitemId;privatefinalLoanPeriodperiod;publicReservation(Stringid,StringborrowerId,StringitemId,LoanPeriodperiod){this.id=requireText(id);this.borrowerId=requireText(borrowerId);this.itemId=requireText(itemId);this.period=Objects.requireNonNull(period,"period");}privatestaticStringrequireText(Stringvalue){if(value==null||value.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}returnvalue;}publicStringid(){returnid;}publicStringborrowerId(){returnborrowerId;}publicStringitemId(){returnitemId;}publicLoanPeriodperiod(){returnperiod;}}
packageedu.se506.lending.domain;/** Illustrative teaching policy, not an approved institutional rule. */publicfinalclassStaffReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<5&&requestedDays>=1&&requestedDays<=14;}}
packageedu.se506.lending.infrastructure.directory;/** Illustrative provider contract, not an actual university API or network client. */@FunctionalInterfacepublicinterfaceLegacyDirectoryClient{// null means no row; codes are exact and case-sensitive.Personlookup(Stringkey);recordPerson(Stringkey,Stringkind,Stringstatus){}finalclassFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicFailure(Stringmessage){super(message);}}}
packageedu.se506.lending.infrastructure.notification;importedu.se506.lending.application.port.NotificationException;importedu.se506.lending.application.port.NotificationSender;importedu.se506.lending.domain.Reservation;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;/** Sequential, in-process Observer example. No retries or durable delivery. */publicfinalclassNotificationPublisherimplementsNotificationSender{@FunctionalInterfacepublicinterfaceObserver{voidonConfirmed(Reservationreservation);}@FunctionalInterfacepublicinterfaceSubscriptionextendsAutoCloseable{@Overridevoidclose();}privatestaticfinalclassEntry{privatefinalObserverobserver;privateEntry(Observerobserver){this.observer=observer;}}privatefinalList<Entry>entries=newArrayList<>();privatebooleanpublishing;publicSubscriptionsubscribe(Observerobserver){Objects.requireNonNull(observer,"observer");if(entries.stream().anyMatch(entry->entry.observer==observer)){thrownewIllegalArgumentException("Observer already subscribed");}Entryentry=newEntry(observer);entries.add(entry);return()->entries.remove(entry);}@Overridepublicvoidsend(Reservationreservation){Objects.requireNonNull(reservation,"reservation");if(publishing)thrownewIllegalStateException("Reentrant publication is not supported");publishing=true;try{NotificationExceptionaggregate=null;for(Entryentry:List.copyOf(entries)){try{entry.observer.onConfirmed(reservation);}catch(NotificationExceptionfailure){if(aggregate==null)aggregate=newNotificationException("One or more observers failed");aggregate.addSuppressed(failure);}}if(aggregate!=null)throwaggregate;}finally{publishing=false;}}}
packageedu.se506.lending.infrastructure.persistence;importedu.se506.lending.application.port.ReservationRepository;importedu.se506.lending.domain.Reservation;importjava.util.LinkedHashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;/** Insertion ordered, append only, not persistent and not thread-safe. */publicfinalclassInMemoryReservationRepositoryimplementsReservationRepository{privatefinalMap<String,Reservation>records=newLinkedHashMap<>();@OverridepublicList<Reservation>findAll(){returnList.copyOf(records.values());}@Overridepublicvoidadd(Reservationreservation){Objects.requireNonNull(reservation,"reservation");if(records.containsKey(reservation.id())){thrownewIllegalArgumentException("Duplicate reservation id: "+reservation.id());}records.put(reservation.id(),reservation);}}
packageedu.se506.lending.presentation;importedu.se506.lending.application.DirectoryReservationService;importjava.io.PrintStream;importjava.time.LocalDate;importjava.util.Objects;/** Thin output adapter: delegates the use case and prints its existing result. */publicfinalclassReservationConsole{privatefinalDirectoryReservationServiceservice;privatefinalPrintStreamoutput;publicReservationConsole(DirectoryReservationServiceservice,PrintStreamoutput){this.service=Objects.requireNonNull(service,"service");this.output=Objects.requireNonNull(output,"output");}publicvoidreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){output.println(service.reserve(borrowerId,itemId,start,days));}}
packageexamples.structure;importjava.time.LocalDate;importjava.util.Objects;/** Separate equality example; deliberately does not replace cumulative LoanPeriod. */publicrecordPeriodValue(LocalDatestart,LocalDateendExclusive){publicPeriodValue{Objects.requireNonNull(start,"start");Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive))thrownewIllegalArgumentException("End must be after start.");}}
packagepatterns;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;/** Isolated teaching examples; none is wired into the lending application. */publicfinalclassPatternExamples{privatePatternExamples(){}// Factory Method: inherited workflow invokes an overridable creation hook.publicinterfaceExporter{Stringexport(Stringvalue);}publicabstractstaticclassExportJob{protectedabstractExportercreateExporter();publicfinalStringrun(Stringvalue){Objects.requireNonNull(value,"value");returncreateExporter().export(value);}}publicstaticfinalclassPlainJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"TEXT: "+value;}}publicstaticfinalclassHtmlJobextendsExportJob{@OverrideprotectedExportercreateExporter(){returnvalue->"<p>"+escape(value)+"</p>";}}// Simple factory: useful, but no overridden creation hook.publicstaticExportersimpleExporter(booleanhtml){returnhtml?value->"<p>"+escape(value)+"</p>":value->"TEXT: "+value;}// Abstract Factory: two product roles, supplied as a related family.publicinterfaceHeading{Stringrender(Stringtext);}publicinterfaceBody{Stringrender(Stringtext);}publicinterfaceReceiptFamily{Headingheading();Bodybody();}publicstaticfinalclassPlainFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->text+"\n";}publicBodybody(){returntext->text+"\n";}}publicstaticfinalclassHtmlFamilyimplementsReceiptFamily{publicHeadingheading(){returntext->"<h1>"+escape(text)+"</h1>";}publicBodybody(){returntext->"<p>"+escape(text)+"</p>";}}publicstaticStringreceipt(ReceiptFamilyfamily,Stringtitle,Stringdetail){Objects.requireNonNull(family,"family");returnfamily.heading().render(title)+family.body().render(detail);}privatestaticStringescape(Stringtext){returnObjects.requireNonNull(text,"text").replace("&","&").replace("<","<").replace(">",">");}// Fluent builder variant: staged configuration, one immutable product.publicstaticfinalclassReportRequest{privatefinalStringborrowerId;privatefinalLocalDatefrom;privatefinalLocalDatetoExclusive;privatefinalbooleanincludeReturned;privatefinalList<String>columns;privateReportRequest(Builderbuilder){if((builder.from==null)!=(builder.toExclusive==null)){thrownewIllegalArgumentException("Both dates or neither are required");}if(builder.from!=null&&!builder.from.isBefore(builder.toExclusive)){thrownewIllegalArgumentException("Positive interval required");}borrowerId=builder.borrowerId;from=builder.from;toExclusive=builder.toExclusive;includeReturned=builder.includeReturned;columns=List.copyOf(builder.columns);}publicStringborrowerId(){returnborrowerId;}publicLocalDatefrom(){returnfrom;}publicLocalDatetoExclusive(){returntoExclusive;}publicbooleanincludeReturned(){returnincludeReturned;}publicList<String>columns(){returncolumns;}publicstaticBuilderforBorrower(Stringid){returnnewBuilder(id);}publicstaticfinalclassBuilder{privatefinalStringborrowerId;privateLocalDatefrom;privateLocalDatetoExclusive;privatebooleanincludeReturned;privatefinalList<String>columns=newArrayList<>();privateBuilder(Stringid){if(id==null||id.isBlank())thrownewIllegalArgumentException("Borrower id required");borrowerId=id;}publicBuilderduring(LocalDatefrom,LocalDatetoExclusive){Objects.requireNonNull(from,"from");Objects.requireNonNull(toExclusive,"toExclusive");this.from=from;this.toExclusive=toExclusive;returnthis;}publicBuilderincludeReturned(booleanvalue){includeReturned=value;returnthis;}publicBuildercolumn(Stringvalue){if(value==null||value.isBlank())thrownewIllegalArgumentException("Column required");columns.add(value);returnthis;}publicReportRequestbuild(){returnnewReportRequest(this);}}}// Decorators: same interface, explicit order and expected failure type.publicinterfaceSender{voidsend(Stringmessage);}publicstaticfinalclassDeliveryFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicDeliveryFailure(Stringmessage){super(message);}}publicstaticfinalclassAuditedSenderimplementsSender{privatefinalSenderdelegate;privatefinalList<String>events;publicAuditedSender(Senderdelegate,List<String>events){this.delegate=Objects.requireNonNull(delegate,"delegate");this.events=Objects.requireNonNull(events,"events");}publicvoidsend(Stringmessage){events.add("attempt");try{delegate.send(message);}catch(DeliveryFailurefailure){events.add("failed");throwfailure;}events.add("sent");}}publicstaticfinalclassRetryOnceSenderimplementsSender{privatefinalSenderdelegate;publicRetryOnceSender(Senderdelegate){this.delegate=Objects.requireNonNull(delegate,"delegate");}publicvoidsend(Stringmessage){try{delegate.send(message);}catch(DeliveryFailurefailure){delegate.send(message);}}}}
4. Complete test sources
Migrated regression tests retain their original expectations. Structure tests examine the new boundaries and compiled references.
packageedu.se506.lending.regression;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.Reservation;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTddTest{@TestvoidoverlappingEnquiryIsUnavailableWithoutChangingRecordsOrNotifying(){// Arrange: one confirmed CAM1 reservation, ending exclusively on 22 October.RecordingNotificationSenderinbox=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(inbox);LocalDatestart=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.booleanavailable=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()));}}
packageedu.se506.lending.regression;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.Reservation;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.LocalDate;importjava.util.List;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.ValueSource;import staticorg.junit.jupiter.api.Assertions.*;classAvailabilityTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);privatefinalRecordingNotificationSenderinbox=newRecordingNotificationSender();privatefinalReservationCoordinatorc=ReservationApplication.create(inbox);@TestvoidemptyScheduleIsAvailableWithoutAnySideEffect(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(2)));assertTrue(c.reservations().isEmpty());assertTrue(inbox.received().isEmpty());}@TestvoidadjacentPeriodAndDifferentItemAreAvailable(){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})voidinvalidPeriodFailsWithoutChangingState(intendOffset){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());}@TestvoidmalformedIdentifiersAndNullDatesAreRejected(){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());}@TestvoidqueryDoesNotAllocateAnIdentifier(){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));}@TestvoidavailabilityDoesNotMeanBorrowerEligibility(){for(Stringitem: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());}@TestvoidqueryCanSpanMoreDaysThanAStudentMayReserve(){assertTrue(c.isAvailable("CAM1",day,day.plusDays(30)));assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,30));}}
packageedu.se506.lending.regression;importedu.se506.lending.application.ConfirmationFormatter;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.domain.BorrowerCategory;importedu.se506.lending.domain.BorrowingPolicy;importedu.se506.lending.domain.ReservationRules;importedu.se506.lending.domain.StaffReservationRules;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importedu.se506.lending.infrastructure.persistence.InMemoryReservationRepository;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.Map;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;classBehaviourStrategyTest{// Starting conditional design retained only for comparison in this exercise.privatebooleanconditional(BorrowerCategorycategory,intcount,intdays){if(category==BorrowerCategory.STUDENT){returncount>=0&&count<3&&days>=1&&days<=7;}returncount>=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"})voidstrategiesAndConditionalMatchIndependentExamples(BorrowerCategorycategory,intcount,intdays,booleanexpected){varpolicies=Map.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,newStaffReservationRules());assertEquals(expected,conditional(category,count,days));assertEquals(expected,policies.get(category).mayRequest(count,days));}@TestvoidinjectedPolicyChangesDecisionWithoutChangingCoordinator(){BorrowingPolicytemporaryStaffRule=(count,days)->count>=0&&count<1&&days>=1&&days<=2;varcoordinator=newReservationCoordinator(Map.of(BorrowerCategory.STUDENT,newReservationRules(),BorrowerCategory.STAFF,temporaryStaffRule),newConfirmationFormatter(),newRecordingNotificationSender(),newInMemoryReservationRepository());varstart=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"));}}
packageedu.se506.lending.regression;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.LoanPeriod;importedu.se506.lending.domain.Reservation;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.DateTimeException;importjava.time.LocalDate;importjava.util.List;publicclassChapter02Checks{privatestaticintchecks;publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);LoanPeriodfirst=newLoanPeriod(day,day.plusDays(2));LoanPeriodoverlapping=newLoanPeriod(day.plusDays(1),day.plusDays(3));LoanPeriodadjacent=newLoanPeriod(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(newLoanPeriod(day,day.plusDays(2))),"equal periods overlap");check(first.overlaps(newLoanPeriod(day.minusDays(1),day.plusDays(4))),"containment overlaps");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day),"zero-length period");expect(IllegalArgumentException.class,()->newLoanPeriod(day,day.minusDays(1)),"reversed period");expect(NullPointerException.class,()->newLoanPeriod(null,day),"null start");expect(NullPointerException.class,()->newLoanPeriod(day,null),"null end");ReservationCoordinatorc=ReservationApplication.create(newRecordingNotificationSender());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");ReservationCoordinatorinvalid=ReservationApplication.create(newRecordingNotificationSender());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,()->newReservation("","B1","CAM1",first),"blank reservation identifier");expect(NullPointerException.class,()->newReservation("R1","B1","CAM1",null),"null reservation period");System.out.println("All "+checks+" chapter checks passed.");}privatestaticvoidcheck(booleancondition,Stringlabel){if(!condition){thrownewAssertionError(label);}checks++;}privatestaticvoidexpect(Class<?extendsThrowable>type,Runnableaction,Stringlabel){try{action.run();}catch(Throwableerror){check(type.isInstance(error),label+": wrong exception "+error);return;}thrownewAssertionError(label+": expected "+type.getSimpleName());}}
packageedu.se506.lending.regression;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.LocalDate;publicclassChapter03AcceptanceExamples{publicstaticvoidmain(String[]args){LocalDateday=LocalDate.of(2026,10,20);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());// AC-01: an eligible request is stored once and confirmed.Stringaccepted=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.Stringconflict=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.Stringadjacent=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");Stringbefore=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.ReservationCoordinatorlimits=ReservationApplication.create(newRecordingNotificationSender());for(Stringitem:newString[]{"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.ReservationCoordinatordurations=ReservationApplication.create(newRecordingNotificationSender());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(intdays:newint[]{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.");}privatestaticvoidrequire(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}}}
packageedu.se506.lending.regression;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.Borrower;importedu.se506.lending.domain.BorrowerCategory;importedu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;importorg.junit.jupiter.params.provider.NullAndEmptySource;importorg.junit.jupiter.params.provider.ValueSource;classDirectoryAdapterTest{privatefinalLocalDatestart=LocalDate.of(2026,10,20);@ParameterizedTest@CsvSource({"S,STUDENT","T,STAFF"})voidmapsKnownActiveCategories(Stringcode,BorrowerCategoryexpected){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,code,"Y"));assertEquals(newBorrower("B1",expected),adapter.find("B1").orElseThrow());}@TestvoidabsenceAndInactiveAreEmpty(){assertTrue(newLegacyDirectoryAdapter(id->null).find("B1").isEmpty());assertTrue(newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,"S","N")).find("B1").isEmpty());}@ParameterizedTest@CsvSource({"X,Y","S,X","s,Y","S,y"})voidunknownCodesAreErrors(Stringkind,Stringstatus){varadapter=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,kind,status));assertThrows(DirectoryException.class,()->adapter.find("B1"));}@TestvoidmalformedRowsAreErrors(){for(varrow:newLegacyDirectoryClient.Person[]{newLegacyDirectoryClient.Person("OTHER","S","Y"),newLegacyDirectoryClient.Person(null,"S","Y"),newLegacyDirectoryClient.Person("B1",null,"Y"),newLegacyDirectoryClient.Person("B1","S",null),newLegacyDirectoryClient.Person("B1","X","N")}){assertThrows(DirectoryException.class,()->newLegacyDirectoryAdapter(id->row).find("B1"));}}@ParameterizedTest@NullAndEmptySource@ValueSource(strings={" "})voidinvalidIdentifierDoesNotReachProvider(Stringid){varcalls=newAtomicInteger();varadapter=newLegacyDirectoryAdapter(key->{calls.incrementAndGet();returnnull;});assertThrows(IllegalArgumentException.class,()->adapter.find(id));assertEquals(0,calls.get());}@TestvoidtranslatesExpectedFailureAndKeepsCause(){varoriginal=newLegacyDirectoryClient.Failure("offline");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});varresult=assertThrows(DirectoryException.class,()->adapter.find("B1"));assertSame(original,result.getCause());}@TestvoidunexpectedProgrammingFailureIsNotDisguisedAsAbsence(){varoriginal=newIllegalStateException("bug");varadapter=newLegacyDirectoryAdapter(id->{throworiginal;});assertSame(original,assertThrows(IllegalStateException.class,()->adapter.find("B1")));}@TestvoidcategoryFromDirectoryControlsReservationPolicy(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);vardirectory=newLegacyDirectoryAdapter(id->newLegacyDirectoryClient.Person(id,id.equals("STAFF1")?"T":"S","Y"));varservice=newDirectoryReservationService(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());}@TestvoidabsenceAndOutageDoNotStoreNotifyOrConsumeId(){varnotices=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(notices);varmissing=newDirectoryReservationService(id->Optional.empty(),coordinator);assertEquals("Rejected: borrower not found or inactive.",missing.reserve("B1","CAM1",start,2));varfailing=newDirectoryReservationService(newLegacyDirectoryAdapter(id->{thrownewLegacyDirectoryClient.Failure("offline");}),coordinator);assertThrows(DirectoryException.class,()->failing.reserve("B1","CAM1",start,2));assertTrue(coordinator.reservations().isEmpty());assertTrue(notices.received().isEmpty());varvalid=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.STUDENT)),coordinator);assertTrue(valid.reserve("B1","CAM1",start,2).startsWith("Confirmed: R1"));}@TestvoidmismatchedDomainBorrowerIsRejectedBeforeMutation(){varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->Optional.of(newBorrower("OTHER",BorrowerCategory.STAFF)),coordinator);assertThrows(DirectoryException.class,()->service.reserve("B1","CAM1",start,10));assertTrue(coordinator.reservations().isEmpty());}@TestvoidlocalArgumentErrorsPrecedeLookup(){varcalls=newAtomicInteger();varservice=newDirectoryReservationService(id->{calls.incrementAndGet();returnOptional.empty();},ReservationApplication.create(newRecordingNotificationSender()));assertThrows(IllegalArgumentException.class,()->service.reserve("B1"," ",start,2));assertThrows(NullPointerException.class,()->service.reserve("B1","CAM1",null,2));assertEquals(0,calls.get());}}
packageedu.se506.lending.regression;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.port.NotificationException;importedu.se506.lending.application.port.NotificationSender;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.Reservation;importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.mockito.ArgumentCaptor;import staticorg.junit.jupiter.api.Assertions.*;import staticorg.mockito.Mockito.*;classNotificationMockitoTest{privatefinalLocalDateday=LocalDate.of(2026,10,20);@TestvoidacceptedReservationIsSentOnceWithItsDomainDetails(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);c.reserve("B1","CAM1",day,2);ArgumentCaptor<Reservation>captured=ArgumentCaptor.forClass(Reservation.class);verify(sender).send(captured.capture());Reservationsent=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()));}@TestvoidrejectedDurationDoesNotNotify(){NotificationSendersender=mock(NotificationSender.class);ReservationCoordinatorc=ReservationApplication.create(sender);assertEquals("Rejected: borrower limit or duration.",c.reserve("B1","CAM1",day,8));assertTrue(c.reservations().isEmpty());verifyNoInteractions(sender);}@TestvoidexpectedNotificationFailureRetainsReservationWithoutRetry(){NotificationSendersender=mock(NotificationSender.class);doThrow(newNotificationException("Expected test failure")).when(sender).send(any(Reservation.class));ReservationCoordinatorc=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));}}
packageedu.se506.lending.structure;import staticorg.junit.jupiter.api.Assertions.*;importjava.nio.charset.StandardCharsets;importjava.nio.file.Path;importjava.util.ArrayDeque;importjava.util.ArrayList;importjava.util.Deque;importjava.util.HashSet;importjava.util.List;importjava.util.Map;importjava.util.Set;importjava.util.TreeMap;importjava.util.TreeSet;importjava.util.regex.Pattern;importorg.junit.jupiter.api.Test;/** Checks compiled local dependency direction; not reflection or semantic correctness. */classDependencyRulesTest{privatestaticfinalStringBASE="edu.se506.lending.";privatestaticfinalPatternEDGE=Pattern.compile("(?m)^\\s*(edu\\.se506\\.lending\\.[\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+.*$");@TestvoidcompiledProductionDependenciesFollowDeclaredDirection()throwsException{Stringoutput=jdepsOutput();varmatcher=EDGE.matcher(output);intobserved=0;List<String>violations=newArrayList<>();while(matcher.find()){observed++;Stringfrom=matcher.group(1);Stringto=matcher.group(2);if(from.startsWith(BASE+"bootstrap."))continue;if(to.startsWith("patterns.")||to.startsWith("behaviour.")||to.startsWith("examples.")){violations.add(from+" -> "+to);}elseif(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));}@TestvoidproductionPackagesHaveNoDependencyCycles()throwsException{Map<String,Set<String>>graph=newTreeMap<>();varmatcher=EDGE.matcher(jdepsOutput());while(matcher.find()){Stringfrom=packageOf(matcher.group(1));Stringto=matcher.group(2).startsWith(BASE)?packageOf(matcher.group(2)):null;graph.computeIfAbsent(from,key->newTreeSet<>());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));}privatestaticStringjdepsOutput()throwsException{Stringexecutable=System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java";Stringjava=Path.of(System.getProperty("java.home"),"bin",executable).toString();Processprocess=newProcessBuilder(java,"--module","jdk.jdeps/com.sun.tools.jdeps.Main","-verbose:class","-filter:none",Path.of("target/classes").toAbsolutePath().toString()).redirectErrorStream(true).start();Stringoutput=newString(process.getInputStream().readAllBytes(),StandardCharsets.UTF_8);assertEquals(0,process.waitFor(),output);returnoutput;}privatestaticStringpackageOf(Stringtype){Stringouter=type.contains("$")?type.substring(0,type.indexOf('$')):type;returnouter.substring(0,outer.lastIndexOf('.'));}/** Depth-first search; returns the packages on one cycle, or an empty list. */privatestaticList<String>findCycle(Map<String,Set<String>>graph){Set<String>done=newHashSet<>();for(Stringstart:graph.keySet()){Deque<String>path=newArrayDeque<>();List<String>found=visit(start,graph,path,done);if(!found.isEmpty())returnfound;}returnList.of();}privatestaticList<String>visit(Stringnode,Map<String,Set<String>>graph,Deque<String>path,Set<String>done){if(path.contains(node)){List<String>cycle=newArrayList<>();booleaninCycle=false;for(Stringelement:(Iterable<String>)path::descendingIterator){if(element.equals(node))inCycle=true;if(inCycle)cycle.add(element);}cycle.add(node);returncycle;}if(!done.add(node))returnList.of();path.push(node);for(Stringnext:graph.getOrDefault(node,Set.of())){List<String>found=visit(next,graph,path,done);if(!found.isEmpty())returnfound;}path.pop();returnList.of();}privatebooleanallowed(Stringfrom,Stringto){if(from.startsWith(BASE+"domain."))returnto.startsWith(BASE+"domain.");if(from.startsWith(BASE+"application.port.")){returnto.startsWith(BASE+"application.port.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"application.")){returnto.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"infrastructure.")){returnto.startsWith(BASE+"infrastructure.")||to.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"presentation.")){returnto.startsWith(BASE+"presentation.")||to.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}returnfalse;}}
packageedu.se506.lending.structure;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.*;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importedu.se506.lending.presentation.ReservationConsole;importjava.io.ByteArrayOutputStream;importjava.io.PrintStream;importjava.nio.charset.StandardCharsets;importjava.time.LocalDate;importjava.util.Optional;importorg.junit.jupiter.api.Test;classReservationConsoleTest{@TestvoidprintsExistingSuccessAndRejectionWithoutDuplicatingPolicy(){varbuffer=newByteArrayOutputStream();varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.STUDENT)),coordinator);varconsole=newReservationConsole(service,newPrintStream(buffer,true,StandardCharsets.UTF_8));varstart=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());}@TestvoidstaffRequestAboveTheStudentLimitIsDecidedByThePolicyNotTheConsole(){varbuffer=newByteArrayOutputStream();varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.STAFF)),coordinator);varconsole=newReservationConsole(service,newPrintStream(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));}@TestvoiddirectoryFailureDoesNotPrintSuccess(){varbuffer=newByteArrayOutputStream();varfailure=newDirectoryException("offline");varcoordinator=ReservationApplication.create(newRecordingNotificationSender());varservice=newDirectoryReservationService(id->{throwfailure;},coordinator);varconsole=newReservationConsole(service,newPrintStream(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());}}
#!/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
importjava.nio.charset.StandardCharsets;importjava.nio.file.Path;importjava.util.Map;importjava.util.Set;importjava.util.TreeMap;importjava.util.TreeSet;importjava.util.regex.Matcher;importjava.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
*/publicfinalclassPackageMetrics{privatestaticfinalPatternEDGE=Pattern.compile("(?m)^\\s+([\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+\\S+\\s*$");publicstaticvoidmain(String[]args)throwsException{if(args.length!=1){System.err.println("usage: java tools/PackageMetrics.java <classes directory>");System.exit(2);}Stringexecutable=System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java";Stringjava=Path.of(System.getProperty("java.home"),"bin",executable).toString();Processprocess=newProcessBuilder(java,"--module","jdk.jdeps/com.sun.tools.jdeps.Main","-verbose:class","-filter:none",Path.of(args[0]).toAbsolutePath().toString()).redirectErrorStream(true).start();Stringoutput=newString(process.getInputStream().readAllBytes(),StandardCharsets.UTF_8);if(process.waitFor()!=0){System.err.println(output);System.exit(1);}Map<String,Set<String>>classesIn=newTreeMap<>();Map<String,Set<String>>dependersOutside=newTreeMap<>();// classes outside P that use PMap<String,Set<String>>dependersInside=newTreeMap<>();// classes in P that use outside classesMatcherm=EDGE.matcher(output);while(m.find()){Stringfrom=outer(m.group(1));Stringto=outer(m.group(2));if(!local(from))continue;classesIn.computeIfAbsent(pkg(from),k->newTreeSet<>()).add(from);if(!local(to))continue;classesIn.computeIfAbsent(pkg(to),k->newTreeSet<>()).add(to);if(!pkg(from).equals(pkg(to))){dependersOutside.computeIfAbsent(pkg(to),k->newTreeSet<>()).add(from);dependersInside.computeIfAbsent(pkg(from),k->newTreeSet<>()).add(from);}}System.out.printf("%-48s %7s %4s %4s %9s%n","package","classes","Ca","Ce","I");for(Stringp:classesIn.keySet()){intca=dependersOutside.getOrDefault(p,Set.of()).size();intce=dependersInside.getOrDefault(p,Set.of()).size();Stringinstability=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);}}privatestaticbooleanlocal(Stringtype){returntype.startsWith("edu.se506.")||type.startsWith("patterns.")||type.startsWith("behaviour.")||type.startsWith("examples.");}privatestaticStringouter(Stringtype){intdollar=type.indexOf('$');returndollar<0?type:type.substring(0,dollar);}privatestaticStringpkg(Stringtype){returntype.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 class
Invocations
Purpose
RepositoryBoundaryTest
7
Snapshot semantics, duplicate/null rejection, storage-before-notification, failed add, independent sessions and a required repository
ValueSemanticsTest
2
Separate record equality and preserved running period equality
ReservationConsoleTest
3
Existing output contract, a staff request above the student limit, and unhidden directory failure
DependencyRulesTest
2
Compiled production references against the local package matrix; no package cycle
Migrated earlier suite
107
Existing policies, directory, loan, notification, pattern and lifecycle examples
Total
121
Complete 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.
Class
Package
May refer to
Must not refer to
StaffReservationRules
domain
domain
Any application, infrastructure or presentation class, for example NotificationSender
LegacyDirectoryAdapter
infrastructure.directory
domain, application (including its ports) and infrastructure
presentation, bootstrap or an isolated example
DirectoryReservationService
application
domain, application.port and application
Any infrastructure class, for example the adapter it receives through its constructor
InMemoryReservationRepository
infrastructure.persistence
domain, application.port and infrastructure
presentation or bootstrap
ReservationApplication
bootstrap
Every application package, and explicitly demonstrated examples
Nothing 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 claim
Finding
Evidence to inspect
A runtime call to the in-memory store proves an application import of infrastructure
Incorrect: interface dispatch and source references are different relationships
Coordinator’s port field, bootstrap construction, jdeps edge
Put the repository interface in infrastructure
Not justified by this use case; it would make the consuming application depend on an infrastructure-owned contract
Port ownership and the declared dependency matrix
Every entity needs repository and service classes
No such requirement follows from identity
Actual access and orchestration needs; LoanPeriod has neither
An unmodifiable list makes every element immutable
Incorrect for mutable elements
List contract plus Reservation/LoanPeriod field semantics
Bootstrap must not import adapters
Incorrect for the selected composition role
Explicit assembly and absence of concrete construction in application
Passing tests establishes peer review
Incorrect evidence substitution
Actual 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:privatefinalReservationRepositoryrepository=newInMemoryReservationRepository();
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:publicList<Reservation>findAll(){returnbackingList;}
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.
Area
Faults tried
Result before the three added tests
Result with the final tests
Repository contract
Duplicate id replaces the original; modifiable snapshot copy; live snapshot view; HashMap instead of insertion order
All four caught by RepositoryBoundaryTest or the inherited checks
Unchanged
Repository contract
Explicit null check removed
Survived: removing it still throws NullPointerException, from reservation.id(), so only the message differs
Survives: equivalent behaviour, deliberately not pinned by a message test
Coordinator
Stores after notification; reservations() returns a modifiable copy; identifier skips a number; conflict check ignores stored records
All four caught
Unchanged
Coordinator
Null repository accepted
Survived: no test constructed a coordinator without a repository
Caught by coordinatorRequiresARepository
Assembly
One repository shared by every session
Caught
Unchanged
Console
Prints without a newline; swallows directory failures
Both caught
Unchanged
Console
Duplicates the seven-day rule
Survived: the console test used only a student, for whom the duplicated rule gives the same output
Caught by staffRequestAboveTheStudentLimitIsDecidedByThePolicyNotTheConsole
Value objects
PeriodValue validation removed; LoanPeriod gains value equality
Both caught
Unchanged
Dependency direction
Application 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 bootstrap
All seven caught by the direction test
Unchanged
Dependency cycle
infrastructure.directory and infrastructure.notification refer to each other
Survived: each reference is individually permitted by the matrix
Caught by productionPackagesHaveNoDependencyCycles
Dependency evasion
A class name held in a string
Survived: jdeps reads compiled references, not strings
Survives: 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.
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.
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
Create the directory after and save every listing of the Chapter 10 companion at its path.
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.
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:
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:
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
Path
Status
Purpose
src/main/java/legacy/LoanRow.java
New
The old system’s export format, unchanged by the refactoring
Model 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 behaviour
Class
Register
Question for the owner
A loan within the grace period is listed with fine 0.00 and counted as overdue
Surprising
SD-11-2
Should 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.00
Expected
None
Staff fines start on day 6, grow by 0.25 a day and stop at 5.00
Expected
None
Any other kind (X, lower-case s, blank) pays 1.00 a day, with no grace and no cap
Suspected defect
SD-11-1
Do other kinds exist? If not, should they be rejected?
A loan due today, due later, or returned is not listed
Expected
None
A null kind throws NullPointerException, but only for a loan that is overdue and unreturned
Unknown
SD-11-3
Can the export contain a null kind?
Amounts use the default locale: 0,50 under Locale.GERMANY, 0.50 otherwise
Surprising
SD-11-4
Should the separator follow the machine or the office?
The report uses whichever date the caller passes, so the zone is the caller’s choice
Known design consequence of the seam
None
build(rows) uses the system date; print(rows) writes to standard output
Known hidden dependencies
None; 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 code
FinePolicy.fineFor(daysLate) with the same numbers
if (late > 2) not taken: fine stays 0
daysLate <= graceDays returns 0
fine = (late - 2) * 0.50; if (fine > 10) fine = 10;
Grace 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
packagelegacy;importjava.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.
*/publicclassLoanRow{publicStringitem;publicStringborrower;/** "S" for a student, "T" for a staff member, as in the old directory. */publicStringkind;publicLocalDatedue;publicbooleanreturned;publicLoanRow(Stringitem,Stringborrower,Stringkind,LocalDatedue,booleanreturned){this.item=item;this.borrower=borrower;this.kind=kind;this.due=due;this.returned=returned;}}
3.2 after/src/main/java/legacy/FinePolicy.java
packagelegacy;/** How a fine grows: free days, a daily rate, and the most one loan can cost. */recordFinePolicy(intgraceDays,doubleratePerDay,doublecap){doublefineFor(longdaysLate){if(daysLate<=graceDays){return0;}returnMath.min((daysLate-graceDays)*ratePerDay,cap);}}
3.3 after/src/main/java/legacy/OverdueReport.java
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Lists the unreturned loans that are past their due date, with the fine for each. */publicclassOverdueReport{privatestaticfinalFinePolicySTUDENT=newFinePolicy(2,0.50,10.00);privatestaticfinalFinePolicySTAFF=newFinePolicy(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).
*/privatestaticfinalFinePolicyANY_OTHER_KIND=newFinePolicy(0,1.00,Double.MAX_VALUE);publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){StringBuilderreport=newStringBuilder("OVERDUE REPORT "+today+"\n");doubletotalFines=0;intoverdueCount=0;for(LoanRowrow:rows){if(row.returned){continue;}longdaysLate=ChronoUnit.DAYS.between(row.due,today);if(daysLate>0){doublefine=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');returnreport.toString();}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}privatestaticFinePolicypolicyFor(Stringkind){if(kind.equals("S")){returnSTUDENT;}if(kind.equals("T")){returnSTAFF;}returnANY_OTHER_KIND;}privatestaticStringmoney(doubleamount){returnString.format("%.2f",amount);}}
packageedu.se506.lending.bootstrap;importjava.time.LocalDate;importjava.util.List;importlegacy.LoanRow;importlegacy.OverdueReport;/** Prints the legacy overdue report for a fixed date. The legacy package stays outside the lending layers. */publicfinalclassChapter11Demo{publicstaticvoidmain(String[]args){LocalDatetoday=LocalDate.of(2026,10,20);List<LoanRow>rows=List.of(newLoanRow("CAM1","B1","S",today.minusDays(10),false),newLoanRow("MIC1","B2","T",today.minusDays(8),false),newLoanRow("TRI1","B3","S",today.minusDays(1),true),newLoanRow("LAP1","B4","S",today.minusDays(2),false));System.out.print(OverdueReport.build(rows,today));}}
packageedu.se506.lending.bootstrap;importjava.util.List;importlegacy.LoanRow;/** One complete, non-null snapshot of rows for the legacy report. A failure must propagate. */@FunctionalInterfacepublicinterfaceOverdueRowSource{List<LoanRow>rows();}
packageedu.se506.lending.bootstrap;importjava.time.Clock;importjava.time.LocalDate;importjava.util.List;importjava.util.Objects;importlegacy.LoanRow;importlegacy.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.
*/publicfinalclassOverdueReportService{privatefinalOverdueRowSourcesource;privatefinalClockclock;publicOverdueReportService(OverdueRowSourcesource,Clockclock){this.source=Objects.requireNonNull(source,"source");this.clock=Objects.requireNonNull(clock,"clock");}publicStringreport(){LocalDatetoday=LocalDate.now(clock);List<LoanRow>rows=Objects.requireNonNull(source.rows(),"rows");returnOverdueReport.build(rows,today);}}
packageedu.se506.lending.bootstrap;importedu.se506.lending.domain.Loan;importedu.se506.lending.domain.Reservation;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.function.Function;importjava.util.function.Supplier;importlegacy.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).
*/publicfinalclassLoanSnapshotRowsimplementsOverdueRowSource{privatefinalSupplier<List<Loan>>loans;privatefinalSupplier<List<Reservation>>reservations;privatefinalFunction<String,String>kindOf;publicLoanSnapshotRows(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");}@OverridepublicList<LoanRow>rows(){List<Loan>loanSnapshot=List.copyOf(loans.get());List<Reservation>reservationSnapshot=List.copyOf(reservations.get());List<LoanRow>rows=newArrayList<>();for(Loanloan:loanSnapshot){Reservationreservation=reservationSnapshot.stream().filter(r->r.id().equals(loan.reservationId())).findFirst().orElseThrow(()->newIllegalStateException("Missing reservation for loan: "+loan.reservationId()));if(!reservation.borrowerId().equals(loan.borrowerId())||!reservation.itemId().equals(loan.itemId())){thrownewIllegalStateException("Mismatched loan: "+loan.reservationId());}Stringkind=kindOf.apply(loan.borrowerId());if(kind==null){thrownewIllegalStateException("No kind for borrower: "+loan.borrowerId());}rows.add(newLoanRow(loan.itemId(),loan.borrowerId(),kind,reservation.period().endExclusive().minusDays(1),!loan.active()));}returnList.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
importjava.nio.charset.StandardCharsets;importjava.nio.file.Path;importjava.util.Map;importjava.util.Set;importjava.util.TreeMap;importjava.util.TreeSet;importjava.util.regex.Matcher;importjava.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
*/publicfinalclassPackageMetrics{privatestaticfinalPatternEDGE=Pattern.compile("(?m)^\\s+([\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+\\S+\\s*$");publicstaticvoidmain(String[]args)throwsException{if(args.length!=1){System.err.println("usage: java tools/PackageMetrics.java <classes directory>");System.exit(2);}Stringexecutable=System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java";Stringjava=Path.of(System.getProperty("java.home"),"bin",executable).toString();Processprocess=newProcessBuilder(java,"--module","jdk.jdeps/com.sun.tools.jdeps.Main","-verbose:class","-filter:none",Path.of(args[0]).toAbsolutePath().toString()).redirectErrorStream(true).start();Stringoutput=newString(process.getInputStream().readAllBytes(),StandardCharsets.UTF_8);if(process.waitFor()!=0){System.err.println(output);System.exit(1);}Map<String,Set<String>>classesIn=newTreeMap<>();Map<String,Set<String>>dependersOutside=newTreeMap<>();// classes outside P that use PMap<String,Set<String>>dependersInside=newTreeMap<>();// classes in P that use outside classesMatcherm=EDGE.matcher(output);while(m.find()){Stringfrom=outer(m.group(1));Stringto=outer(m.group(2));if(!local(from))continue;classesIn.computeIfAbsent(pkg(from),k->newTreeSet<>()).add(from);if(!local(to))continue;classesIn.computeIfAbsent(pkg(to),k->newTreeSet<>()).add(to);if(!pkg(from).equals(pkg(to))){dependersOutside.computeIfAbsent(pkg(to),k->newTreeSet<>()).add(from);dependersInside.computeIfAbsent(pkg(from),k->newTreeSet<>()).add(from);}}System.out.printf("%-48s %7s %4s %4s %9s%n","package","classes","Ca","Ce","I");for(Stringp:classesIn.keySet()){intca=dependersOutside.getOrDefault(p,Set.of()).size();intce=dependersInside.getOrDefault(p,Set.of()).size();Stringinstability=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);}}privatestaticbooleanlocal(Stringtype){returntype.startsWith("edu.se506.")||type.startsWith("patterns.")||type.startsWith("behaviour.")||type.startsWith("examples.")||type.startsWith("legacy.");}privatestaticStringouter(Stringtype){intdollar=type.indexOf('$');returndollar<0?type:type.substring(0,dollar);}privatestaticStringpkg(Stringtype){returntype.substring(0,type.lastIndexOf('.'));}}
packagelegacy;import staticorg.junit.jupiter.api.Assertions.*;importjava.io.ByteArrayOutputStream;importjava.io.PrintStream;importjava.nio.charset.StandardCharsets;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneId;importjava.time.ZoneOffset;importjava.util.List;importjava.util.Locale;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.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".
*/classOverdueReportCharacterisationTest{privatestaticfinalLocalDateTODAY=LocalDate.of(2026,10,20);privatestaticfinalStringHEADER="OVERDUE REPORT 2026-10-20\n";privateLocalesavedLocale;@BeforeEachvoidpinTheLocale(){savedLocale=Locale.getDefault();Locale.setDefault(Locale.ROOT);}@AfterEachvoidrestoreTheLocale(){Locale.setDefault(savedLocale);}privatestaticLoanRowopen(Stringkind,intdaysLate){returnnewLoanRow("CAM1","B1",kind,TODAY.minusDays(daysLate),false);}privatestaticStringoneLoan(intdaysLate,Stringfine){returnHEADER+"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"})voidfinesForStudentsAndStaff(Stringkind,intdaysLate,Stringfine){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"})voidotherKindsPayTheFullRateWithNoGraceOrCap(Stringkind,intdaysLate,Stringfine){assertEquals(oneLoan(daysLate,fine),OverdueReport.build(List.of(open(kind,daysLate)),TODAY));}@TestvoidaBlankKindIsTreatedLikeAnyOtherKind(){assertEquals(oneLoan(2,"2.00"),OverdueReport.build(List.of(open("",2)),TODAY));}@TestvoidloansWithinGraceAreStillListedAndCounted(){Stringreport=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);}@TestvoidloansDueTodayDueLaterOrReturnedAreNotListed(){List<LoanRow>rows=List.of(open("S",0),newLoanRow("CAM1","B1","S",TODAY.plusDays(3),false),newLoanRow("CAM1","B1","S",TODAY.minusDays(30),true));assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(rows,TODAY));}@TestvoidaNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored(){List<LoanRow>rows=List.of(newLoanRow("CAM1","B1",null,TODAY.minusDays(30),true),newLoanRow("CAM1","B1",null,TODAY,false));assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(rows,TODAY));}@TestvoidanEmptyListGivesTheHeaderAndAZeroTotal(){assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(List.of(),TODAY));}@TestvoidrowsAreListedInInputOrderAndTheTotalAddsTheFines(){List<LoanRow>rows=List.of(newLoanRow("CAM1","B1","S",TODAY.minusDays(10),false),newLoanRow("MIC1","B2","T",TODAY.minusDays(8),false),newLoanRow("TRI1","B3","S",TODAY.minusDays(1),true),newLoanRow("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.@TestvoidamountsFollowTheDefaultLocale(){Locale.setDefault(Locale.GERMANY);assertEquals(oneLoan(3,"0,50"),OverdueReport.build(List.of(open("S",3)),TODAY));}@TestvoidaNullKindOrANullListFailsWithNullPointerException(){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.@TestvoidtheShortEntryPointUsesTheSystemDate(){LocalDatebefore=LocalDate.now();Stringreport=OverdueReport.build(List.of());LocalDateafter=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.@TestvoidtheCallerChoosesTheZoneByChoosingTheDate(){Instantinstant=Instant.parse("2026-10-21T22:30:00Z");LocalDateutcDate=LocalDate.now(Clock.fixed(instant,ZoneOffset.UTC));LocalDatebaghdadDate=LocalDate.now(Clock.fixed(instant,ZoneId.of("Asia/Baghdad")));List<LoanRow>rows=List.of(newLoanRow("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.@TestvoidprintWritesTheReportToStandardOutput(){PrintStreamsaved=System.out;ByteArrayOutputStreamcaptured=newByteArrayOutputStream();System.setOut(newPrintStream(captured,true,StandardCharsets.UTF_8));try{OverdueReport.print(List.of());}finally{System.setOut(saved);}Stringtext=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.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.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.
*/finalclassOriginalOverdueReport{publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){Stringout="";doubletotal=0;intcount=0;out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=0;if(r.kind.equals("S")){if(late>2){fine=(late-2)*0.50;if(fine>10){fine=10;}}}elseif(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";returnout;}publicstaticvoidprint(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.
packagelegacy;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Locale;importjava.util.Random;importorg.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.
*/classOverdueReportDifferentialTest{privatestaticfinalLocalDateTODAY=LocalDate.of(2026,10,20);privatestaticfinalString[]KINDS={"S","T","X","s",""};@TestvoidcurrentReportMatchesTheOriginalOnGeneratedInputs(){Localesaved=Locale.getDefault();Locale.setDefault(Locale.ROOT);try{Randomrandom=newRandom(11);for(intrun=0;run<3000;run++){List<LoanRow>rows=newArrayList<>();intsize=random.nextInt(7);for(inti=0;i<size;i++){Stringkind=KINDS[random.nextInt(KINDS.length)];LocalDatedue=TODAY.minusDays(random.nextInt(70)-5);rows.add(newLoanRow("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.
packageedu.se506.lending.structure;import staticorg.junit.jupiter.api.Assertions.*;importjava.nio.charset.StandardCharsets;importjava.nio.file.Path;importjava.util.ArrayDeque;importjava.util.ArrayList;importjava.util.Deque;importjava.util.HashSet;importjava.util.List;importjava.util.Map;importjava.util.Set;importjava.util.TreeMap;importjava.util.TreeSet;importjava.util.regex.Pattern;importorg.junit.jupiter.api.Test;/** Checks compiled local dependency direction; not reflection or semantic correctness. */classDependencyRulesTest{privatestaticfinalStringBASE="edu.se506.lending.";privatestaticfinalPatternEDGE=Pattern.compile("(?m)^\\s*(edu\\.se506\\.lending\\.[\\w.$]+)\\s+->\\s+([\\w.$]+)\\s+.*$");@TestvoidcompiledProductionDependenciesFollowDeclaredDirection()throwsException{Stringoutput=jdepsOutput();varmatcher=EDGE.matcher(output);intobserved=0;List<String>violations=newArrayList<>();while(matcher.find()){observed++;Stringfrom=matcher.group(1);Stringto=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);}elseif(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));}@TestvoidproductionPackagesHaveNoDependencyCycles()throwsException{Map<String,Set<String>>graph=newTreeMap<>();varmatcher=EDGE.matcher(jdepsOutput());while(matcher.find()){Stringfrom=packageOf(matcher.group(1));Stringto=matcher.group(2).startsWith(BASE)?packageOf(matcher.group(2)):null;graph.computeIfAbsent(from,key->newTreeSet<>());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));}privatestaticStringjdepsOutput()throwsException{Stringexecutable=System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java";Stringjava=Path.of(System.getProperty("java.home"),"bin",executable).toString();Processprocess=newProcessBuilder(java,"--module","jdk.jdeps/com.sun.tools.jdeps.Main","-verbose:class","-filter:none",Path.of("target/classes").toAbsolutePath().toString()).redirectErrorStream(true).start();Stringoutput=newString(process.getInputStream().readAllBytes(),StandardCharsets.UTF_8);assertEquals(0,process.waitFor(),output);returnoutput;}privatestaticStringpackageOf(Stringtype){Stringouter=type.contains("$")?type.substring(0,type.indexOf('$')):type;returnouter.substring(0,outer.lastIndexOf('.'));}/** Depth-first search; returns the packages on one cycle, or an empty list. */privatestaticList<String>findCycle(Map<String,Set<String>>graph){Set<String>done=newHashSet<>();for(Stringstart:graph.keySet()){Deque<String>path=newArrayDeque<>();List<String>found=visit(start,graph,path,done);if(!found.isEmpty())returnfound;}returnList.of();}privatestaticList<String>visit(Stringnode,Map<String,Set<String>>graph,Deque<String>path,Set<String>done){if(path.contains(node)){List<String>cycle=newArrayList<>();booleaninCycle=false;for(Stringelement:(Iterable<String>)path::descendingIterator){if(element.equals(node))inCycle=true;if(inCycle)cycle.add(element);}cycle.add(node);returncycle;}if(!done.add(node))returnList.of();path.push(node);for(Stringnext:graph.getOrDefault(node,Set.of())){List<String>found=visit(next,graph,path,done);if(!found.isEmpty())returnfound;}path.pop();returnList.of();}privatebooleanallowed(Stringfrom,Stringto){if(from.startsWith(BASE+"domain."))returnto.startsWith(BASE+"domain.");if(from.startsWith(BASE+"application.port.")){returnto.startsWith(BASE+"application.port.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"application.")){returnto.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"infrastructure.")){returnto.startsWith(BASE+"infrastructure.")||to.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}if(from.startsWith(BASE+"presentation.")){returnto.startsWith(BASE+"presentation.")||to.startsWith(BASE+"application.")||to.startsWith(BASE+"domain.");}returnfalse;}}
packageedu.se506.lending.bootstrap;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.LoanService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.domain.Loan;importedu.se506.lending.domain.LoanPeriod;importedu.se506.lending.domain.Reservation;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneId;importjava.time.ZoneOffset;importjava.util.List;importjava.util.Locale;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Test;importlegacy.LoanRow;/** Tests the optional bridge that feeds the legacy report from the live lending data. */classOverdueBridgeTest{privatestaticfinalClockUTC_EVENING=Clock.fixed(Instant.parse("2026-10-21T22:30:00Z"),ZoneOffset.UTC);privatestaticfinalLoanRowDUE_ON_THE_21ST=newLoanRow("CAM1","B1","S",LocalDate.of(2026,10,21),false);privateLocalesavedLocale;@BeforeEachvoidpinTheLocale(){savedLocale=Locale.getDefault();Locale.setDefault(Locale.ROOT);}@AfterEachvoidrestoreTheLocale(){Locale.setDefault(savedLocale);}@TestvoidtheClockZoneDecidesWhichDateTheReportUses(){varutc=newOverdueReportService(()->List.of(DUE_ON_THE_21ST),UTC_EVENING);varbaghdad=newOverdueReportService(()->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());}@TestvoideachReportReadsOneFreshSnapshot(){AtomicIntegerreads=newAtomicInteger();OverdueRowSourcesource=()->reads.incrementAndGet()==1?List.of(DUE_ON_THE_21ST):List.of();varservice=newOverdueReportService(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());}@TestvoidaSourceFailureIsNotAnEmptySuccessfulReport(){IllegalStateExceptionfailure=newIllegalStateException("Snapshot unavailable");varservice=newOverdueReportService(()->{throwfailure;},UTC_EVENING);assertSame(failure,assertThrows(IllegalStateException.class,service::report));}@TestvoidaNullSnapshotIsNotTreatedAsEmpty(){varservice=newOverdueReportService(()->null,UTC_EVENING);assertThrows(NullPointerException.class,service::report);}@TestvoiddependenciesMustBeSupplied(){assertThrows(NullPointerException.class,()->newOverdueReportService(null,UTC_EVENING));assertThrows(NullPointerException.class,()->newOverdueReportService(List::of,null));assertThrows(NullPointerException.class,()->newLoanSnapshotRows(null,List::of,id->"S"));assertThrows(NullPointerException.class,()->newLoanSnapshotRows(List::of,null,id->"S"));assertThrows(NullPointerException.class,()->newLoanSnapshotRows(List::of,List::of,null));}@TestvoidtheLastPermittedDayIsTheDayBeforeTheExclusiveEnd(){varstart=LocalDate.of(2026,10,20);varreservation=newReservation("R1","B1","CAM1",newLoanPeriod(start,start.plusDays(2)));varactive=newLoan("R1","B1","CAM1",start,null);varreturned=newLoan("R1","B1","CAM1",start,start.plusDays(1));varopenRow=newLoanSnapshotRows(()->List.of(active),()->List.of(reservation),id->"S").rows().get(0);varclosedRow=newLoanSnapshotRows(()->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);}@TestvoidaLoanWithoutAMatchingReservationIsAnError(){varstart=LocalDate.of(2026,10,20);varloan=newLoan("R9","B1","CAM1",start,null);varrows=newLoanSnapshotRows(()->List.of(loan),List::of,id->"S");assertThrows(IllegalStateException.class,rows::rows);}@TestvoidaLoanThatDisagreesWithItsReservationIsAnError(){varstart=LocalDate.of(2026,10,20);varreservation=newReservation("R1","B1","CAM1",newLoanPeriod(start,start.plusDays(2)));varotherBorrower=newLoan("R1","B2","CAM1",start,null);varotherItem=newLoan("R1","B1","MIC1",start,null);assertThrows(IllegalStateException.class,()->newLoanSnapshotRows(()->List.of(otherBorrower),()->List.of(reservation),id->"S").rows());assertThrows(IllegalStateException.class,()->newLoanSnapshotRows(()->List.of(otherItem),()->List.of(reservation),id->"S").rows());}@TestvoidaBorrowerWithoutAKindIsAnErrorNotARowWithANullKind(){varstart=LocalDate.of(2026,10,20);varreservation=newReservation("R1","B1","CAM1",newLoanPeriod(start,start.plusDays(2)));varloan=newLoan("R1","B1","CAM1",start,null);varrows=newLoanSnapshotRows(()->List.of(loan),()->List.of(reservation),id->null);assertThrows(IllegalStateException.class,rows::rows);}@TestvoidtheReportFollowsARealCheckoutAndReturn(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);ClockreportDay=Clock.fixed(Instant.parse("2026-10-24T09:00:00Z"),ZoneOffset.UTC);ReservationCoordinatorcoordinator=ReservationApplication.create(newRecordingNotificationSender());LoanServiceloans=newLoanService(coordinator,checkoutDay);assertTrue(coordinator.reserve("B1","CAM1",LocalDate.of(2026,10,20),2).startsWith("Confirmed: R1"));loans.checkOut("R1");varrows=newLoanSnapshotRows(loans::loans,coordinator::reservations,id->"S");varservice=newOverdueReportService(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());}}
# 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.
# 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.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */publicclassOverdueReport{publicstaticStringbuild(List<LoanRow>rows){Stringout="";doubletotal=0;intcount=0;LocalDatetoday=LocalDate.now();out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=0;if(r.kind.equals("S")){if(late>2){fine=(late-2)*0.50;if(fine>10){fine=10;}}}elseif(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";returnout;}publicstaticvoidprint(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.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */publicclassOverdueReport{publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){Stringout="";doubletotal=0;intcount=0;out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=0;if(r.kind.equals("S")){if(late>2){fine=(late-2)*0.50;if(fine>10){fine=10;}}}elseif(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";returnout;}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}}
6.3 after/stages/stage2/OverdueReport.java
Extract Method: fineFor and money.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */publicclassOverdueReport{publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){Stringout="";doubletotal=0;intcount=0;out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=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";returnout;}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}privatestaticdoublefineFor(Stringkind,longlate){doublefine=0;if(kind.equals("S")){if(late>2){fine=(late-2)*0.50;if(fine>10){fine=10;}}}elseif(kind.equals("T")){if(late>5){fine=(late-5)*0.25;if(fine>5){fine=5;}}}else{fine=1.00*late;}returnfine;}privatestaticStringmoney(doubleamount){returnString.format("%.2f",amount);}}
6.4 after/stages/stage3/OverdueReport.java
Extract Class and Move Method: FinePolicy.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Overdue report as found in the old records system. Deliberately untidy; do not copy its style. */publicclassOverdueReport{privatestaticfinalFinePolicySTUDENT=newFinePolicy(2,0.50,10.00);privatestaticfinalFinePolicySTAFF=newFinePolicy(5,0.25,5.00);/** Suspected defect, preserved on purpose: any other kind pays 1.00 a day with no grace and no cap. */privatestaticfinalFinePolicyANY_OTHER_KIND=newFinePolicy(0,1.00,Double.MAX_VALUE);publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){Stringout="";doubletotal=0;intcount=0;out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=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";returnout;}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}privatestaticFinePolicypolicyFor(Stringkind){if(kind.equals("S")){returnSTUDENT;}if(kind.equals("T")){returnSTAFF;}returnANY_OTHER_KIND;}privatestaticStringmoney(doubleamount){returnString.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
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.util.List;/** Lists the unreturned loans that are past their due date, with the fine for each. */publicclassOverdueReport{privatestaticfinalFinePolicySTUDENT=newFinePolicy(2,0.50,10.00);privatestaticfinalFinePolicySTAFF=newFinePolicy(5,0.25,5.00);publicstaticStringbuild(List<LoanRow>rows){returnbuild(rows,LocalDate.now());}publicstaticStringbuild(List<LoanRow>rows,LocalDatetoday){StringBuilderreport=newStringBuilder("OVERDUE REPORT "+today+"\n");doubletotalFines=0;intoverdueCount=0;for(LoanRowrow:rows){if(row.returned){continue;}longdaysLate=ChronoUnit.DAYS.between(row.due,today);if(daysLate>0){doublefine=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');returnreport.toString();}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}privatestaticFinePolicypolicyFor(Stringkind){if(kind.equals("S")){returnSTUDENT;}if(kind.equals("T")){returnSTAFF;}thrownewIllegalArgumentException("Unknown borrower kind: "+kind);}privatestaticStringmoney(doubleamount){returnString.format("%.2f",amount);}}
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.
packagelegacy;import staticorg.junit.jupiter.api.Assertions.*;importjava.io.ByteArrayOutputStream;importjava.io.PrintStream;importjava.nio.charset.StandardCharsets;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneId;importjava.time.ZoneOffset;importjava.util.List;importjava.util.Locale;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;importorg.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".
*/classOverdueReportCharacterisationTest{privatestaticfinalLocalDateTODAY=LocalDate.of(2026,10,20);privatestaticfinalStringHEADER="OVERDUE REPORT 2026-10-20\n";privateLocalesavedLocale;@BeforeEachvoidpinTheLocale(){savedLocale=Locale.getDefault();Locale.setDefault(Locale.ROOT);}@AfterEachvoidrestoreTheLocale(){Locale.setDefault(savedLocale);}privatestaticLoanRowopen(Stringkind,intdaysLate){returnnewLoanRow("CAM1","B1",kind,TODAY.minusDays(daysLate),false);}privatestaticStringoneLoan(intdaysLate,Stringfine){returnHEADER+"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"})voidfinesForStudentsAndStaff(Stringkind,intdaysLate,Stringfine){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",""})voidunknownKindsAreRejected(Stringkind){IllegalArgumentExceptionfailure=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.@TestvoidanUnknownKindOnAReturnedLoanIsStillIgnored(){List<LoanRow>rows=List.of(newLoanRow("CAM1","B1","X",TODAY.minusDays(30),true));assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(rows,TODAY));}@TestvoidloansWithinGraceAreStillListedAndCounted(){Stringreport=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);}@TestvoidloansDueTodayDueLaterOrReturnedAreNotListed(){List<LoanRow>rows=List.of(open("S",0),newLoanRow("CAM1","B1","S",TODAY.plusDays(3),false),newLoanRow("CAM1","B1","S",TODAY.minusDays(30),true));assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(rows,TODAY));}@TestvoidaNullKindOnALoanThatIsReturnedOrNotYetDueIsIgnored(){List<LoanRow>rows=List.of(newLoanRow("CAM1","B1",null,TODAY.minusDays(30),true),newLoanRow("CAM1","B1",null,TODAY,false));assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(rows,TODAY));}@TestvoidanEmptyListGivesTheHeaderAndAZeroTotal(){assertEquals(HEADER+"0 overdue, total fines 0.00\n",OverdueReport.build(List.of(),TODAY));}@TestvoidrowsAreListedInInputOrderAndTheTotalAddsTheFines(){List<LoanRow>rows=List.of(newLoanRow("CAM1","B1","S",TODAY.minusDays(10),false),newLoanRow("MIC1","B2","T",TODAY.minusDays(8),false),newLoanRow("TRI1","B3","S",TODAY.minusDays(1),true),newLoanRow("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.@TestvoidamountsFollowTheDefaultLocale(){Locale.setDefault(Locale.GERMANY);assertEquals(oneLoan(3,"0,50"),OverdueReport.build(List.of(open("S",3)),TODAY));}@TestvoidaNullKindOrANullListFailsWithNullPointerException(){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.@TestvoidtheShortEntryPointUsesTheSystemDate(){LocalDatebefore=LocalDate.now();Stringreport=OverdueReport.build(List.of());LocalDateafter=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.@TestvoidtheCallerChoosesTheZoneByChoosingTheDate(){Instantinstant=Instant.parse("2026-10-21T22:30:00Z");LocalDateutcDate=LocalDate.now(Clock.fixed(instant,ZoneOffset.UTC));LocalDatebaghdadDate=LocalDate.now(Clock.fixed(instant,ZoneId.of("Asia/Baghdad")));List<LoanRow>rows=List.of(newLoanRow("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.@TestvoidprintWritesTheReportToStandardOutput(){PrintStreamsaved=System.out;ByteArrayOutputStreamcaptured=newByteArrayOutputStream();System.setOut(newPrintStream(captured,true,StandardCharsets.UTF_8));try{OverdueReport.print(List.of());}finally{System.setOut(saved);}Stringtext=captured.toString(StandardCharsets.UTF_8);assertTrue(text.startsWith("OVERDUE REPORT ")&&text.endsWith("0 overdue, total fines 0.00\n"),text);}}
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.
packagelegacy;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Locale;importjava.util.Random;importorg.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.
*/classOverdueReportDifferentialTest{privatestaticfinalLocalDateTODAY=LocalDate.of(2026,10,20);privatestaticfinalString[]KINDS={"S","T"};@TestvoidcurrentReportMatchesTheOriginalOnGeneratedInputs(){Localesaved=Locale.getDefault();Locale.setDefault(Locale.ROOT);try{Randomrandom=newRandom(11);for(intrun=0;run<3000;run++){List<LoanRow>rows=newArrayList<>();intsize=random.nextInt(7);for(inti=0;i<size;i++){Stringkind=KINDS[random.nextInt(KINDS.length)];LocalDatedue=TODAY.minusDays(random.nextInt(70)-5);rows.add(newLoanRow("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.
packagelegacy;importjava.time.LocalDate;importjava.time.temporal.ChronoUnit;importjava.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. */publicfinalclassOriginalUnseamed{publicstaticStringbuild(List<LoanRow>rows){Stringout="";doubletotal=0;intcount=0;LocalDatetoday=LocalDate.now();out+="OVERDUE REPORT "+today+"\n";for(inti=0;i<rows.size();i++){LoanRowr=rows.get(i);if(r.returned){continue;}longlate=ChronoUnit.DAYS.between(r.due,today);if(late>0){doublefine=0;if(r.kind.equals("S")){if(late>2){fine=(late-2)*0.50;if(fine>10){fine=10;}}}elseif(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";returnout;}publicstaticvoidprint(List<LoanRow>rows){System.out.print(build(rows));}}
6.7.2 after/probe/OriginalReportProbe.java
packagelegacy;importjava.time.LocalDate;importjava.util.List;importjava.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.
*/publicfinalclassOriginalReportProbe{publicstaticvoidmain(String[]args){Locale.setDefault(Locale.ROOT);LocalDatebefore=LocalDate.now();List<LoanRow>rows=List.of(newLoanRow("CAM1","B1","S",before.minusDays(10),false),newLoanRow("MIC1","B2","T",before.minusDays(8),false),newLoanRow("TRI1","B3","S",before,false),newLoanRow("LAP1","B4","X",before.minusDays(1),false),newLoanRow("PRJ1","B5","S",before.minusDays(40),true));Stringoriginal=OriginalUnseamed.build(rows);Stringseamed=OverdueReport.build(rows);Stringempty=OriginalUnseamed.build(List.of());StringemptySeamed=OverdueReport.build(List.of());if(!LocalDate.now().equals(before)){thrownewIllegalStateException("The date changed during the probe; run it again.");}if(!original.equals(seamed)||!empty.equals(emptySeamed)){thrownewAssertionError("The seamed report differs from the original:\n"+original+"---\n"+seamed);}StringexpectedFirstLine="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")){thrownewAssertionError("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
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.
Classify each result (Section 2.2) and write the test with a comment for each surprising or suspected case.
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.
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
#
Message
Files
Check run
1
Add a date parameter to OverdueReport.build (seam)
OverdueReport.java
Compiles; the probe agrees with the original
2
Add characterisation and differential tests for OverdueReport
Three test files
All pass on commit 1
3
Extract fineFor and money from OverdueReport.build
OverdueReport.java
28 invocations pass
4
Extract FinePolicy; keep the unknown-kind policy visible
OverdueReport.java, FinePolicy.java
28 invocations pass
5
Rename variables and use StringBuilder in OverdueReport
OverdueReport.java
28 invocations pass
6
Reject unknown borrower kinds (functional change)
Stage C files
159 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:
Input
Observed
Class
Two rows for the same item, both overdue
Two lines, listed in input order, fines added
Expected
A student exactly 22 days late, then 23
10.00 for both; the cap is reached at 22
Expected (a boundary worth pinning)
A row with a blank item name
A line that starts with a space
Surprising: 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.
Claim
Finding
Evidence (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 it
5 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 ignored
1 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 decision
5 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 behaviour
9 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.privatestaticFinePolicypolicyFor(Stringkind){if(kind.equals("S"))returnSTUDENT;if(kind.equals("T"))returnSTAFF;thrownewIllegalArgumentException("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).
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.publicfinalclassOverdueReport{privatefinalClockclock;publicOverdueReport(Clockclock){this.clock=clock;}publicStringbuild(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
@TestvoidamountsFollowTheDefaultLocale(){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.
Area
Faults tried
Result with the final suite
Student rules
Grace 2 to 3; rate 0.50 to 0.60; cap 10 to 9
All three caught
Staff rules
Grace 5 to 4; rate 0.25 to 0.30; cap 5 to 6
All three caught
Other kinds
One grace day; a cap of 10; lower-case s accepted as a student
All three caught
Policy arithmetic
Math.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 rules
Due today counted as overdue; returned loans not skipped; overdue count off by two; total fines not accumulated
All four caught
Zero-fine patches
Zero-fine loans skipped; fine computed first and zero-fine loans skipped
Both caught
The AI claims
The overdue test written as !isAfter; the policy looked up before the overdue check; unknown kinds treated as students; all three together
All four caught (Section 9 lists the failing tests)
Hidden dependencies
Amounts always formatted with a point; header shows the system date; print writes to standard error; short entry point uses yesterday
All four caught
Date arithmetic
Dates subtracted the wrong way round; days late from Period.getDays()
Both caught
Bridge
The due day is the exclusive end; the returned flag inverted; a mismatched loan not detected; a null kind passed through
All four caught
Service
A 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 reports
All 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.
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 method
Pinned choice
Calculator
FlowMetrics v1.0, complete source in this file
Repository method
RM-12 v1.0, defined in Section 4 below
Java
Full JDK 17; locally checked with 17.0.20
Build/test
Plain JDK compilation and explicit Java checks; no new dependencies
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.
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.
Field
Meaning
Missing value
id
Unique work-item ID within this input
Invalid
points
Nonnegative integer estimate held fixed in this example
Blank means unknown, not zero
accepted
Timestamp admitted to tracked scope
Invalid
started
Timestamp at the chosen start boundary
Blank means not started
done
Timestamp at the chosen finish boundary
Blank means not completed
removed
Explicit cancellation/removal timestamp
Blank 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
ID
Lead calculation
Lead days
Cycle calculation
Cycle days
A
03 â 01
2
03 â 01
2
B
06 â 01
5
06 â 02
4
C
04 â 01
3
04 â 03
1
D
08 â 01
7
08 â 05
3
G
10 â 04
6
10 â 06
4
Total
23
14
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.
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
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.
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.
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.
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.
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
Field
Your 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
ID
Book contract
CR12-1
Add 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-2
Visitors 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-3
Reuse 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-4
A 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-5
Capture 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-6
A missing match, DENIED status or an out-of-window record is an expected rejection. It creates no reservation and sends no confirmation.
CR12-7
An 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-8
Once 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-9
The 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-10
Approval 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:
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.
Example
Input difference
Expected observation
AC1
CAM1, two days, matching approved record valid at request time
One new reservation; existing confirmation format; one notice
AC2
CAM2, three days with its own matching record
Allowed; upper duration boundary preserved
AC3
Four days, otherwise matching approval
Reject; no state or notice change
AC4
Zero or negative days
Reject; no state or notice change
AC5
Item MIC1 with matching approval
Reject because item is outside visitor allowlist
AC6
Missing approval or DENIED record
Expected rejection; no state or notice change
AC7
Approval belongs to V2, CAM2 or a different end date
Does not authorise the V1/CAM1/requested-period tuple
AC8
now == validFrom
Valid if all other conditions hold
AC9
now == validUntil, or before validFrom
Rejected as invalid at request time
AC10
Unreadable file, malformed row or duplicate matching key
Distinct operational failure; no state or notice change
AC11
Three retained reservations already exist
Reject count limit even if loans have been returned
AC12
Visitor feature disabled
Explicit disabled result; approval source not called
AC13
Existing student/staff request, either flag setting
Existing result and notification behaviour; approval source not called
AC14
Visitor’s approved item overlaps an existing reservation
Existing availability rejection; no extra reservation or notice
AC15
Valid visitor succeeds but expected notification failure occurs
Visitor reservation created, then approval expires or flag turns off
Existing checkout/return flow remains available
AC17
Unknown directory code or inactive visitor
Existing unknown/inactive semantics; never default to student
AC18
Report generated after a visitor loan exists
Existing 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.
Add recognised visitor path, policies and explicit wiring
C12-1
Valid visitor with controlled approval source; old categories still behave correctly
C12-3
Implement and test actual approval-file adapter
C12-1 data contract; connect after C12-2
Temporary-file success, malformed/missing/duplicate failures; no mock replacing the reader under test
C12-4
Complete matching/time/failure and no-mutation protection
C12-2, C12-3
AC boundary and operational-failure evidence
C12-5
Introduce one flag and integrate CLI/demo
C12-2â4
Both settings; old path; existing reservations usable after disable
C12-6
Run full regression, real boundary checks and delivery workflow
C12-2â5
Observed 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 claim
Judgement and correction
Six items / 18 points delivered
Contradicted: F is removed, not Done. Five items and 16 points completed.
Mean cycle is two after subtracting blocking
Contradicted: defined mean is 2.8. Subtracting B’s two days would give 2.4, and would change the measure.
Day 04 rise proves productivity declined
Not established: G adds three points while C completes two. The remaining line rises by one.
Day 08 fall proves five points delivered
Contradicted: D completes three; F removes two.
Legacy architecture caused waiting
Not established: one mapping clarification is recorded; broad causality needs more evidence.
Remaining work definitely finishes in 0.44 iterations
Unsupported forecast: the division is approximately right, but comparability, capacity and uncertainty are missing.
Rank members by commits
Reject 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
importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Duration;importjava.time.Instant;importjava.util.ArrayList;importjava.util.HashSet;importjava.util.List;importjava.util.Locale;importjava.util.Objects;/** Chapter 12 teaching tool v1.0. TSV input, UTC instants, elapsed 24-hour days. */publicfinalclassFlowMetrics{publicrecordItem(Stringid,Integerpoints,Instantaccepted,Instantstarted,Instantdone,Instantremoved){publicItem{if(id==null||id.isBlank())thrownewIllegalArgumentException("Blank id");if(points!=null&&points<0)thrownewIllegalArgumentException("Negative points");Objects.requireNonNull(accepted,"accepted");if(started!=null&&started.isBefore(accepted))thrownewIllegalArgumentException("Start before acceptance: "+id);if(done!=null&&(started==null||done.isBefore(started)))thrownewIllegalArgumentException("Invalid completion: "+id);if(removed!=null&&(done!=null||removed.isBefore(accepted)||(started!=null&&removed.isBefore(started))))thrownewIllegalArgumentException("Invalid removal: "+id);}}publicrecordSummary(intcompleted,StringcompletedPoints,doublemeanLeadDays,doublemeanCycleDays,doublemedianCycleDays,intwip){}privateFlowMetrics(){}publicstaticList<Item>read(Pathpath)throwsException{varlines=Files.readAllLines(path);Stringheader="id\tpoints\taccepted\tstarted\tdone\tremoved";if(lines.isEmpty()||!lines.get(0).equals(header))thrownewIllegalArgumentException("Expected exact TSV header");List<Item>result=newArrayList<>();varids=newHashSet<String>();for(intline=1;line<lines.size();line++){if(lines.get(line).isBlank())continue;String[]f=lines.get(line).split("\t",-1);if(f.length!=6)thrownewIllegalArgumentException("Six fields required at line "+(line+1));if(!ids.add(f[0]))thrownewIllegalArgumentException("Duplicate id: "+f[0]);result.add(newItem(f[0],f[1].isEmpty()?null:Integer.valueOf(f[1]),parse(f[2]),parse(f[3]),parse(f[4]),parse(f[5])));}returnList.copyOf(result);}privatestaticInstantparse(Stringtext){returntext.isEmpty()?null:Instant.parse(text);}publicstaticdoubledays(Instantstart,Instantend){Durationd=Duration.between(start,end);returnd.getSeconds()/86400.0+d.getNano()/86400_000_000_000.0;}publicstaticList<Item>completed(List<Item>items,Instantfrom,Instantuntil){if(!from.isBefore(until))thrownewIllegalArgumentException("Empty or reversed window");returnitems.stream().filter(i->i.done()!=null&&!i.done().isBefore(from)&&i.done().isBefore(until)).toList();}publicstaticbooleaninProgress(Itemi,Instantat){returni.started()!=null&&!i.started().isAfter(at)&&(i.done()==null||i.done().isAfter(at))&&(i.removed()==null||i.removed().isAfter(at));}publicstaticSummarysummarize(List<Item>items,Instantfrom,Instantuntil){List<Item>cohort=completed(items,from,until);double[]cycles=cohort.stream().mapToDouble(i->days(i.started(),i.done())).sorted().toArray();doublelead=cohort.stream().mapToDouble(i->days(i.accepted(),i.done())).average().orElse(Double.NaN);doublecycle=java.util.Arrays.stream(cycles).average().orElse(Double.NaN);doublemedian=cycles.length==0?Double.NaN:cycles.length%2==1?cycles[cycles.length/2]:(cycles[cycles.length/2-1]+cycles[cycles.length/2])/2;Stringpoints=cohort.stream().anyMatch(i->i.points()==null)?"NA":Long.toString(cohort.stream().mapToLong(i->i.points()).sum());returnnewSummary(cohort.size(),points,lead,cycle,median,(int)items.stream().filter(i->inProgress(i,until)).count());}publicstaticlongscopePoints(List<Item>items,Instantat){returnitems.stream().filter(i->!i.accepted().isAfter(at)&&(i.removed()==null||i.removed().isAfter(at))).mapToLong(i->{if(i.points()==null)thrownewIllegalArgumentException("Scope point missing: "+i.id());returni.points();}).sum();}publicstaticlongdonePointsAt(List<Item>items,Instantat){returnitems.stream().filter(i->i.done()!=null&&!i.done().isAfter(at)).mapToLong(i->{if(i.points()==null)thrownewIllegalArgumentException("Done point missing: "+i.id());returni.points();}).sum();}privatestaticStringnumber(doublen){returnDouble.isNaN(n)?"NA":String.format(Locale.ROOT,"%.2f",n);}publicstaticvoidmain(String[]args)throwsException{if(args.length!=3&&args.length!=4)thrownewIllegalArgumentException("Usage: FlowMetrics file.tsv fromInstant untilInstant [--scope]");if(args.length==4&&!args[3].equals("--scope"))thrownewIllegalArgumentException("Unknown option");List<Item>items=read(Path.of(args[0]));Instantfrom=Instant.parse(args[1]);Instantuntil=Instant.parse(args[2]);Summarys=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(Itemi: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(Instantat=from;!at.isAfter(until);at=at.plus(Duration.ofDays(1))){longscope=scopePoints(items,at);longdone=donePointsAt(items,at);Instantsample=at;longwip=items.stream().filter(i->inProgress(i,sample)).count();System.out.println(at+","+scope+","+done+","+(scope-done)+","+wip);}}}}
File: lab/FlowMetricsChecks.java
importjava.nio.file.Path;importjava.time.Instant;importjava.util.List;/** Plain Java checks throw explicitly; -ea is not required. */publicfinalclassFlowMetricsChecks{privatestaticintchecks;privatestaticInstantday(intn){returnInstant.parse("2026-10-%02dT00:00:00Z".formatted(n));}privatestaticvoidcheck(booleanvalue,Stringdescription){checks++;if(!value)thrownewAssertionError(description);}privatestaticvoidinvalid(Runnableoperation,Stringdescription){checks++;try{operation.run();}catch(IllegalArgumentExceptionexpected){return;}thrownewAssertionError(description);}publicstaticvoidmain(String[]args)throwsException{vardata=FlowMetrics.read(Path.of("issues.tsv"));vars=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");varempty=FlowMetrics.summarize(List.of(),day(1),day(2));check(empty.completed()==0&&Double.isNaN(empty.meanCycleDays()),"No completions is not zero duration");varunknown=newFlowMetrics.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(()->newFlowMetrics.Item("X",1,day(2),day(1),null,null),"Invalid ordering rejected");invalid(()->newFlowMetrics.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
Create the directory after. Save every listing of the Chapter 10 companion at its path.
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.
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.
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:
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 checksmvn --batch-mode test -Dgroups=integration # stage 3mvn --batch-mode test -Dgroups=e2e # stage 4mvn --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.
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.
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
Situation
Result
Kind
Flag off
Rejected: visitor reservations are not enabled.; the register is not read
Expected rejection
Item not CAM1 or CAM2
Rejected; the register is not read
Expected rejection
Fewer than one or more than three days, or three reservations already retained
Rejected by the existing policy and count rules
Expected rejection
No record, a denied record, or a record for a different borrower, item or period
Rejected: no valid sponsor approval.
Expected rejection
Record valid at the request instant (validFrom inclusive, validUntil exclusive)
Continues to the existing availability, reservation and notification steps
Success
Register missing, unreadable, malformed or with a duplicate key
ApprovalSourceException; nothing is reserved or sent
Operational failure
Directory unreachable, slow or nonsensical
DirectoryException; nothing is reserved or sent
Operational 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.
packageedu.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.
*/publicfinalclassVisitorReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<3&&requestedDays>=1&&requestedDays<=3;}}
packageedu.se506.lending.domain;/** A sponsor's decision on one request. Any other word in the register is a data error. */publicenumApprovalStatus{APPROVED,DENIED}
packageedu.se506.lending.domain;importjava.time.Instant;importjava.time.LocalDate;importjava.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.
*/publicrecordApprovalRecord(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive,StringsponsorId,ApprovalStatusstatus,InstantvalidFrom,InstantvalidUntil){publicApprovalRecord{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)){thrownewIllegalArgumentException("Loan period must end after it starts.");}if(!validFrom.isBefore(validUntil)){thrownewIllegalArgumentException("Validity must end after it starts.");}}/** True only for the exact borrower, item and period; approval for anything else is not enough. */publicbooleanmatches(Stringborrower,Stringitem,LocalDateperiodStart,LocalDateperiodEnd){returnborrowerId.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. */publicbooleanvalidAt(Instantnow){Objects.requireNonNull(now,"now");return!now.isBefore(validFrom)&&now.isBefore(validUntil);}publicbooleanapprovesAt(Instantnow){returnstatus==ApprovalStatus.APPROVED&&validAt(now);}privatestaticvoidrequireText(Stringvalue,Stringname){if(value==null||value.isBlank()){thrownewIllegalArgumentException(name+" must not be blank.");}}}
packageedu.se506.lending.application.port;importedu.se506.lending.domain.ApprovalRecord;importjava.time.LocalDate;importjava.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.
*/@FunctionalInterfacepublicinterfaceApprovalSource{Optional<ApprovalRecord>find(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive);}
packageedu.se506.lending.application.port;/** The approval register could not be read or trusted. This is not a sponsor's rejection. */publicfinalclassApprovalSourceExceptionextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicApprovalSourceException(Stringmessage){super(message);}publicApprovalSourceException(Stringmessage,Throwablecause){super(message,cause);}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.domain.ApprovalRecord;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.util.Objects;importjava.util.Optional;importjava.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.
*/publicfinalclassVisitorGate{publicstaticfinalStringDISABLED="Rejected: visitor reservations are not enabled.";publicstaticfinalStringITEM_NOT_ALLOWED="Rejected: item not available to visitors.";publicstaticfinalStringNOT_APPROVED="Rejected: no valid sponsor approval.";privatestaticfinalSet<String>ALLOWED_ITEMS=Set.of("CAM1","CAM2");privatefinalApprovalSourcesource;privatefinalClockclock;privatefinalbooleanenabled;publicVisitorGate(ApprovalSourcesource,Clockclock,booleanenabled){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. */publicstaticVisitorGatedisabled(){returnnewVisitorGate((borrowerId,itemId,start,end)->{thrownewIllegalStateException("The approval source must not be read while visitors are disabled.");},Clock.systemUTC(),false);}publicOptional<String>rejection(StringborrowerId,StringitemId,LocalDatestart,intdays){if(!enabled){returnOptional.of(DISABLED);}if(!ALLOWED_ITEMS.contains(itemId)){returnOptional.of(ITEM_NOT_ALLOWED);}if(days<1){returnOptional.empty();// there is no period to look up; the policy rejects it}Instantnow=clock.instant();// one request-time instant for the whole decisionLocalDateendExclusive=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)){returnOptional.of(NOT_APPROVED);}returnOptional.empty();}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.BorrowerDirectory;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.domain.Borrower;importedu.se506.lending.domain.BorrowerCategory;importjava.time.LocalDate;importjava.util.Objects;importjava.util.Optional;/** Directory-backed entry point; this does not authenticate the caller. */publicfinalclassDirectoryReservationService{privatefinalBorrowerDirectorydirectory;privatefinalReservationCoordinatorcoordinator;privatefinalVisitorGatevisitorGate;publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator){this(directory,coordinator,VisitorGate.disabled());}publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator,VisitorGatevisitorGate){this.directory=Objects.requireNonNull(directory,"directory");this.coordinator=Objects.requireNonNull(coordinator,"coordinator");this.visitorGate=Objects.requireNonNull(visitorGate,"visitorGate");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");varfound=directory.find(borrowerId);if(found.isEmpty())return"Rejected: borrower not found or inactive.";Borrowerborrower=found.get();if(!borrowerId.equals(borrower.id())){thrownewDirectoryException("Directory returned a different borrower");}if(borrower.category()==BorrowerCategory.VISITOR){Optional<String>rejection=visitorGate.rejection(borrower.id(),itemId,start,days);if(rejection.isPresent())returnrejection.get();}returncoordinator.reserve(borrower.id(),itemId,start,days,borrower.category());}}
packageedu.se506.lending.infrastructure.approvals;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Instant;importjava.time.LocalDate;importjava.time.format.DateTimeParseException;importjava.util.ArrayList;importjava.util.HashSet;importjava.util.List;importjava.util.Objects;importjava.util.Optional;importjava.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.
*/publicfinalclassFileApprovalSourceimplementsApprovalSource{staticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatefinalPathfile;publicFileApprovalSource(Pathfile){this.file=Objects.requireNonNull(file,"file");}@OverridepublicOptional<ApprovalRecord>find(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive){List<ApprovalRecord>records=readAll();for(ApprovalRecordrecord:records){if(record.matches(borrowerId,itemId,start,endExclusive)){returnOptional.of(record);}}returnOptional.empty();}privateList<ApprovalRecord>readAll(){List<String>lines;try{lines=Files.readAllLines(file,StandardCharsets.UTF_8);}catch(IOExceptionfailure){thrownewApprovalSourceException("Approval register cannot be read: "+file.getFileName(),failure);}if(lines.isEmpty()||!HEADER.equals(lines.get(0))){thrownewApprovalSourceException("Approval register has no valid header row.");}List<ApprovalRecord>records=newArrayList<>();Set<String>keys=newHashSet<>();for(inti=1;i<lines.size();i++){introw=i+1;ApprovalRecordrecord=parse(lines.get(i),row);Stringkey=record.borrowerId()+"|"+record.itemId()+"|"+record.start()+"|"+record.endExclusive();if(!keys.add(key)){thrownewApprovalSourceException("Duplicate approval request key at row "+row+".");}records.add(record);}returnList.copyOf(records);}privatestaticApprovalRecordparse(Stringline,introw){String[]columns=line.split("\t",-1);if(columns.length!=8){thrownewApprovalSourceException("Row "+row+" has "+columns.length+" columns, not 8.");}try{returnnewApprovalRecord(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|IllegalArgumentExceptionbad){thrownewApprovalSourceException("Row "+row+" is not a valid approval record.",bad);}}privatestaticApprovalStatusstatus(Stringtext,introw){returnswitch(text){case"APPROVED"->ApprovalStatus.APPROVED;case"DENIED"->ApprovalStatus.DENIED;default->thrownewApprovalSourceException("Row "+row+" has an unknown status.");};}}
packageedu.se506.lending.infrastructure.directory;/** Illustrative provider contract, not an actual university API. See HttpLegacyDirectoryClient for a network client. */@FunctionalInterfacepublicinterfaceLegacyDirectoryClient{// null means no row; codes are exact and case-sensitive.Personlookup(Stringkey);recordPerson(Stringkey,Stringkind,Stringstatus){}finalclassFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicFailure(Stringmessage){super(message);}publicFailure(Stringmessage,Throwablecause){super(message,cause);}}}
packageedu.se506.lending.infrastructure.directory;importjava.net.URI;importjava.net.URLEncoder;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.nio.charset.StandardCharsets;importjava.time.Duration;importjava.util.Objects;importjava.util.concurrent.CompletableFuture;importjava.util.concurrent.ExecutionException;importjava.util.concurrent.TimeUnit;importjava.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.
*/publicfinalclassHttpLegacyDirectoryClientimplementsLegacyDirectoryClient{privatefinalHttpClienthttp;privatefinalURIbase;privatefinalDurationrequestTimeout;publicHttpLegacyDirectoryClient(URIbase,DurationconnectTimeout,DurationrequestTimeout){this.base=Objects.requireNonNull(base,"base");this.requestTimeout=Objects.requireNonNull(requestTimeout,"requestTimeout");this.http=HttpClient.newBuilder().connectTimeout(Objects.requireNonNull(connectTimeout,"connectTimeout")).build();}@OverridepublicPersonlookup(Stringkey){URIuri=URI.create(base.toString().replaceAll("/+$","")+"/borrowers/"+URLEncoder.encode(key,StandardCharsets.UTF_8));HttpRequestrequest=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(TimeoutExceptionlate){pending.cancel(true);// asks the client to drop the exchange; the caller does not wait for itthrownewFailure("Directory did not answer within "+requestTimeout.toMillis()+" ms.",late);}catch(InterruptedExceptioninterrupted){pending.cancel(true);Thread.currentThread().interrupt();thrownewFailure("Directory request was interrupted.",interrupted);}catch(ExecutionExceptionfailed){Throwablecause=failed.getCause()==null?failed:failed.getCause();thrownewFailure("Directory unavailable: "+cause.getClass().getSimpleName(),cause);}if(response.statusCode()==404){returnnull;}if(response.statusCode()!=200){thrownewFailure("Directory answered with status "+response.statusCode()+".");}String[]fields=response.body().strip().split("\t",-1);if(fields.length!=2){thrownewFailure("Directory answered with a malformed row.");}returnnewPerson(key,fields[0],fields[1]);}}
packageedu.se506.lending.bootstrap;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.net.URI;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Duration;importjava.time.Instant;importjava.time.LocalDate;importjava.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).
*/publicfinalclassChapter13Demo{publicstaticvoidmain(String[]args){URIdirectoryUrl=URI.create(required("lending.directory.url"));Pathapprovals=Path.of(required("lending.approvals.file"));booleanvisitorsEnabled=Boolean.parseBoolean(System.getProperty("lending.visitors","false"));Stringnow=System.getProperty("lending.now");Clockclock=now==null?Clock.systemUTC():Clock.fixed(Instant.parse(now),ZoneOffset.UTC);longtimeoutMillis=Long.parseLong(System.getProperty("lending.directory.timeout.millis","1000"));varsender=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(sender);vardirectory=newLegacyDirectoryAdapter(newHttpLegacyDirectoryClient(directoryUrl,Duration.ofMillis(timeoutMillis),Duration.ofMillis(timeoutMillis)));vargate=newVisitorGate(newFileApprovalSource(approvals),clock,visitorsEnabled);varservice=newDirectoryReservationService(directory,coordinator,gate);LocalDatestart=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());}privatestaticvoidattempt(Stringlabel,DirectoryReservationServiceservice,Stringborrower,Stringitem,LocalDatestart,intdays){Stringoutcome;try{outcome=service.reserve(borrower,item,start,days);}catch(DirectoryExceptionfailure){outcome="Unavailable: "+failure.getMessage();}catch(ApprovalSourceExceptionfailure){outcome="Approval register unavailable: "+failure.getMessage();}System.out.println(label+": "+outcome);}privatestaticStringrequired(Stringproperty){Stringvalue=System.getProperty(property);if(value==null||value.isBlank()){thrownewIllegalStateException("Missing system property "+property);}returnvalue;}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.domain.VisitorReservationRules;importjava.time.Instant;importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;/** Unit tests for the visitor count and duration rule and for the approval record's own rules. */classVisitorRulesTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticApprovalRecordrecord(ApprovalStatusstatus){returnnewApprovalRecord("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"})voidcountAndDurationBoundaries(intcount,intdays,booleanexpected){assertEquals(expected,newVisitorReservationRules().mayRequest(count,days));}@TestvoidequalityAtTheStartOfTheWindowIsValidAndAtTheEndIsNot(){ApprovalRecordapproved=record(ApprovalStatus.APPROVED);assertTrue(approved.validAt(FROM));assertTrue(approved.validAt(UNTIL.minusSeconds(1)));assertFalse(approved.validAt(UNTIL));assertFalse(approved.validAt(FROM.minusSeconds(1)));}@TestvoidonlyAnApprovedStatusApprovesAndOnlyInsideTheWindow(){Instantinside=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));}@TestvoidaRecordMatchesOnlyTheExactBorrowerItemAndPeriod(){ApprovalRecordapproved=record(ApprovalStatus.APPROVED);LocalDateend=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)));}@TestvoidanInvalidRecordCannotBeBuilt(){assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2)," ",ApprovalStatus.APPROVED,FROM,UNTIL));assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START,"S1",ApprovalStatus.APPROVED,FROM,UNTIL));assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",ApprovalStatus.APPROVED,UNTIL,UNTIL));assertThrows(NullPointerException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",null,FROM,UNTIL));}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneId;importjava.time.ZoneOffset;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.Test;/** Unit tests for the visitor gate with a controlled approval source and clock. */classVisitorGateTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticfinalInstantNOW=Instant.parse("2026-10-19T12:00:00Z");privatefinalAtomicIntegerreads=newAtomicInteger();privateApprovalSourcesourceOf(ApprovalRecordrecord){return(borrower,item,start,end)->{reads.incrementAndGet();returnOptional.ofNullable(record);};}privatestaticApprovalRecordrecord(ApprovalStatusstatus){returnnewApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",status,FROM,UNTIL);}privateVisitorGategate(ApprovalRecordrecord,Instantnow,booleanenabled){returnnewVisitorGate(sourceOf(record),Clock.fixed(now,ZoneOffset.UTC),enabled);}@TestvoidaMatchingApprovedRecordInsideItsWindowLetsTheRequestContinue(){assertEquals(Optional.empty(),gate(record(ApprovalStatus.APPROVED),NOW,true).rejection("V1","CAM1",START,2));assertEquals(1,reads.get());}@TestvoidaDisabledGateRejectsWithoutReadingTheSource(){assertEquals(Optional.of(VisitorGate.DISABLED),gate(record(ApprovalStatus.APPROVED),NOW,false).rejection("V1","CAM1",START,2));assertEquals(0,reads.get());}@TestvoidanItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource(){assertEquals(Optional.of(VisitorGate.ITEM_NOT_ALLOWED),gate(record(ApprovalStatus.APPROVED),NOW,true).rejection("V1","MIC1",START,2));assertEquals(0,reads.get());}@TestvoidaNonPositiveDurationIsLeftToThePolicyWithoutReadingTheSource(){VisitorGategate=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());}@TestvoidaMissingOrDeniedRecordIsAnExpectedRejection(){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));}@TestvoidtheWindowIncludesItsStartAndExcludesItsEnd(){ApprovalRecordapproved=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));}@TestvoidaSourceThatReturnsADifferentRequestDoesNotApprove(){ApprovalRecordother=newApprovalRecord("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));}@TestvoidanOperationalFailureOfTheSourceIsNotCaught(){ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");VisitorGategate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);assertSame(failure,assertThrows(ApprovalSourceException.class,()->gate.rejection("V1","CAM1",START,2)));}@TestvoidoneRequestReadsTheClockOnce(){AtomicIntegerinstants=newAtomicInteger();Clockcounting=newClock(){@OverridepublicZoneIdgetZone(){returnZoneOffset.UTC;}@OverridepublicClockwithZone(ZoneIdzone){returnthis;}@OverridepublicInstantinstant(){instants.incrementAndGet();returnNOW;}};newVisitorGate(sourceOf(record(ApprovalStatus.APPROVED)),counting,true).rejection("V1","CAM1",START,2);assertEquals(1,instants.get());}@TestvoidtheDisabledFactoryNeverReadsASource(){assertEquals(Optional.of(VisitorGate.DISABLED),VisitorGate.disabled().rejection("V1","CAM1",START,2));}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.LoanService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.application.port.BorrowerDirectory;importedu.se506.lending.application.port.NotificationException;importedu.se506.lending.bootstrap.LoanSnapshotRows;importedu.se506.lending.bootstrap.OverdueReportService;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.domain.Borrower;importedu.se506.lending.domain.BorrowerCategory;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importjava.util.ArrayList;importjava.util.List;importjava.util.Locale;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.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.
*/classVisitorAcceptanceTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticfinalInstantNOW=Instant.parse("2026-10-19T12:00:00Z");privatestaticfinalStringSUCCESS_V1_CAM1="Confirmed: R1 for V1 on CAM1 [2026-10-20, 2026-10-22).";privatestaticfinalStringLIMIT="Rejected: borrower limit or duration.";privateLocalesavedLocale;@BeforeEachvoidpinTheLocale(){savedLocale=Locale.getDefault();Locale.setDefault(Locale.ROOT);}@AfterEachvoidrestoreTheLocale(){Locale.setDefault(savedLocale);}/** A complete small system with a controlled directory, approval list, clock and sender. */privatestaticfinalclassWorld{finalList<ApprovalRecord>approvals=newArrayList<>();finalAtomicIntegerapprovalReads=newAtomicInteger();finalRecordingNotificationSendersender=newRecordingNotificationSender();finalReservationCoordinatorcoordinator;finalDirectoryReservationServiceservice;World(booleanenabled,Instantnow,edu.se506.lending.application.port.NotificationSendernotifier){coordinator=ReservationApplication.create(notifier);BorrowerDirectorydirectory=id->switch(id){case"V1","V2"->Optional.of(newBorrower(id,BorrowerCategory.VISITOR));case"S1"->Optional.of(newBorrower(id,BorrowerCategory.STUDENT));case"T1"->Optional.of(newBorrower(id,BorrowerCategory.STAFF));default->Optional.empty();};VisitorGategate=newVisitorGate((borrower,item,start,end)->{approvalReads.incrementAndGet();returnapprovals.stream().filter(r->r.matches(borrower,item,start,end)).findFirst();},Clock.fixed(now,ZoneOffset.UTC),enabled);service=newDirectoryReservationService(directory,coordinator,gate);}World(booleanenabled,Instantnow){this(enabled,now,newRecordingNotificationSender());}Worldapprove(Stringborrower,Stringitem,LocalDatestart,intdays){approvals.add(newApprovalRecord(borrower,item,start,start.plusDays(days),"S1",ApprovalStatus.APPROVED,FROM,UNTIL));returnthis;}}privatestaticvoidassertNothingChanged(Worldw){assertTrue(w.coordinator.reservations().isEmpty());}@Testvoidac1_aVisitorWithAMatchingApprovalReservesCam1ForTwoDays(){varrecorder=newRecordingNotificationSender();varw=newWorld(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());}@Testvoidac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowed(){varw=newWorld(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));}@Testvoidac3_fourDaysIsRejectedEvenWithAMatchingRecord(){varrecorder=newRecordingNotificationSender();varw=newWorld(true,NOW,recorder).approve("V1","CAM1",START,4);assertEquals(LIMIT,w.service.reserve("V1","CAM1",START,4));assertNothingChanged(w);assertTrue(recorder.received().isEmpty());}@Testvoidac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister(){varrecorder=newRecordingNotificationSender();varw=newWorld(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());}@Testvoidac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecord(){varw=newWorld(true,NOW).approve("V1","MIC1",START,2);assertEquals(VisitorGate.ITEM_NOT_ALLOWED,w.service.reserve("V1","MIC1",START,2));assertNothingChanged(w);}@Testvoidac6_aMissingOrDeniedApprovalIsAnExpectedRejection(){varmissing=newWorld(true,NOW);assertEquals(VisitorGate.NOT_APPROVED,missing.service.reserve("V1","CAM1",START,2));assertNothingChanged(missing);vardenied=newWorld(true,NOW);denied.approvals.add(newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",ApprovalStatus.DENIED,FROM,UNTIL));assertEquals(VisitorGate.NOT_APPROVED,denied.service.reserve("V1","CAM1",START,2));assertNothingChanged(denied);}@Testvoidac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise(){varw=newWorld(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);}@Testvoidac8_theRequestInstantEqualToValidFromIsValid(){varw=newWorld(true,FROM).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));}@Testvoidac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejected(){varatExpiry=newWorld(true,UNTIL).approve("V1","CAM1",START,2);assertEquals(VisitorGate.NOT_APPROVED,atExpiry.service.reserve("V1","CAM1",START,2));varearly=newWorld(true,FROM.minusSeconds(1)).approve("V1","CAM1",START,2);assertEquals(VisitorGate.NOT_APPROVED,early.service.reserve("V1","CAM1",START,2));assertNothingChanged(atExpiry);assertNothingChanged(early);}@Testvoidac10_anUnreadableApprovalRegisterIsAnOperationalFailureNotARejection(){varrecorder=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(recorder);ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");vargate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);varservice=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),coordinator,gate);assertSame(failure,assertThrows(ApprovalSourceException.class,()->service.reserve("V1","CAM1",START,2)));assertTrue(coordinator.reservations().isEmpty());assertTrue(recorder.received().isEmpty());}@Testvoidprecedence_theRegisterIsReadBeforeTheCountAndDurationRulesApply(){varrecorder=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(recorder);ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");vargate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);varservice=newDirectoryReservationService(id->Optional.of(newBorrower(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());}@Testvoidac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturned(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW);varloans=newLoanService(w.coordinator,checkoutDay);for(inti=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());}@Testvoidac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister(){varw=newWorld(false,NOW).approve("V1","CAM1",START,2);assertEquals(VisitorGate.DISABLED,w.service.reserve("V1","CAM1",START,2));assertEquals(0,w.approvalReads.get());assertNothingChanged(w);vardefaultService=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),ReservationApplication.create(newRecordingNotificationSender()));assertEquals(VisitorGate.DISABLED,defaultService.reserve("V1","CAM1",START,2));}@Testvoidac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister(){for(booleanenabled:newboolean[]{false,true}){varw=newWorld(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());}}@Testvoidac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule(){varw=newWorld(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());}@Testvoidac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace(){varw=newWorld(true,NOW,reservation->{thrownewNotificationException("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());}@Testvoidac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));varafterwards=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),w.coordinator,VisitorGate.disabled());assertEquals(VisitorGate.DISABLED,afterwards.reserve("V1","CAM2",START,1));varloans=newLoanService(w.coordinator,checkoutDay);assertTrue(loans.checkOut("R1").active());assertFalse(loans.returnLoan("R1").active());}@Testvoidac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore(){varw=newWorld(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);}@Testvoidac18_aVisitorLoanInTheOverdueReportKeepsTheExistingKindRuleSd11_1(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);ClockreportDay=Clock.fixed(Instant.parse("2026-10-22T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));varloans=newLoanService(w.coordinator,checkoutDay);loans.checkOut("R1");varrows=newLoanSnapshotRows(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",newOverdueReportService(rows,reportDay).report());}}
packageedu.se506.lending.integration;importcom.sun.net.httpserver.HttpExchange;importcom.sun.net.httpserver.HttpServer;importjava.io.IOException;importjava.net.InetAddress;importjava.net.InetSocketAddress;importjava.net.URI;importjava.nio.charset.StandardCharsets;importjava.util.concurrent.CountDownLatch;importjava.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.
*/publicfinalclassDirectoryStubimplementsAutoCloseable{privatefinalHttpServerserver;privatefinalCountDownLatchrelease=newCountDownLatch(1);privatevolatileintrequests;publicDirectoryStub()throwsIOException{server=HttpServer.create(newInetSocketAddress(InetAddress.getLoopbackAddress(),0),0);server.createContext("/borrowers/",this::handle);server.start();}publicURIbaseUri(){returnURI.create("http://127.0.0.1:"+server.getAddress().getPort());}publicintrequests(){returnrequests;}privatevoidhandle(HttpExchangeexchange)throwsIOException{requests++;Stringkey=exchange.getRequestURI().getPath().substring("/borrowers/".length());intstatus=200;booleanstallBody=false;Stringbody;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(IOExceptionclientGone){// the client gave up (a timeout test); nothing more to send}finally{exchange.close();}}privatevoidawaitRelease(){try{release.await(30,TimeUnit.SECONDS);}catch(InterruptedExceptioninterrupted){Thread.currentThread().interrupt();}}@Overridepublicvoidclose(){release.countDown();server.stop(0);}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importorg.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.
*/abstractclassDirectoryClientContract{/** A client that answers "S" and "Y" for the key B1 and has no row for the key NONE. */abstractLegacyDirectoryClientclient()throwsException;/** A client for which every lookup fails because the provider cannot be reached or understood. */abstractLegacyDirectoryClientfailingClient()throwsException;@TestvoidaKnownKeyReturnsItsRowWithTheKeyItWasAskedFor()throwsException{LegacyDirectoryClient.Personrow=client().lookup("B1");assertEquals(newLegacyDirectoryClient.Person("B1","S","Y"),row);}@TestvoidanUnknownKeyReturnsNullAndIsNotAFailure()throwsException{assertNull(client().lookup("NONE"));}@TestvoidaProviderThatCannotAnswerFailsAndNeverReturnsAnEmptyAnswer()throwsException{LegacyDirectoryClientfailing=failingClient();assertThrows(LegacyDirectoryClient.Failure.class,()->failing.lookup("B1"));}}
packageedu.se506.lending.integration;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;/** The contract run against the in-memory fixture that earlier chapters used. */classFixtureDirectoryClientContractTestextendsDirectoryClientContract{@OverrideLegacyDirectoryClientclient(){returnkey->"B1".equals(key)?newLegacyDirectoryClient.Person("B1","S","Y"):null;}@OverrideLegacyDirectoryClientfailingClient(){returnkey->{thrownewLegacyDirectoryClient.Failure("fixture provider is down");};}}
packageedu.se506.lending.integration;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importjava.io.IOException;importjava.net.ServerSocket;importjava.net.URI;importjava.time.Duration;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Tag;/** The same contract run against the real HTTP client and a controlled local service. */@Tag("integration")classHttpDirectoryClientContractTestextendsDirectoryClientContract{privateDirectoryStubstub;@BeforeEachvoidstartStub()throwsIOException{stub=newDirectoryStub();}@AfterEachvoidstopStub(){stub.close();}@OverrideLegacyDirectoryClientclient(){returnnewHttpLegacyDirectoryClient(stub.baseUri(),Duration.ofSeconds(2),Duration.ofSeconds(2));}@OverrideLegacyDirectoryClientfailingClient()throwsIOException{intclosedPort;try(ServerSocketsocket=newServerSocket(0)){closedPort=socket.getLocalPort();}returnnewHttpLegacyDirectoryClient(URI.create("http://127.0.0.1:"+closedPort),Duration.ofSeconds(2),Duration.ofSeconds(2));}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.io.IOException;importjava.net.ServerSocket;importjava.net.URI;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Duration;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.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")classDirectoryOutageIntegrationTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privateDirectoryStubstub;privateRecordingNotificationSendersender;privateReservationCoordinatorcoordinator;@BeforeEachvoidstart()throwsIOException{stub=newDirectoryStub();sender=newRecordingNotificationSender();coordinator=ReservationApplication.create(sender);}@AfterEachvoidstop(){stub.close();}privateDirectoryReservationServiceservice(URIbase,Durationtimeout,VisitorGategate){varclient=newHttpLegacyDirectoryClient(base,timeout,timeout);returnnewDirectoryReservationService(newLegacyDirectoryAdapter(client),coordinator,gate);}privatevoidassertNothingHappened(){assertTrue(coordinator.reservations().isEmpty());assertTrue(sender.received().isEmpty());}@TestvoidanOrdinaryStudentRequestWorksThroughTheRealHttpClient(){varservice=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());}@TestvoidaMissingRowIsAnOrdinaryRejection(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());assertEquals("Rejected: borrower not found or inactive.",service.reserve("NOBODY","CAM1",START,2));assertNothingHappened();}@TestvoidaServerErrorIsAnOutageNotANotFoundAndIsNotRetried(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("BROKEN","CAM1",START,2));assertInstanceOf(LegacyDirectoryClient.Failure.class,failure.getCause());assertEquals(1,stub.requests());assertNothingHappened();}@TestvoidaSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest(){varservice=service(stub.baseUri(),Duration.ofMillis(300),VisitorGate.disabled());longbegin=System.nanoTime();DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("SLOW","CAM1",START,2));longelapsedMillis=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();}@TestvoidaStalledBodyIsAFailureWithinTheOverallWait(){varservice=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.DirectoryExceptionfailure=assertTimeoutPreemptively(Duration.ofSeconds(5),()->assertThrows(DirectoryException.class,()->service.reserve("SLOWBODY","CAM1",START,2)));assertInstanceOf(LegacyDirectoryClient.Failure.class,failure.getCause());assertEquals(1,stub.requests());assertNothingHappened();}@TestvoidaRefusedConnectionIsAnOutage()throwsIOException{intclosedPort;try(ServerSocketsocket=newServerSocket(0)){closedPort=socket.getLocalPort();}varservice=service(URI.create("http://127.0.0.1:"+closedPort),Duration.ofSeconds(2),VisitorGate.disabled());assertThrows(DirectoryException.class,()->service.reserve("B1","CAM1",START,2));assertNothingHappened();}@TestvoidaMalformedAnswerIsAnOutageNotAGuess(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());assertThrows(DirectoryException.class,()->service.reserve("GARBAGE","CAM1",START,2));assertNothingHappened();}@TestvoidanUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("ODD","CAM1",START,2));assertEquals("Unsupported borrower category",failure.getMessage());assertNothingHappened();}@TestvoidanInactiveVisitorIsRejectedAndTheApprovalRegisterIsNotRead(@TempDirPathdir){PathmissingRegister=dir.resolve("never-read.tsv");vargate=newVisitorGate(newFileApprovalSource(missingRegister),Clock.systemUTC(),true);varservice=service(stub.baseUri(),Duration.ofSeconds(2),gate);assertEquals("Rejected: borrower not found or inactive.",service.reserve("VOFF","CAM1",START,2));assertNothingHappened();}@TestvoidaVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile(@TempDirPathdir)throwsIOException{Pathregister=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);Clockclock=Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"),ZoneOffset.UTC);vargate=newVisitorGate(newFileApprovalSource(register),clock,true);varservice=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());}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.NoSuchFileException;importjava.nio.file.Path;importjava.time.Instant;importjava.time.LocalDate;importjava.util.Optional;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.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")classFileApprovalSourceIntegrationTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalLocalDateEND=LocalDate.of(2026,10,22);privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringGOOD="V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";@TempDirPathdir;privatePathregister(String...lines)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,String.join("\n",lines)+"\n",StandardCharsets.UTF_8);returnfile;}privatestaticApprovalSourceExceptionfailureOf(Pathfile){returnassertThrows(ApprovalSourceException.class,()->newFileApprovalSource(file).find("V1","CAM1",START,END));}@TestvoidaMatchingRowIsReturnedWithEveryFieldParsed()throwsIOException{Optional<ApprovalRecord>found=newFileApprovalSource(register(HEADER,GOOD)).find("V1","CAM1",START,END);ApprovalRecordrecord=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());}@TestvoidaRequestWithNoMatchingRowIsEmptyNotAFailure()throwsIOException{varsource=newFileApprovalSource(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)));}@TestvoidaDeniedRowIsReturnedAsDeniedForTheCallerToDecide()throwsIOException{Stringdenied=GOOD.replace("APPROVED","DENIED");assertEquals(ApprovalStatus.DENIED,newFileApprovalSource(register(HEADER,denied)).find("V1","CAM1",START,END).orElseThrow().status());}@TestvoidtheFileIsReadAfreshOnEveryCall()throwsIOException{Pathfile=register(HEADER);varsource=newFileApprovalSource(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());}@TestvoidaMissingFileIsAnOperationalFailureWithItsCause(){ApprovalSourceExceptionfailure=failureOf(dir.resolve("absent.tsv"));assertInstanceOf(NoSuchFileException.class,failure.getCause());}@TestvoidaWrongOrMissingHeaderIsAFailure()throwsIOException{failureOf(register(GOOD));failureOf(register(HEADER.replace("sponsorId","sponsor"),GOOD));Pathempty=dir.resolve("empty.tsv");Files.writeString(empty,"",StandardCharsets.UTF_8);failureOf(empty);}@TestvoidaRowWithTheWrongNumberOfColumnsIsAFailure()throwsIOException{failureOf(register(HEADER,GOOD+"\textra"));failureOf(register(HEADER,"V1\tCAM1\t2026-10-20"));}@TestvoidanUnknownStatusIsAFailureNotADenial()throwsIOException{failureOf(register(HEADER,GOOD.replace("APPROVED","approved")));failureOf(register(HEADER,GOOD.replace("APPROVED","PENDING")));}@TestvoidanInvalidDateOrInstantIsAFailure()throwsIOException{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")));}@TestvoidaBlankSponsorOrAnInvalidIntervalIsAFailure()throwsIOException{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")));}@TestvoidaDuplicateRequestKeyIsAFailureEvenIfTheRowsDisagree()throwsIOException{failureOf(register(HEADER,GOOD,GOOD.replace("APPROVED","DENIED")));}@TestvoidoneBadRowRejectsTheWholeRegisterEvenWhenTheRequestMatchesAGoodRow()throwsIOException{failureOf(register(HEADER,GOOD,"V9\tCAM2\tnot-a-date\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z"));}@TestvoidbytesThatAreNotUtf8AreAFailure()throwsIOException{Pathfile=dir.resolve("binary.tsv");byte[]header=(HEADER+"\n").getBytes(StandardCharsets.UTF_8);byte[]bad={(byte)0xC3,(byte)0x28,'\n'};byte[]all=newbyte[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);}}
packageedu.se506.lending.e2e;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.integration.DirectoryStub;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.TimeUnit;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.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")classChapter13JourneyE2ETest{privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringV1_CAM2="V1\tCAM2\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";@TempDirPathdir;privaterecordRun(intexitCode,List<String>lines){}privateRunrun(DirectoryStubstub,Pathregister,booleanvisitors)throwsIOException,InterruptedException{Stringjava=Path.of(System.getProperty("java.home"),"bin",System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java").toString();Stringclasspath=System.getProperty("e2e.classpath","target/classes");ProcessBuilderbuilder=newProcessBuilder(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);Processprocess=builder.start();List<String>lines=newArrayList<>();try(varreader=newjava.io.BufferedReader(newjava.io.InputStreamReader(process.getInputStream(),StandardCharsets.UTF_8))){Stringline;while((line=reader.readLine())!=null)lines.add(line);}assertTrue(process.waitFor(60,TimeUnit.SECONDS),"the demo did not finish");returnnewRun(process.exitValue(),lines);}privatePathregister(String...rows)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,String.join("\n",rows)+"\n",StandardCharsets.UTF_8);returnfile;}@TestvoidwithTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed()throwsException{try(varstub=newDirectoryStub()){Runrun=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());}}@TestvoidwithTheFlagOnAnApprovedVisitorIsServedAndAnUnapprovedOneIsRefused()throwsException{try(varstub=newDirectoryStub()){Runrun=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());}}@TestvoidaMalformedRegisterFailsOnlyTheVisitorRequestsAndIsReportedAsAnOutage()throwsException{try(varstub=newDirectoryStub()){Runrun=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.
--- 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:
#!/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:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fistage(){localname="$1"shiftecho"=== STAGE: ${name}"localbegan=$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 itset -euo pipefail
cd"$(dirname "${BASH_SOURCE[0]}")/.."MVN="${CHAPTER_MVN:-mvn}"ARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiSTAGING="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"];thenecho"No previous release to roll back to." >&2exit1fi 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]" >&2exit2;;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 pipelineon:push:pull_request:workflow_dispatch:permissions:contents:readjobs:unit:name:1Compile, unit tests and architecture checksruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Compilerun:mvn --batch-mode -q compile- name:Unit testsrun:mvn --batch-mode test -DexcludedGroups=integration,e2eintegration:name:2Integration testsneeds:unitruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Integration testsrun:mvn --batch-mode test -Dgroups=integratione2e:name:3End-to-end journeysneeds:integrationruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Compilerun:mvn --batch-mode -q compile- name:End-to-end journeysrun:mvn --batch-mode test -Dgroups=e2epackage:name:4Package the release candidateneeds:e2eruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Packagerun:mvn --batch-mode package -DskipTests- name:Keep the jaruses:actions/upload-artifact@v7with:name:release-candidatepath:target/equipment-lending-*.jarif-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.
packageedu.se506.lending.aifixture;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importjava.util.Optional;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.io.TempDir;/** The repaired test: a real register file, read by the real adapter, through the real gate. */@Tag("integration")classRepairedRegisterIntegrationTest{privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringROW="V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);@TestvoidanApprovedVisitorIsAcceptedFromARealRegisterFile(@TempDirPathdir)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,HEADER+"\n"+ROW+"\n",StandardCharsets.UTF_8);Clockclock=Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"),ZoneOffset.UTC);VisitorGategate=newVisitorGate(newFileApprovalSource(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.
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.
Run
Result
New test aStalledBodyIsAFailureWithinTheOverallWait against a client that relies on the request timeout alone
Fails: 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 chapter
Passes. 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.2
Removing 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:
Example
Test
Deciding production code
Observable result
Stage
AC9 (the expiry boundary)
ac9_...
ApprovalRecord.validAt, called from VisitorGate.rejection
Rejected: no valid sponsor approval. at validUntil; no reservation
2
AC10 (operational failure)
ac10_... and the third journey
FileApprovalSource.readAll throws; nothing catches it on the request path
An ApprovalSourceException; no reservation or notice. The demonstration prints Approval register unavailable: ...
2 and 4
AC12 (the flag)
ac12_... and the first journey
VisitorGate.rejection, the first check
Rejected: visitor reservations are not enabled.; the register is not read
2 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.
Question
Answer
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.
Run
Proposed test
Repaired test
No fault
Passes
Passes
Fault A: the validFrom and validUntil columns are read the wrong way round
Passes
Fails: anApprovedVisitorIsAcceptedFromARealRegisterFile throws ApprovalSourceException: Row 2 is not a valid approval record.
Fault B: a broken row is skipped instead of rejecting the whole file
Passes
Fails: 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{returnfetch(key);}catch(IOException|InterruptedExceptionfailure){returnnull;}
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.HttpRequestrequest=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.ApprovalSourceregister=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.publicstaticbooleanvisitingResearchersEnabled=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:
Risk
Check
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 duplicate
Integration 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 nonsense
Integration 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 build
One end-to-end journey that starts the serving process with each setting
Old behaviour changed
The 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.
Area
Fault
A test that caught it
Gate
Duration check removed (the register is read for zero days)
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.
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
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.
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.
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 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.
That a named test is the right evidence for its requirement
Not 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:
#!/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"];thenecho"Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2exit2fiARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fistage(){localname="$1"shiftecho"=== STAGE: ${name}"localbegan=$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 itset -euo pipefail
cd"$(dirname "${BASH_SOURCE[0]}")/.."MVN="${CHAPTER_MVN:-mvn}"if ! command -v "$MVN" >/dev/null 2>&1&&[ ! -f "$MVN"];thenecho"Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2exit2fiARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiSTAGING="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"];thenecho"No previous release to roll back to." >&2exit1fi 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]" >&2exit2;;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"];thenecho"Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2exit2fiif ! command -v java >/dev/null 2>&1;thenecho"Java was not found. Install a full Java 17 JDK and make sure java is on the PATH." >&2exit2fiARGS=(--batch-mode -q)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fi"$MVN""${ARGS[@]}" test-compile
SEP=":"case"$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)SEP=";";;esacjava -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 pipelineEXPECT_INTEGRATION=26# stage 3EXPECT_E2E=3# stage 4EXPECT_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=0expect(){# expect <file> <fixed text> <what it shows>if grep -qF -- "$2""$1";thenecho"ok $3"elseecho"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;thenecho"ok the pipeline exited 0";elseecho"FAIL the pipeline did not exit 0";failures=$((failures +1));fiexpect 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;thenecho"ok the demonstration exited 0";elseecho"FAIL the demonstration did not exit 0";failures=$((failures +1));fiexpect 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];thenecho"HANDOVER CHECK PASSED"elseecho"HANDOVER CHECK FAILED: ${failures} problem(s)"exit1fi
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
packageedu.se506.lending.docs;import staticorg.junit.jupiter.api.Assertions.*;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.ArrayList;importjava.util.List;importjava.util.TreeMap;importjava.util.TreeSet;importjava.util.regex.Matcher;importjava.util.regex.Pattern;importjava.util.stream.Stream;importorg.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.
*/classTraceabilityTest{privatestaticfinalPathTABLE=Path.of("docs","REQUIREMENTS-TO-EVIDENCE.md");privatestaticfinalPathMAIN=Path.of("src","main","java");privatestaticfinalPathTEST=Path.of("src","test","java");privatestaticfinalPatternTEST_REFERENCE=Pattern.compile("`([A-Z]\\w+)\\.([a-z]\\w*)`");privatestaticfinalPatternCLASS_REFERENCE=Pattern.compile("`([A-Z]\\w+)`");privatestaticfinalPatternACCEPTANCE_ID=Pattern.compile("^AC\\d+$");privatestaticfinalPatternACCEPTANCE_TEST=Pattern.compile("void (ac\\d+_\\w+)\\(");/** One table row: its cells, trimmed, without the outer bars. */privaterecordRow(List<String>cells){Stringid(){returncells.get(0);}StringdecidedBy(){returncells.get(2);}Stringevidence(){returncells.get(3);}}privatestaticList<Row>rows()throwsIOException{List<Row>rows=newArrayList<>();for(Stringline:Files.readAllLines(TABLE,StandardCharsets.UTF_8)){if(!line.startsWith("| ")||line.startsWith("| ---")||line.startsWith("| ID "))continue;List<String>cells=newArrayList<>();for(Stringcell:line.substring(1,line.length()-1).split("\\|",-1))cells.add(cell.trim());if(cells.size()==5)rows.add(newRow(cells));// the gap table has three columns}returnrows;}privatestaticStringsourceOf(Pathroot,StringclassName)throwsIOException{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);returnFiles.readString(found.get(0),StandardCharsets.UTF_8);}}@TestvoideveryTestNamedInTheTableStillExists()throwsIOException{List<String>missing=newArrayList<>();intchecked=0;for(Rowrow:rows()){Matcherm=TEST_REFERENCE.matcher(row.evidence());assertTrue(m.find(),row.id()+" names no test");do{checked++;Stringsource=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");}@TestvoideveryClassNamedAsDecidingStillExists()throwsIOException{List<String>missing=newArrayList<>();for(Rowrow:rows()){Matcherm=CLASS_REFERENCE.matcher(row.decidedBy());assertTrue(m.find(),row.id()+" names no deciding class");do{try{sourceOf(MAIN,m.group(1));}catch(AssertionErrorgone){missing.add(row.id()+": "+m.group(1));}}while(m.find());}assertEquals(List.of(),missing,"classes named in the table that no longer exist");}@TestvoideveryAcceptanceExampleHasExactlyOneRowAndEveryAcceptanceTestIsInTheTable()throwsIOException{TreeMap<String,Integer>rowsPerExample=newTreeMap<>();TreeSet<String>referenced=newTreeSet<>();for(Rowrow:rows()){if(ACCEPTANCE_ID.matcher(row.id()).matches())rowsPerExample.merge(row.id(),1,Integer::sum);Matcherm=TEST_REFERENCE.matcher(row.evidence());while(m.find())referenced.add(m.group(2));}for(intn=1;n<=18;n++){assertEquals(1,rowsPerExample.getOrDefault("AC"+n,0),"rows for AC"+n);}assertEquals(18,rowsPerExample.size(),"unexpected acceptance ids: "+rowsPerExample.keySet());Matcherdeclared=ACCEPTANCE_TEST.matcher(sourceOf(TEST,"VisitorAcceptanceTest"));List<String>unlisted=newArrayList<>();inttests=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");}}
packageedu.se506.lending.integration;importedu.se506.lending.bootstrap.Chapter13Demo;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.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.
*/publicfinalclassDemoRun{privatestaticfinalStringREGISTER=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","");privateDemoRun(){}publicstaticvoidmain(String[]args)throwsIOException{Pathregister=Files.createTempFile("approvals",".tsv");try(DirectoryStubstub=newDirectoryStub()){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(Stringenabled:newString[]{"false","true"}){System.out.println("== visitors "+(enabled.equals("true")?"enabled":"disabled")+" ==");System.setProperty("lending.visitors",enabled);Chapter13Demo.main(newString[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.
Group
Files
Lines
New production files
9
399 (63 + 15 + 9 + 73 + 53 + 6 + 16 + 92 + 72)
Existing production files changed
5
21 added, 3 removed
New test files
10
992
Existing test files changed
3
10 added, 4 removed
New scripts, workflow and record
4
213
README and POM changed
2
14 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.
Experiment
Starting point
Edit
Result
E1
Chapter 11 version (159 invocations)
Add VISITOR to the enum only
45 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
E2
The same
The enum, VisitorReservationRules and the composition root
5 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
E3
Chapter 13 version (232 invocations)
LegacyDirectoryAdapter maps kind V to BorrowerCategory.STAFF; nothing else
4 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
B1
Chapter 14 version (235 invocations)
Remove the visitor policy from ReservationApplication.create
73 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.
#
Comment
Check
Verdict
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:
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.
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.
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.
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.
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.
File
Change
VisitorGate.java
Removes DISABLED, the enabled field and parameter and the disabled() factory; adds noApprovals() (a gate whose source never has a record)
DirectoryReservationService.java
The two-argument constructor uses VisitorGate.noApprovals()
Chapter13Demo.java
No lending.visitors property and no Visitors enabled line
VisitorGateTest.java
Drops the flag argument; removes the two disabled tests; adds aGateWithNoRegisterApprovesNoVisitor
VisitorAcceptanceTest.java
World has no flag; AC12 becomes âno register approves no visitorâ; AC13 and AC16 are renamed
Chapter13JourneyE2ETest.java
Removes the flag-off journey, so two journeys remain
DirectoryOutageIntegrationTest.java
disabled() becomes noApprovals(); constructor calls lose the flag
DemoRun.java
Runs once
REQUIREMENTS-TO-EVIDENCE.md
C9, C10, AC12, AC13 and AC16 point at the new names
HANDOVER.md, handover-check.sh
The 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.
Measure
Order
Share of the team total
Commits
P1, P3, P2
P1 38 of 71 (more than half)
Lines changed
P2, P3, P1
P1 410 of 2,180 (19%)
Reviews given
P2, P3, P1
Reviews that requested changes
P2, 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:
Item
Decision
Reason
KI-2 (SD-11-1, visitor late-fine rate)
Defer
Needs the records office; any rate would be a guess
KI-7 (whole-file rejection of the register)
Accept
A silent skip could hide a lost approval or a duplicate; revisit with real use
KI-8 (one-second wait, not measured)
Accept
It is a setting; measure before changing
KI-12 (no Java 17 or hosted CI run)
Defer
Needs a JDK 17 and a hosted runner
KI-14 (the VISITOR branch)
Accept
Fine 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.
#
Statement
Verdict
Evidence and correction
1
A Java 11 application
Contradicted
maven.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
2
Supports students, staff and visiting researchers
Supported
BorrowerCategory, ADR-013
3
Build: mvn package
Supported
Produces target/equipment-lending-0.14.0.jar (it also runs the tests unless they are skipped)
4
Run: java -jar target/equipment-lending.jar
Contradicted
java -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).
5
mvn verify runs all 230 tests
Contradicted
233
6
…which pass in under 30 seconds
Not established
A 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
7
Visitors are enabled by default; -Dlending.visitors=false turns them off
Contradicted
The 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
8
A visitor may reserve CAM1 or CAM2 for one to three days when a sponsor approval matches
Supported
C2, C3, C4 of the requirements table
9
Approvals are read from a CSV file
Contradicted
Tab-separated with a fixed header and eight columns (FileApprovalSource.HEADER)
10
The file is cached in memory for speed
Contradicted
theFileIsReadAfreshOnEveryCall: a changed register is seen at once
11
If the directory does not answer within one second, the system retries three times and then treats the borrower as a student
Contradicted (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
12
scripts/pipeline.sh runs five stages…
Supported
Compile, unit tests and architecture checks, integration, end-to-end, package
13
…and deploys the application to the staging server
Contradicted
Stage 5 only packages. scripts/simulate-release.sh copies a jar into a local folder and is labelled a simulation. Nothing is deployed
14
Architecture rules are enforced by DependencyRulesTest
Partly supported
It checks the direction of dependencies between packages (and a separate test checks for cycles). It does not enforce every architecture rule
15
The system is production-ready and safe for concurrent use
Not established, and contradicted by the project’s own limits
KI-10 and KI-11: stand-in directory and register; concurrency not considered; an in-memory teaching system
16
Run bash scripts/demo.sh to see visitors rejected and then approved
Supported
The 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.
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.privatestaticfinalFinePolicyVISITOR=newFinePolicy(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:
Item
Content
README for a stranger
What 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 check
The runtime and tool versions with the output they should print; what to do if a tool is missing; the warnings that are not errors
Demonstration
A 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 evidence
Each 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 issues
Including what was not evaluated about the model: accuracy, fairness, drift, behaviour on data unlike the synthetic data
Decision record
The flag that gates a model version: the conditions for removal, the owner, what rollback does not undo (responses already served)
Limits statement
No 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.
Fault
Result
A named test is renamed
Caught: two of the three methods fail
An acceptance example loses its row
Caught: 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 mentions
Caught: the row check fails
A row names an extra test that exists
Not caught; harmless
AC3 is pointed at the AC1 test, which exists
Not 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.
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
Create the directory lab.
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 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:
Command
Expected
try-attempt.sh 1
BUILD FAILURE with two cannot find symbol errors, at AvailabilityFinder.java line 18 and line 20
try-attempt.sh 2 generated
Tests run: 7, Failures: 0, BUILD SUCCESS
try-attempt.sh 2 (acceptance)
BUILD FAILURE: reference to AvailabilityFinder is ambiguous in AvailabilityFinderTest
try-attempt.sh 2 adapted
Tests run: 9, Failures: 2: ex4_... and ex5_...
check-record.sh
checked docs/AI-RECORD-earliest-start.md and RECORD CHECK PASSED
check-record.sh where only the template exists
FAIL 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
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:
ID
Request
Result
EX1
from 20, 2 days, window 10
22 October
EX2
from 20, 3 days, window 10
26 October
EX3
from 10, 2 days, window 10; and no bookings, from 10, 5 days, window 1
10 October
EX4
from 20, 2 days, window 2
none
EX5
from 20, 2 days, window 3
22 October; and window 2 gives none
EX6
booking 18â20 only; from 20, 2 days, window 1
20 October
EX7
days 0, window 0, a null from, a null list
the exceptions above
EX8
the caller’s list gains a booking after construction
the finder is unchanged
EX9
one booking covering 1â31 October; from 5, 1 day, window 10
# 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 examplesbash scripts/try-attempt.sh 1# attempt 1 in a scratch copy: does not compilebash scripts/try-attempt.sh 2 generated # attempt 2 with its own generated tests: they passbash scripts/try-attempt.sh 2 adapted # attempt 2 with the acceptance examples: two failbash 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
packageassist;importjava.time.LocalDate;importjava.util.Objects;/** A half-open period of days. This is a copy of the lending project's LoanPeriod, so that the lab stands alone. */publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
packageassist;importjava.time.LocalDate;importjava.util.List;importjava.util.Objects;importjava.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.
*/publicfinalclassAvailabilityFinder{privatefinalList<LoanPeriod>booked;publicAvailabilityFinder(List<LoanPeriod>booked){this.booked=List.copyOf(Objects.requireNonNull(booked,"booked"));}publicOptional<LocalDate>earliestStart(LocalDatefrom,intdays,intsearchDays){Objects.requireNonNull(from,"from");if(days<1){thrownewIllegalArgumentException("days must be at least 1");}if(searchDays<1){thrownewIllegalArgumentException("searchDays must be at least 1");}for(intoffset=0;offset<searchDays;offset++){LocalDatestart=from.plusDays(offset);LoanPeriodwanted=newLoanPeriod(start,start.plusDays(days));if(booked.stream().noneMatch(wanted::overlaps)){returnOptional.of(start);}}returnOptional.empty();}}
packageassist;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Optional;importorg.junit.jupiter.api.Test;/** The acceptance examples EX1 to EX9, written from the requirement before any code was requested. */classAvailabilityFinderTest{privatestaticLocalDateoct(intday){returnLocalDate.of(2026,10,day);}privatestaticLoanPeriodperiod(intfrom,intendExclusive){returnnewLoanPeriod(oct(from),oct(endExclusive));}/** CAM1 is booked for [20, 22) and [24, 26). */privatestaticAvailabilityFindercam1(){returnnewAvailabilityFinder(List.of(period(20,22),period(24,26)));}@Testvoidex1_aBookingThatEndsOnADateDoesNotBlockAStartOnThatDate(){assertEquals(Optional.of(oct(22)),cam1().earliestStart(oct(20),2,10));}@Testvoidex2_aLongerStayMustFitBeforeTheNextBooking(){assertEquals(Optional.of(oct(26)),cam1().earliestStart(oct(20),3,10));}@Testvoidex3_aFreeStartDateIsReturnedImmediately(){assertEquals(Optional.of(oct(10)),cam1().earliestStart(oct(10),2,10));assertEquals(Optional.of(oct(10)),newAvailabilityFinder(List.of()).earliestStart(oct(10),5,1));}@Testvoidex4_theWindowCountsCandidateStartDatesAndNotDaysAfterFrom(){assertEquals(Optional.empty(),cam1().earliestStart(oct(20),2,2));}@Testvoidex5_theLastCandidateInTheWindowIsIncluded(){assertEquals(Optional.of(oct(22)),cam1().earliestStart(oct(20),2,3));assertEquals(Optional.empty(),cam1().earliestStart(oct(20),2,2));}@Testvoidex6_aBookingThatEndsOnFromDoesNotBlockFrom(){varfinder=newAvailabilityFinder(List.of(period(18,20)));assertEquals(Optional.of(oct(20)),finder.earliestStart(oct(20),2,1));}@Testvoidex7_invalidArgumentsAreRejected(){varfinder=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,()->newAvailabilityFinder(null));}@Testvoidex8_theCallersListIsCopiedAndNothingIsChanged(){List<LoanPeriod>callers=newArrayList<>(List.of(period(20,22)));varfinder=newAvailabilityFinder(callers);callers.add(period(22,24));assertEquals(Optional.of(oct(22)),finder.earliestStart(oct(20),2,10));assertEquals(2,callers.size());}@Testvoidex9_everyCandidateBlockedMeansEmpty(){varfinder=newAvailabilityFinder(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"];thenecho"Maven was not found (looked for: $MVN). Install Maven 3.9.11, or set CHAPTER_MVN to its full path." >&2exit2fiARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiSRC="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/"ficd"$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;exit2;;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];thenFILES=("$@")elseFILES=()for f in docs/AI-RECORD-*.md;do[ -e "$f"]||continue["$(basename "$f")"="AI-RECORD-TEMPLATE.md"]||FILES+=("$f")donefiif["${#FILES[@]}" -eq 0];thenecho"FAIL no assistance record to check: only the template was found, or no record exists"echo"RECORD CHECK FAILED: nothing was checked"exit1fiproblems=0for file in "${FILES[@]}";dofor heading in "${HEADINGS[@]}";doif ! grep -q "^## ${heading}\$""$file";thenecho"FAIL ${file}: missing heading '${heading}'"problems=$((problems +1))continuefi# the text between this heading and the next must contain a non-blank linebody="$(awk -v h="## ${heading}"'$0 == h {on=1; next} /^## / {on=0} on'"$file"| tr -d '[:space:]')"if[ -z "$body"];thenecho"FAIL ${file}: heading '${heading}' is empty"problems=$((problems +1))fidoneecho"checked ${file}"doneif["$problems" -eq 0];thenecho"RECORD CHECK PASSED";elseecho"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.
packageassist;importjava.time.LocalDate;importjava.util.List;importjava.util.Optional;// Author-written fictional AI output, constructed to illustrate. It is not a recorded service response.publicfinalclassAvailabilityFinder{privatefinalList<LoanPeriod>booked;publicAvailabilityFinder(List<LoanPeriod>booked){this.booked=List.copyOf(booked);}publicOptional<LocalDate>earliestStart(LocalDatefrom,intdays,intsearchDays){List<LocalDate>free=from.datesUntil(from.plusDays(searchDays)).filter(start->booked.stream().noneMatch(b->b.intersects(newLoanPeriod(start,start.plusDays(days))))).toList();returnfree.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.
packageassist;importjava.time.LocalDate;importjava.util.List;importjava.util.Optional;// Author-written fictional AI output, constructed to illustrate. It is not a recorded service response.publicfinalclassAvailabilityFinder{privatefinalAvailabilityRulerule;publicAvailabilityFinder(List<LoanPeriod>booked){this(AvailabilityRules.noOverlapWith(booked));}AvailabilityFinder(AvailabilityRulerule){this.rule=rule;}publicOptional<LocalDate>earliestStart(LocalDatefrom,intdays,intsearchDays){if(from==null)thrownewNullPointerException("from");if(days<1||searchDays<1)thrownewIllegalArgumentException("days and searchDays must be positive");for(intoffset=0;offset<=searchDays;offset++){LocalDatestart=from.plusDays(offset);if(rule.isFree(newLoanPeriod(start,start.plusDays(days)))){returnOptional.of(start);}}returnOptional.empty();}}
4.4.2 lab/attempts/attempt2/AvailabilityRule.java
packageassist;// Author-written fictional AI output, constructed to illustrate.interfaceAvailabilityRule{booleanisFree(LoanPeriodwanted);}
packageassist;importjava.util.List;// Author-written fictional AI output, constructed to illustrate.finalclassAvailabilityRules{privateAvailabilityRules(){}staticAvailabilityRulenoOverlapWith(List<LoanPeriod>booked){List<LoanPeriod>copy=List.copyOf(booked);returnwanted->copy.stream().noneMatch(wanted::overlaps);}}
packageassist;import staticorg.junit.jupiter.api.Assertions.*;importjava.time.LocalDate;importjava.util.List;importjava.util.Optional;importorg.junit.jupiter.api.Test;// Author-written fictional AI output, constructed to illustrate. Generated from the code above.classAvailabilityFinderGeneratedTest{privatestaticLocalDateoct(intday){returnLocalDate.of(2026,10,day);}privatestaticLoanPeriodperiod(intfrom,intend){returnnewLoanPeriod(oct(from),oct(end));}@TestvoidfindsTheFirstFreeDayAfterABooking(){varfinder=newAvailabilityFinder(List.of(period(20,22),period(24,26)));assertEquals(Optional.of(oct(22)),finder.earliestStart(oct(20),2,10));}@TestvoidreturnsFromWhenNothingIsBooked(){assertEquals(Optional.of(oct(10)),newAvailabilityFinder(List.of()).earliestStart(oct(10),3,5));}@TestvoidskipsALongStayThatDoesNotFit(){varfinder=newAvailabilityFinder(List.of(period(20,22),period(24,26)));assertEquals(Optional.of(oct(26)),finder.earliestStart(oct(20),3,10));}@TestvoidtheSearchWindowIsInclusiveOfTheLastDay(){varfinder=newAvailabilityFinder(List.of(period(20,22)));assertEquals(Optional.of(oct(22)),finder.earliestStart(oct(20),2,2));}@TestvoidreturnsEmptyWhenTheWindowIsFullyBooked(){varfinder=newAvailabilityFinder(List.of(period(1,31)));assertEquals(Optional.empty(),finder.earliestStart(oct(5),1,3));}@TestvoidrejectsNonPositiveDays(){varfinder=newAvailabilityFinder(List.of());assertThrows(IllegalArgumentException.class,()->finder.earliestStart(oct(1),0,3));}@TestvoidrejectsNullFrom(){assertThrows(NullPointerException.class,()->newAvailabilityFinder(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
Command
Result
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 *.java
Two errors: the same, and cannot find symbol, symbol: method getFirst(), location: variable free of type List<LocalDate>
After changing only intersects to overlaps: plain javac
Both 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
Run
Result
Attempt 2 with its own seven generated tests
7 pass
Attempt 2 with the acceptance tests as written
Does 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 finder
6 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.
#
Claim
Verdict
Evidence
1
The flag is checked first and the ânot enabledâ text is returned
Supported
The first statement of rejection is if (!enabled) return Optional.of(DISABLED)
2
Then the item is checked against CAM1 or CAM2
Supported
ALLOWED_ITEMS = Set.of("CAM1", "CAM2") and the check that follows
3
Fewer than one day returns empty without asking anyone, because the policy will reject it
Supported
if (days < 1) return Optional.empty() and its comment; ac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister asserts that the register is not read; VisitorReservationRules.mayRequest requires requestedDays >= 1
4
The clock is read once
Supported
oneRequestReadsTheClockOnce
5
The exact request is asked of the source, and the request is rejected unless the record matches and is valid at that instant
The 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)
7
Each rejection is written to the audit log
Contradicted
The class has no logger and no call that writes anywhere
8
If the source throws, the gate returns the âno valid sponsor approvalâ rejection
Contradicted
The 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
Prompt
Contains
Does not contain
prompt1.md
The task sentence; the source of LoanPeriod; the constraints (Java 17, standard library only, no new dependencies); the output format; the scope
The acceptance examples; any statement of how the window counts
prompt2.md
The two compiler findings; the request for a small solution and tests
The 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 prompt
Why it should not be sent
In the second prompt
A student’s name, student ID and email
Personal data about a real borrower
Keys 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 pasted
No configuration; the method only
An internal address and path
Information about your infrastructure
<base>, no real URL
200 lines of production log with other borrowers’ names
More personal data, and far more than the question needs
Three 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.
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.@TestvoidtheSearchWindowIsInclusiveOfTheLastDay(){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
Item
Content
Requirement and examples
The 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 share
The function signature, the record type, the constraints. Synthetic records only
Not to share
Real records, keys, tokens, the registry’s address, any real model or customer identifiers
Independent checks
The compiler under the project’s settings; the examples; a fault experiment on the tests; a trace on selected inputs
Dependencies
Look up any suggested library; ask whether the standard library already does it; pin the version
Record
Task, tool and model, context, each attempt, decision, changes, verification, limits
What it does not show about the model
Nothing 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 product
The 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).
Fault
Acceptance examples
Generated tests
The window loop runs one step too far (offset <= searchDays), which is attempt 2’s own fault
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.
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].
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.
importjava.time.LocalDate;importjava.util.Objects;publicfinalclassLoanPeriod{privatefinalLocalDatestart;privatefinalLocalDateendExclusive;publicLoanPeriod(LocalDatestart,LocalDateendExclusive){this.start=Objects.requireNonNull(start,"start");this.endExclusive=Objects.requireNonNull(endExclusive,"endExclusive");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("End must be after start.");}}publicLocalDatestart(){returnstart;}publicLocalDateendExclusive(){returnendExclusive;}publicbooleanoverlaps(LoanPeriodother){Objects.requireNonNull(other,"other");returnstart.isBefore(other.endExclusive)&&other.start.isBefore(endExclusive);}}
2.4 ReadinessLab.java
importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.LocalDate;importjava.util.ArrayList;importjava.util.List;importjava.util.Objects;importjava.util.Optional;publicfinalclassReadinessLab{privatestaticintchecks;// These nested types keep the isolated demonstration in four source files.enumItemKind{CAMERA,MICROPHONE}recordRequest(StringborrowerId,intdays){Request{Objects.requireNonNull(borrowerId,"borrowerId");if(borrowerId.isBlank()||days<1){thrownewIllegalArgumentException("A request needs an id and positive days.");}}}publicstaticvoidmain(String[]args)throwsIOException{BorrowingPolicypolicy=newStudentRules();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));Stringleft=newString("CAM1");Stringright=newString("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=newArrayList<>();List<String>second=first;second.add("CAM1");second=newArrayList<>();check(first.equals(List.of("CAM1")),"alias mutation reaches first");check(second.isEmpty(),"reassignment selects another list");finalList<String>mutable=newArrayList<>();mutable.add("CAM1");check(mutable.size()==1,"final reference permits object mutation");System.out.println("Alias contents: "+first);List<String>source=newArrayList<>(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);StringBuildername=newStringBuilder("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");LocalDatestart=LocalDate.of(2026,10,20);LoanPeriodperiod=newLoanPeriod(start,start.plusDays(2));LoanPeriodadjacent=newLoanPeriod(start.plusDays(2),start.plusDays(4));LoanPeriodcrossing=newLoanPeriod(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(newLoanPeriod(start,start.plusDays(2))),"identical bounds overlap");expect(IllegalArgumentException.class,()->newLoanPeriod(start,start),"empty period rejected");expect(NullPointerException.class,()->newLoanPeriod(null,start),"missing date rejected");check(!period.equals(newLoanPeriod(start,start.plusDays(2))),"ordinary class has identity equality here");System.out.println("Adjacent periods overlap: "+period.overlaps(adjacent));longcameras=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(newRequest("B1",2).equals(newRequest("B1",2)),"record has component equality");Stringlabel=switch(ItemKind.CAMERA){caseCAMERA->"Camera";caseMICROPHONE->"Microphone";};check(label.equals("Camera"),"switch expression selects a result");Pathdirectory=Files.createTempDirectory("se506-readiness-");Pathfile=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"));thrownewAssertionError("missing file must fail");}catch(IOExceptionexpected){check(true,"checked file failure propagated");}}finally{Files.deleteIfExists(file);Files.deleteIfExists(directory);}System.out.println("READINESS CHECKS PASSED: "+checks);}staticStringreadFirstLine(Pathpath)throwsIOException{try(varreader=Files.newBufferedReader(path,StandardCharsets.UTF_8)){returnreader.readLine();}}privatestaticvoidcheck(booleancondition,Stringdescription){if(!condition){thrownewAssertionError(description);}checks++;}privatestaticvoidexpect(Class<?extendsRuntimeException>type,Runnableoperation,Stringdescription){try{operation.run();}catch(RuntimeExceptionexception){if(!type.isInstance(exception)){thrownewAssertionError(description+": wrong exception",exception);}checks++;return;}thrownewAssertionError(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
Item
Answer and explanation
D1
true, 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.
D2
first 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.
D3
false, then true: two explicitly created objects, equal string contents. Matching text does not require reference identity.
D4
Adding compiles; reassigning the final variable does not. The list object remains mutable.
D5
Source: CAM1, CAM2; snapshot: CAM1. Clearing the snapshot throws UnsupportedOperationException. Mutable element state can still change through shared references.
D6
Reject 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.
D7
Declared type: BorrowingPolicy; implementation: StudentRules. Interface syntax establishes callable operations, not proof of the policy’s correctness.
D8
Missing declared method: compile-time error. Null receiver: run-time NullPointerException. Wrong comparison: a logical defect that can compile and execute normally.
D9
No 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.
D10
Department-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.
Fragment
Interpretation
Pack *-- Page
Pack is the composite whole; Page is the part. The model must justify exclusive composite ownership and the intended lifecycle.
Pack o-- Article
Pack groups articles; explain what shared aggregation means in that model. A plain association may be sufficient.
Article <|-- ResearchArticle
ResearchArticle specialises Article; the triangle points to Article.
Printer <|.. PdfPrinter
PdfPrinter implements Printer; the triangle points to the interface.
Pack ..> Printer
Pack 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.
Fragment
Interpretation
U->>R: mayRequest(2, 7) followed by R-->>U: true
U 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 … end
Two alternatives. Exactly one happens in a given run.
AVAILABLE --> RESERVED: reserve
When 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 no
A decision with two labelled exits. Each path continues from the exit that matches the answer.
I --> A in a dependency view
Code 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 claim
Verdict
Correction and evidence
âThe list is final, so callers cannot clear it.â
Incorrect
A 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.â
Incorrect
A 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.â
Incorrect
Construction 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
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:
packageedu.se506.setup;/** Reports which Java runtime is executing and which release the code was compiled for. */publicfinalclassToolchainReport{/** The language and API level the build targets (see maven.compiler.release in pom.xml). */publicstaticfinalintRELEASE_TARGET=17;privateToolchainReport(){}publicstaticintjavaFeatureVersion(){returnRuntime.version().feature();}/** Code compiled for a release runs on that Java version and on every later one. */publicstaticbooleancanRunOn(intruntimeFeature){returnruntimeFeature>=RELEASE_TARGET;}publicstaticStringsummary(){return"Running on Java "+javaFeatureVersion()+"; compiled for release target "+RELEASE_TARGET;}publicstaticvoidmain(String[]args){System.out.println(summary());}}
packageedu.se506.setup;import staticorg.junit.jupiter.api.Assertions.assertEquals;import staticorg.mockito.Mockito.mock;import staticorg.mockito.Mockito.verify;import staticorg.mockito.Mockito.when;importorg.junit.jupiter.api.Test;/** Checks that Mockito is configured. It tests the framework setup, not any domain behaviour. */classGreeterMockTest{interfaceGreeter{Stringgreet(Stringname);}@TestvoidaMockOfAnInterfaceAnswersWhatItIsTold(){Greetergreeter=mock(Greeter.class);when(greeter.greet("Ada")).thenReturn("Hello, Ada");assertEquals("Hello, Ada",greeter.greet("Ada"));verify(greeter).greet("Ada");}}
#!/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=0report(){printf'%-8s %s\n'"$1""$2"}# Report the first line of the output of a tool's version command.need(){locallabel="$1"shiftifcommand -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=1fi}need "bash" bash --version
need "git" git --version
need "java" java -version
need "maven""$MVN" --version
ifcommand -v javac >/dev/null 2>&1;thenfeature="$(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=1fielse report "MISSING""javac: a JDK (not only a JRE) is needed"status=1fiif["$status" -eq 0];thenecho"SETUP OK"elseecho"SETUP INCOMPLETE"fiexit"$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:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiif["${CHAPTER_OFFLINE:-0}"="1"];thenARGS+=(--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:Verifyon:push:pull_request:workflow_dispatch:permissions:contents:readjobs:verify:runs-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Build and testrun: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.
Change
Command
What must happen
Name a Maven command that does not exist: CHAPTER_MVN=/nonexistent/mvn
bash scripts/check-setup.sh
A MISSING maven line, SETUP INCOMPLETE and exit status 1.
Put a fake javac that prints javac 1.8.0_999 first on the path
bash scripts/check-setup.sh
A TOO-OLD javac line and exit status 1.
In ToolchainReport.canRunOn, change >= to >
bash scripts/check.sh
One failing invocation (the parameterised case for 17), BUILD FAILURE, a non-zero status, and no BUILD AND TESTS PASSED line.
The same change
mvn --batch-mode -DskipTests verify
Tests are skipped. and BUILD SUCCESS: the fault is not detected.
The 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
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
Setup check.SETUP OK. If a line starts MISSING or TOO-OLD, the named tool is the problem.
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â.
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.
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.
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 sentence
Verdict
Evidence and correction
âAny Java installation will do because the code is plain Java.â
Incorrect
The 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.â
Incorrect
The 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.â
Unsafe
git 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
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:
Template
First used in
Follows
story-card
Chapter 3
The story-card fields of the Chapter 3 companion
acceptance-example
Chapter 3
The given, when, then form of Section 3.7
definition-of-done
Chapters 3 and 4
The proposed Definition of Done and its Chapter 4 extension
decision-record
Chapters 4, 10, 13, 14
The headings of ADR-010, ADR-013 and ADR-014
iteration-plan
Chapter 4
The capacity and forecast tables of Section 4.4
team-agreement
Chapter 4
The team agreement of Section 4.5
pull-request
Chapters 2 and 4
The problem, change and checks of a review description
evidence-note
Chapter 4
The evidence-note fields of the Chapter 4 companion
requirements-to-evidence
Chapter 14
docs/REQUIREMENTS-TO-EVIDENCE.md
known-issues
Chapter 14
docs/KNOWN-ISSUES.md
release-notes
Chapter 14
docs/RELEASE-NOTES-0.14.0.md
retrospective
Chapter 14
The observation, evidence, action and owner table of Section 14.6
ai-assistance-record
Chapter 15
The 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
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.
Replace every {{placeholder}}. A placeholder left in a document is a promise you did not keep.
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.
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.
Say what is constructed. An example, a sample or a model answer is labelled as one.
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 check
Only a reviewer can check
Every required heading is present
That the answer under it is correct
No heading is empty
That âreviewed and verifiedâ describes a real review
No {{placeholder}} is left
That the evidence named is the right evidence
An example is labelled as constructed
That 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";; *)return1;;esac}problems=0checked=0fail(){echo"FAIL $*"problems=$((problems +1))}# check_file KIND FILE NAME (KIND is template, example or mine)check_file(){localkind="$1"file="$2"name="$3" item body
if[ ! -f "$file"];then fail "$file: no such file"returnfichecked=$((checked +1))whileIFS=read -r item;docase"$item" in
"## "*)if ! grep -qxF "$item""$file";then fail "$file: missing heading '${item#\#\#}'"elif["$kind" != template ];thenbody="$(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;;esacdone < <(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"fiif["$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"elsefor name in "${NAMES[@]}";do check_file template "templates/${name}.md""$name" check_file example "examples/${name}.md""$name"donefiif["$checked" -eq 0];thenecho"FAIL nothing was checked"echo"TEMPLATE CHECK FAILED"exit1fiif["$problems" -gt 0];thenecho"TEMPLATE CHECK FAILED: ${problems} problem(s) in ${checked} file(s)"exit1fiecho"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:
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.
Change
What must happen
Delete the ## Limits heading from examples/ai-assistance-record.md
FAIL ... missing heading 'Limits', exit status 1
Empty the text under ## Problem in examples/pull-request.md
FAIL ... 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.md
FAIL ... the first line must label it as a constructed example
Remove every {{ and }} from templates/retrospective.md
FAIL ... a template must still show its {{placeholders}}
Delete examples/known-issues.md
FAIL ... no such file, and 25 files checked
Empty the value after Status: in examples/decision-record.md
FAIL ... 'Status:' has no value
Delete ## Revisit triggers from templates/decision-record.md
FAIL ... missing heading 'Revisit triggers'
Delete the templates and examples folders
FAIL nothing was checked, exit status 1
--file pull-request with a path that does not exist
FAIL 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
Template
Mistakes to look for
Story card
A feature name with no actor and no value; guesses listed as confirmed rules; âCompletion evidenceâ filled in before anything exists
Acceptance example
A 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 Done
Conditions 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 record
Recording a decision after the fact without saying so; editing an old record; listing only the chosen option; âreviewedâ when nobody reviewed it
Iteration plan
Allocating every hour to features; converting story points into hours; hiding an overrun by skipping checks; treating unallocated hours as a promise
Team agreement
Copying another team’s agreement; an empty gaps section; never rereading it
Pull request
A 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 note
Planned behaviour written as implemented; the result of one revision cited for another; guidance text left in place of an answer
Requirements to evidence
A test that exists but checks something else; a table nobody rereads
Known issues
A 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â
Retrospective
An observation with no evidence; actions with no owner or date; naming a person as the cause
AI assistance record
Writing 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
Check the kit.TEMPLATE CHECK PASSED: 26 file(s).
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.
Break your file three ways. Each break gives a different message (Section 4.3); restoring the file gives a pass.
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.
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.
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.
Yes. Open the repository and the run pages; compare the revision and the date
Nothing 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 exist
A 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 exist
No named reviewer, no finding, no correction; it is the ritual version
âNoneâ under both questions and limitations
No
A 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.
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.
Nygard, M., Documenting Architecture Decisions, 15 November 2011. Short numbered records: title, context, decision, status, consequences; superseded and not deleted.
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
importjava.io.BufferedReader;importjava.io.InputStreamReader;importjava.net.HttpURLConnection;importjava.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.
*/publicclassUnsafeRiskScorer{privatefinalStringserviceUrl;publicUnsafeRiskScorer(StringserviceUrl){this.serviceUrl=serviceUrl;}publicbooleanapprove(StringborrowerId,StringitemId){try{HttpURLConnectionconnection=(HttpURLConnection)URI.create(serviceUrl+"/risk?borrower="+borrowerId+"&item="+itemId).toURL().openConnection();BufferedReaderin=newBufferedReader(newInputStreamReader(connection.getInputStream()));Stringline=in.readLine();String[]parts=line.split(" ");floatscore=Float.parseFloat(parts[1]);if(parts[0].equals("LOW")){returnscore>0.8f;}returnfalse;}catch(Exceptionanything){returnfalse;}}}
2.2 starter/StarterProbe.java
importcom.sun.net.httpserver.HttpServer;importjava.net.InetAddress;importjava.net.InetSocketAddress;importjava.nio.charset.StandardCharsets;/** Runs UnsafeRiskScorer against a small local service and prints what it does. */publicclassStarterProbe{publicstaticvoidmain(String[]args)throwsException{HttpServerserver=HttpServer.create(newInetSocketAddress(InetAddress.getLoopbackAddress(),0),0);server.createContext("/risk",exchange->{Stringquery=exchange.getRequestURI().getQuery();Stringbody="LOW 0.95";if(query.contains("borrower=BADSCORE")){body="LOW 1.7";}elseif(query.contains("borrower=SLOW")){try{Thread.sleep(1500);}catch(InterruptedExceptionignored){Thread.currentThread().interrupt();}}byte[]bytes=body.getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(200,bytes.length);exchange.getResponseBody().write(bytes);exchange.close();});server.start();Stringurl="http://127.0.0.1:"+server.getAddress().getPort();UnsafeRiskScorerscorer=newUnsafeRiskScorer(url);System.out.println("good day : "+scorer.approve("B1","CAM1"));System.out.println("confidence of 1.7 : "+scorer.approve("BADSCORE","CAM1"));longbegan=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
packagerisk.support;importjava.time.Clock;importjava.time.Instant;importjava.time.ZoneOffset;importrisk.domain.RiskAssessment;importrisk.domain.RiskLevel;/** Small shared test values. The clock is fixed so that no test depends on the time of day. */publicfinalclassFixtures{publicstaticfinalInstantNOW=Instant.parse("2026-10-13T12:00:00Z");publicstaticfinalClockCLOCK=Clock.fixed(NOW,ZoneOffset.UTC);privateFixtures(){}publicstaticRiskAssessmentassessment(RiskLevellevel,doubleconfidence){returnnewRiskAssessment(level,confidence,NOW);}}
packagerisk.support;importjava.util.HashMap;importjava.util.Map;importjava.util.Objects;importjava.util.concurrent.atomic.AtomicInteger;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.domain.RiskLevel;/** A deterministic, hand-written assessor for tests: no network, no model, and it counts its calls. */publicfinalclassFakeItemRiskAssessorimplementsItemRiskAssessor{privatefinalMap<String,RiskAssessment>answers=newHashMap<>();privateRiskAssessmentdefaultAnswer=Fixtures.assessment(RiskLevel.LOW,0.95);privatebooleanfailing;privatefinalAtomicIntegercalls=newAtomicInteger();publicFakeItemRiskAssessoranswer(StringborrowerId,RiskAssessmentassessment){answers.put(borrowerId,Objects.requireNonNull(assessment));returnthis;}publicFakeItemRiskAssessordefaultAnswer(RiskAssessmentassessment){this.defaultAnswer=Objects.requireNonNull(assessment);returnthis;}publicFakeItemRiskAssessorfailing(){this.failing=true;returnthis;}publicintcalls(){returncalls.get();}@OverridepublicRiskAssessmentassessRisk(StringborrowerId,StringitemId){calls.incrementAndGet();if(failing){thrownewRiskAssessmentException("The fake was told to fail.");}returnanswers.getOrDefault(borrowerId,defaultAnswer);}}
packagerisk.support;importcom.sun.net.httpserver.HttpExchange;importcom.sun.net.httpserver.HttpServer;importjava.io.IOException;importjava.net.InetAddress;importjava.net.InetSocketAddress;importjava.net.URI;importjava.net.URLDecoder;importjava.nio.charset.StandardCharsets;importjava.util.concurrent.CountDownLatch;importjava.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.
*/publicfinalclassStubRiskServiceimplementsAutoCloseable{privatefinalHttpServerserver;privatefinalCountDownLatchrelease=newCountDownLatch(1);privatevolatileStringlastQuery="";publicStubRiskService()throwsIOException{server=HttpServer.create(newInetSocketAddress(InetAddress.getLoopbackAddress(),0),0);server.createContext("/risk",this::handle);server.start();}publicURIbaseUri(){returnURI.create("http://127.0.0.1:"+server.getAddress().getPort());}/** The raw query of the last request, so a test can check what was sent. */publicStringlastQuery(){returnlastQuery;}privatevoidhandle(HttpExchangeexchange)throwsIOException{Stringquery=exchange.getRequestURI().getRawQuery();lastQuery=query==null?"":query;Stringborrower="";for(Stringpair:lastQuery.split("&")){if(pair.startsWith("borrower=")){borrower=URLDecoder.decode(pair.substring("borrower=".length()),StandardCharsets.UTF_8);}}intstatus=200;booleanstallBody=false;Stringbody;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(IOExceptionclientGone){// the client gave up (a timeout test); nothing more to send}finally{exchange.close();}}privatevoidawaitRelease(){try{release.await(30,TimeUnit.SECONDS);}catch(InterruptedExceptioninterrupted){Thread.currentThread().interrupt();}}@Overridepublicvoidclose(){release.countDown();server.stop(0);}}
packagerisk.adapter;import staticorg.junit.jupiter.api.Assertions.assertEquals;import staticorg.junit.jupiter.api.Assertions.assertFalse;import staticorg.junit.jupiter.api.Assertions.assertThrows;import staticorg.junit.jupiter.api.Assertions.assertTrue;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.domain.RiskLevel;importrisk.support.Fixtures;classRuleBasedRiskAssessorTest{privatestaticRiskAssessmentassess(intviolations){returnnewRuleBasedRiskAssessor(borrower->violations,Fixtures.CLOCK).assessRisk("B1","CAM1");}@ParameterizedTest@CsvSource({"0, LOW","1, MODERATE","2, MODERATE","3, HIGH","9, HIGH"})voidtheLevelFollowsTheNumberOfViolations(intviolations,Stringexpected){assertEquals(RiskLevel.valueOf(expected),assess(violations).level());}@ParameterizedTest@CsvSource({"0, 1","0, 3","1, 2","2, 3","0, 8","3, 4","2, 9"})voidmoreViolationsNeverLowerTheRisk(intfewer,intmore){// a metamorphic property: it does not say what the level is, only how two answers relateassertTrue(assess(more).level().compareTo(assess(fewer).level())>=0);}@ParameterizedTest@CsvSource({"0","1","5"})voidtheCautiousFallbackNeverApprovesWithoutAPerson(intviolations){assertFalse(assess(violations).permitsAutomaticApproval());}@TestvoidaNegativeCountIsAFailureAndNotALowRisk(){assertThrows(RiskAssessmentException.class,()->assess(-1));}}
packagerisk.adapter;import staticorg.junit.jupiter.api.Assertions.assertEquals;import staticorg.junit.jupiter.api.Assertions.assertThrows;import staticorg.junit.jupiter.api.Assertions.assertTrue;importjava.net.URI;importjava.time.Duration;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.Timeout;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.ValueSource;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.domain.RiskLevel;importrisk.support.Fixtures;importrisk.support.StubRiskService;/** The adapter against a real HTTP client and a service under our control: one test per outcome. */@Tag("integration")classHttpRiskPredictionAdapterContractTest{privatestaticfinalDurationBOUND=Duration.ofMillis(300);privateStubRiskServicestub;privateHttpRiskPredictionAdapteradapter;@BeforeEachvoidstartStub()throwsException{stub=newStubRiskService();adapter=newHttpRiskPredictionAdapter(stub.baseUri(),Duration.ofSeconds(1),BOUND,Fixtures.CLOCK);}@AfterEachvoidstopStub(){stub.close();}@TestvoidaGoodAnswerBecomesATypedAssessment(){RiskAssessmentanswer=adapter.assessRisk("LOWB","CAM1");assertEquals(RiskLevel.LOW,answer.level());assertEquals(0.93,answer.confidence());assertEquals(Fixtures.NOW,answer.assessedAt());}@TestvoidtheLevelAndConfidenceOfEachAnswerAreKept(){assertEquals(RiskLevel.MODERATE,adapter.assessRisk("MODB","CAM1").level());assertEquals(RiskLevel.HIGH,adapter.assessRisk("HIGHB","CAM1").level());}@TestvoididentifiersAreEncodedInTheRequest(){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"})voideverythingExceptAGoodAnswerIsAFailureAndNeverADefault(Stringborrower){assertThrows(RiskAssessmentException.class,()->adapter.assessRisk(borrower,"CAM1"));}@Test@Timeout(5)voidaServiceThatSendsNothingIsGivenUpOnWithinTheBound(){longbegan=System.nanoTime();assertThrows(RiskAssessmentException.class,()->adapter.assessRisk("SLOW","CAM1"));assertTrue(elapsedMillis(began)<BOUND.toMillis()*4,"waited too long");}@Test@Timeout(5)voidaServiceThatStallsInTheMiddleOfTheBodyIsAlsoBounded(){longbegan=System.nanoTime();assertThrows(RiskAssessmentException.class,()->adapter.assessRisk("SLOWBODY","CAM1"));assertTrue(elapsedMillis(began)<BOUND.toMillis()*4,"waited too long");}@TestvoidaServiceThatIsNotThereIsAFailure(){HttpRiskPredictionAdapternobody=newHttpRiskPredictionAdapter(URI.create("http://127.0.0.1:1"),Duration.ofMillis(300),BOUND,Fixtures.CLOCK);assertThrows(RiskAssessmentException.class,()->nobody.assessRisk("LOWB","CAM1"));}privatestaticlongelapsedMillis(longbeganNanos){return(System.nanoTime()-beganNanos)/1_000_000;}}
packagerisk.adapter;import staticorg.junit.jupiter.api.Assertions.assertEquals;import staticorg.junit.jupiter.api.Assertions.assertSame;import staticorg.junit.jupiter.api.Assertions.assertThrows;import staticorg.junit.jupiter.api.Assertions.assertTrue;importjava.time.Duration;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.TimeUnit;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.Timeout;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.domain.RiskLevel;importrisk.support.FakeItemRiskAssessor;importrisk.support.Fixtures;classResilientRiskAssessorTest{privatestaticfinalDurationLIMIT=Duration.ofMillis(200);privatestaticfinalRiskAssessmentPRIMARY=Fixtures.assessment(RiskLevel.LOW,0.95);privatestaticfinalRiskAssessmentCAUTIOUS=Fixtures.assessment(RiskLevel.MODERATE,0.50);privatestaticItemRiskAssessorstalled(CountDownLatchrelease){return(borrower,item)->{try{release.await();}catch(InterruptedExceptioninterrupted){Thread.currentThread().interrupt();}returnPRIMARY;};}@TestvoidaTimelyPrimaryAnswerIsUsedAndTheFallbackIsNotTouched(){FakeItemRiskAssessorfallback=newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS);try(ResilientRiskAssessorassessor=newResilientRiskAssessor(newFakeItemRiskAssessor().defaultAnswer(PRIMARY),fallback,LIMIT)){assertSame(PRIMARY,assessor.assessRisk("B1","CAM1"));assertEquals(0,fallback.calls());assertEquals(0,assessor.fallbackCount());}}@Test@Timeout(5)voidaStalledPrimaryIsGivenUpOnWithinTheLimitAndTheFallbackAnswers(){CountDownLatchnever=newCountDownLatch(1);try(ResilientRiskAssessorassessor=newResilientRiskAssessor(stalled(never),newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS),LIMIT)){longbegan=System.nanoTime();RiskAssessmentanswer=assessor.assessRisk("B1","CAM1");longmillis=(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)voidaStalledPrimaryIsAskedToStop()throwsInterruptedException{CountDownLatchaskedToStop=newCountDownLatch(1);CountDownLatchnever=newCountDownLatch(1);ItemRiskAssessorprimary=(borrower,item)->{try{never.await();}catch(InterruptedExceptioninterrupted){askedToStop.countDown();}returnPRIMARY;};try(ResilientRiskAssessorassessor=newResilientRiskAssessor(primary,newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS),LIMIT)){assessor.assessRisk("B1","CAM1");assertTrue(askedToStop.await(2,TimeUnit.SECONDS),"the stalled call was not asked to stop");}finally{never.countDown();}}@TestvoidanExpectedFailureOfThePrimaryLeadsToTheFallback(){try(ResilientRiskAssessorassessor=newResilientRiskAssessor(newFakeItemRiskAssessor().failing(),newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS),LIMIT)){assertSame(CAUTIOUS,assessor.assessRisk("B1","CAM1"));assertEquals(1,assessor.fallbackCount());}}@TestvoidaProgrammingErrorInThePrimaryIsNotHiddenByTheFallback(){ItemRiskAssessorbuggy=(borrower,item)->{thrownewIllegalStateException("bug");};FakeItemRiskAssessorfallback=newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS);try(ResilientRiskAssessorassessor=newResilientRiskAssessor(buggy,fallback,LIMIT)){assertThrows(IllegalStateException.class,()->assessor.assessRisk("B1","CAM1"));assertEquals(0,fallback.calls());}}@TestvoidifTheFallbackAlsoFailsTheCallerSeesTheFailureAndNotAnApproval(){try(ResilientRiskAssessorassessor=newResilientRiskAssessor(newFakeItemRiskAssessor().failing(),newFakeItemRiskAssessor().failing(),LIMIT)){assertThrows(RiskAssessmentException.class,()->assessor.assessRisk("B1","CAM1"));}}@TestvoidaPrimaryThatReturnsNullIsABugAndIsReportedAsOne(){ItemRiskAssessorreturnsNull=(borrower,item)->null;try(ResilientRiskAssessorassessor=newResilientRiskAssessor(returnsNull,newFakeItemRiskAssessor().defaultAnswer(CAUTIOUS),LIMIT)){assertThrows(NullPointerException.class,()->assessor.assessRisk("B1","CAM1"));}}@Test@Timeout(5)voidanInterruptedCallerKeepsItsInterruptFlagAndGetsAFailure(){CountDownLatchnever=newCountDownLatch(1);try(ResilientRiskAssessorassessor=newResilientRiskAssessor(stalled(never),newFakeItemRiskAssessor().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();}}}
packagerisk.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.
*/publicenumRiskLevel{LOW,MODERATE,HIGH,UNKNOWN}
4.2 src/main/java/risk/domain/RiskAssessment.java
packagerisk.domain;importjava.time.Instant;importjava.util.Objects;/** One answer to the question âhow risky is it to lend this item to this borrower?â. */publicrecordRiskAssessment(RiskLevellevel,doubleconfidence,InstantassessedAt){/** The least confidence at which a LOW assessment may approve a request without a person. */publicstaticfinaldoubleAUTOMATIC_APPROVAL_CONFIDENCE=0.80;publicRiskAssessment{Objects.requireNonNull(level,"level");Objects.requireNonNull(assessedAt,"assessedAt");if(Double.isNaN(confidence)||confidence<0.0||confidence>1.0){thrownewIllegalArgumentException("Confidence must be between 0.0 and 1.0: "+confidence);}}/** Only a LOW level held with enough confidence may skip a person. */publicbooleanpermitsAutomaticApproval(){returnlevel==RiskLevel.LOW&&confidence>=AUTOMATIC_APPROVAL_CONFIDENCE;}}
packagerisk.domain;/** There is no trustworthy assessment: the service failed, did not answer in time or answered nonsense. */publicclassRiskAssessmentExceptionextendsRuntimeException{publicRiskAssessmentException(Stringmessage){super(message);}publicRiskAssessmentException(Stringmessage,Throwablecause){super(message,cause);}}
packagerisk.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.
*/publicinterfaceItemRiskAssessor{RiskAssessmentassessRisk(StringborrowerId,StringitemId);}
4.5 src/main/java/risk/domain/Route.java
packagerisk.domain;/** What happens to a request after it has been screened. */publicenumRoute{AUTO_APPROVE,SUPERVISOR_REVIEW,REJECT}
packagerisk.domain;importjava.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.
*/publicfinalclassReservationScreen{privatefinalItemRiskAssessorassessor;publicReservationScreen(ItemRiskAssessorassessor){this.assessor=Objects.requireNonNull(assessor,"assessor");}publicRouteroute(StringborrowerId,StringitemId){requireText(borrowerId,"borrowerId");requireText(itemId,"itemId");RiskAssessmentassessment=assessor.assessRisk(borrowerId,itemId);if(assessment.level()==RiskLevel.HIGH){returnRoute.REJECT;}returnassessment.permitsAutomaticApproval()?Route.AUTO_APPROVE:Route.SUPERVISOR_REVIEW;}privatestaticvoidrequireText(Stringvalue,Stringname){if(value==null||value.isBlank()){thrownewIllegalArgumentException(name+" must not be blank.");}}}
packagerisk.adapter;/** How many times a borrower has returned equipment late. */publicinterfaceViolationHistory{intoverdueViolations(StringborrowerId);}
packagerisk.adapter;importjava.time.Clock;importjava.util.Objects;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.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.
*/publicfinalclassRuleBasedRiskAssessorimplementsItemRiskAssessor{staticfinaldoubleCONFIDENCE=0.50;privatefinalViolationHistoryhistory;privatefinalClockclock;publicRuleBasedRiskAssessor(ViolationHistoryhistory,Clockclock){this.history=Objects.requireNonNull(history,"history");this.clock=Objects.requireNonNull(clock,"clock");}@OverridepublicRiskAssessmentassessRisk(StringborrowerId,StringitemId){intviolations=history.overdueViolations(borrowerId);if(violations<0){thrownewRiskAssessmentException("The history reported a negative number of violations.");}RiskLevellevel=violations==0?RiskLevel.LOW:violations<3?RiskLevel.MODERATE:RiskLevel.HIGH;returnnewRiskAssessment(level,CONFIDENCE,clock.instant());}}
packagerisk.adapter;importjava.net.URI;importjava.net.URLEncoder;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.nio.charset.StandardCharsets;importjava.time.Clock;importjava.time.Duration;importjava.util.Objects;importjava.util.concurrent.CompletableFuture;importjava.util.concurrent.ExecutionException;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.TimeoutException;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.domain.RiskAssessmentException;importrisk.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.
*/publicfinalclassHttpRiskPredictionAdapterimplementsItemRiskAssessor{privatefinalHttpClienthttp;privatefinalURIbase;privatefinalDurationrequestTimeout;privatefinalClockclock;publicHttpRiskPredictionAdapter(URIbase,DurationconnectTimeout,DurationrequestTimeout,Clockclock){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();}@OverridepublicRiskAssessmentassessRisk(StringborrowerId,StringitemId){URIuri=URI.create(base.toString().replaceAll("/+$","")+"/risk?borrower="+URLEncoder.encode(borrowerId,StandardCharsets.UTF_8)+"&item="+URLEncoder.encode(itemId,StandardCharsets.UTF_8));HttpRequestrequest=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(TimeoutExceptionlate){pending.cancel(true);thrownewRiskAssessmentException("The risk service did not answer within "+requestTimeout.toMillis()+" ms.",late);}catch(InterruptedExceptioninterrupted){pending.cancel(true);Thread.currentThread().interrupt();thrownewRiskAssessmentException("The risk request was interrupted.",interrupted);}catch(ExecutionExceptionfailed){Throwablecause=failed.getCause()==null?failed:failed.getCause();thrownewRiskAssessmentException("The risk service is unavailable: "+cause.getClass().getSimpleName(),cause);}if(response.statusCode()!=200){thrownewRiskAssessmentException("The risk service answered with status "+response.statusCode()+".");}returnparse(response.body());}privateRiskAssessmentparse(Stringbody){String[]fields=body.strip().split(" ",-1);if(fields.length!=2){thrownewRiskAssessmentException("The risk service answered with a malformed line.");}try{returnnewRiskAssessment(RiskLevel.valueOf(fields[0]),Double.parseDouble(fields[1]),clock.instant());}catch(IllegalArgumentExceptioninvalid){thrownewRiskAssessmentException("The risk service answered with an invalid value.",invalid);}}}
packagerisk.adapter;importjava.time.Duration;importjava.util.Objects;importjava.util.concurrent.ExecutionException;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.Future;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.TimeoutException;importjava.util.concurrent.atomic.AtomicInteger;importjava.util.logging.Logger;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.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.
*/publicfinalclassResilientRiskAssessorimplementsItemRiskAssessor,AutoCloseable{privatestaticfinalLoggerLOG=Logger.getLogger(ResilientRiskAssessor.class.getName());privatefinalItemRiskAssessorprimary;privatefinalItemRiskAssessorfallback;privatefinalDurationlimit;privatefinalExecutorServiceexecutor;privatefinalAtomicIntegerfallbacks=newAtomicInteger();publicResilientRiskAssessor(ItemRiskAssessorprimary,ItemRiskAssessorfallback,Durationlimit){this.primary=Objects.requireNonNull(primary,"primary");this.fallback=Objects.requireNonNull(fallback,"fallback");this.limit=Objects.requireNonNull(limit,"limit");this.executor=Executors.newCachedThreadPool(task->{Threadthread=newThread(task,"risk-primary");thread.setDaemon(true);returnthread;});}/** How many times the fallback has answered; a monitor can watch this. */publicintfallbackCount(){returnfallbacks.get();}@OverridepublicRiskAssessmentassessRisk(StringborrowerId,StringitemId){Future<RiskAssessment>pending=executor.submit(()->primary.assessRisk(borrowerId,itemId));try{returnObjects.requireNonNull(pending.get(limit.toNanos(),TimeUnit.NANOSECONDS),"the primary assessor returned null");}catch(TimeoutExceptionlate){pending.cancel(true);returnfallBackBecause("no answer within "+limit.toMillis()+" ms",borrowerId,itemId);}catch(InterruptedExceptioninterrupted){pending.cancel(true);Thread.currentThread().interrupt();thrownewRiskAssessmentException("Interrupted while waiting for the risk service.",interrupted);}catch(ExecutionExceptionfailed){Throwablecause=failed.getCause();if(causeinstanceofRiskAssessmentException){returnfallBackBecause(cause.getMessage(),borrowerId,itemId);}if(causeinstanceofRuntimeExceptionruntime){throwruntime;}if(causeinstanceofErrorerror){throwerror;}thrownewIllegalStateException("The primary assessor failed unexpectedly.",cause);}}privateRiskAssessmentfallBackBecause(Stringreason,StringborrowerId,StringitemId){fallbacks.incrementAndGet();LOG.warning(()->"Risk service unavailable ("+reason+"); using the cautious fallback.");returnfallback.assessRisk(borrowerId,itemId);}@Overridepublicvoidclose(){executor.shutdownNow();}}
4.11 src/main/java/risk/rollout/RolloutMode.java
packagerisk.rollout;/** How far a new model has been rolled out. */publicenumRolloutMode{/** 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}
packagerisk.rollout;importjava.util.Objects;importrisk.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.
*/publicrecordProvenanceRecord(StringborrowerId,StringitemId,StringmodelVersion,RiskAssessmentmodelAnswer,RiskAssessmentdecision,booleanmodelDecided){publicProvenanceRecord{Objects.requireNonNull(borrowerId,"borrowerId");Objects.requireNonNull(itemId,"itemId");Objects.requireNonNull(modelVersion,"modelVersion");Objects.requireNonNull(decision,"decision");}}
packagerisk.rollout;/** Where provenance records go. An append-only store in production; a list in tests. */publicinterfaceProvenanceLog{voidrecord(ProvenanceRecordrecord);}
packagerisk.rollout;importjava.util.Objects;importrisk.domain.ItemRiskAssessor;importrisk.domain.RiskAssessment;importrisk.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.
*/publicfinalclassRolloutAssessorimplementsItemRiskAssessor{privatefinalItemRiskAssessorbaseline;privatefinalItemRiskAssessormodel;privatefinalRolloutModemode;privatefinalintcanaryPercent;privatefinalStringmodelVersion;privatefinalProvenanceLoglog;publicRolloutAssessor(ItemRiskAssessorbaseline,ItemRiskAssessormodel,RolloutModemode,intcanaryPercent,StringmodelVersion,ProvenanceLoglog){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){thrownewIllegalArgumentException("canaryPercent must be between 0 and 100: "+canaryPercent);}this.canaryPercent=canaryPercent;}/** A stable choice: the same borrower and percentage always give the same answer. */publicbooleaninCanary(StringborrowerId){returnMath.floorMod(borrowerId.hashCode(),100)<canaryPercent;}@OverridepublicRiskAssessmentassessRisk(StringborrowerId,StringitemId){returnswitch(mode){caseOFF->baseline.assessRisk(borrowerId,itemId);caseSHADOW->shadow(borrowerId,itemId);caseCANARY->inCanary(borrowerId)?modelDecides(borrowerId,itemId):baseline.assessRisk(borrowerId,itemId);caseON->modelDecides(borrowerId,itemId);};}privateRiskAssessmentshadow(StringborrowerId,StringitemId){RiskAssessmentdecision=baseline.assessRisk(borrowerId,itemId);RiskAssessmentmodelAnswer;try{modelAnswer=model.assessRisk(borrowerId,itemId);}catch(RiskAssessmentExceptionunavailable){modelAnswer=null;// a shadow failure must never affect the decision}log.record(newProvenanceRecord(borrowerId,itemId,modelVersion,modelAnswer,decision,false));returndecision;}privateRiskAssessmentmodelDecides(StringborrowerId,StringitemId){RiskAssessmentanswer=model.assessRisk(borrowerId,itemId);log.record(newProvenanceRecord(borrowerId,itemId,modelVersion,answer,answer,true));returnanswer;}}
#!/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:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiif["${CHAPTER_OFFLINE:-0}"="1"];thenARGS+=(--offline)fiecho"=== 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.
Fault
Change
What happens
E1
the wrapper waits an hour instead of the limit
2 invocation(s) end at the five-second deadline: aStalledPrimaryIsAskedToStop, aStalledPrimaryIsGivenUpOnWithinTheLimitAndTheFallbackAnswers
a 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:
Failure
Where
Why it matters
No timeout
openConnection() and getInputStream()
A stalled service holds the caller for as long as it likes (the probe: 1,502 ms)
An outage looks like a refusal
catch (Exception anything) { return false; }
The caller cannot tell âhigh riskâ from âservice downâ
A nonsense score is accepted
Float.parseFloat(parts[1]), no range check
A confidence of 1.7 was approved
A magic number
score > 0.8f
No name and no owner; also > where the domain’s rule is >=
A level compared as text
parts[0].equals("LOW")
A new level or a misspelling is silently ânoâ
Identifiers are not encoded
the string concatenation
A space or & in an identifier breaks or changes the request
Every exception is swallowed
the catch-all
A programming error (NullPointerException on a missing line) looks like a refusal
The reader and the connection are never closed
in, connection
A resource leak
No test is possible without the service
the constructor takes a URL
There is no seam
No record of what was decided
the whole method
Nothing 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.