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.