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.