Syllabus · B7
Chain, decorator, middleware — composing steps that may stop each other
The idea
A chain is cheap for another step and expensive for another dispatch
You have used middleware in a web framework, so the shape is familiar. The decision it hides is not: when a pipeline is the right structure, and who is allowed to stop it.
corpus/middleware-router measured both answers. In curveballs/03-short-circuit-audit, security asks for a gate in front of /admin, and legal asks that a rejected request never appear in the access log. reference_diff: 0. No file in reference/src changed and none was added; the gate and log are two use() calls in caller code. Then curveballs/02-method-aware-routing asks that GET /widgets/:id and POST /widgets/:id do different things. That one measures 47, the priciest absorption in the corpus.
Same design, same seam. What differs is what the requirement moved. A gate is another link in the pipeline. A method is a new thing the pipeline dispatches on.
So write the requirement as a question about one request. If it reads "also do this to everything coming through", it is another link, and the chain absorbs it for the price of one file. If it reads "decide which steps apply, or in what order, from something about this request", the shape is wrong. The chain's one ordered list has nowhere to keep that answer, and you pay to rebuild it.
contrast/ measures both directions on one pair. Another step: 7 lines hand-wired against 1 chained. Per-route steps: 33 hand-wired against 42 chained. Run node lessons/B7/contrast/measure.mjs.
Ordering is the other half, and when-not.md states the rule.
Worked walkthrough
What each line guarantees
Run it first: javac -Xlint:all *.java && java Main. Every number quoted below is in that output.
The eight lines that are the whole seam
MiddlewareChain.wrapping is the entire chain mechanism. Read it in the order it executes.
Chain chain = terminal;
for (int i = middlewares.size() - 1; i >= 0; i--) {
Middleware middleware = middlewares.get(i);
Chain rest = chain;
chain = request -> middleware.handle(request, rest);
}
return chain;
Chain chain = terminal; — the terminal step is a Chain like any other continuation. That is what lets the last middleware call next without knowing it is last. If the terminal were a separate Handler parameter that wrapping special-cased, every link would need a "am I last" branch, and the one that forgot would drop the request.
The loop runs backwards. Building from the inside out means the last registered link is wired first, so the first registered link ends up outermost. Walk the list forwards and the pipeline runs in reverse registration order. Block 1 of Main shows the correct order:
order : [first in, second in, third in, handler, third out, second out, first out]
That reversal is the bug that turns the gate-then-log order into log-then-gate, which is exactly the leak block 3 prints. A test that only checks the returned status will not see it.
Chain rest = chain; — a new local per iteration, because the lambda on the next line captures it. Assigning chain inside the lambda instead is a compile error, and a useful one:
error: local variables referenced from a lambda expression must be final or effectively final
The bug this line prevents, if the language allowed it, is every link's next pointing at whatever chain happened to hold last. rest freezes "everything built so far" at the moment of wiring.
chain = request -> middleware.handle(request, rest); — note that the lambda's parameter is what gets passed on, not the request the pipeline started with. That is what makes a rewriting link possible: chain.next(new Request("/v2" + request.path(), request.method())) reaches the route under the new path. Capture the outer request instead and a rewrite silently does nothing.
What Chain.next returning a Response buys
A void next() would still support gates. It would not support AccessLog, because a log line carries the status, and the status does not exist until the rest of the pipeline has run.
Response response = chain.next(request);
lines.add(request.method() + " " + request.path() + " -> " + response.status());
return response;
Two invariants here. The log runs after the dispatch, so 410 in the log means a route really answered 410. And the response is passed back unchanged, so recording something is not the same as consuming it. A link that forgets to return what it got turns every response into whatever it made up.
FriendlyNotFound is the same idea with nothing at all in its "before" half. It cannot act early: the 404 it fixes is manufactured by dispatch, several frames deeper. The body().isEmpty() check is load-bearing. Without it, a route that deliberately answers new Response(404, "no widget 99") has its message overwritten, and the person who wrote that message finds out from a user.
The short-circuit, and what the links behind it observe
AdminGate has two returns and they are different kinds of thing.
if (request.path().startsWith("/admin") && !validToken.equals(request.method())) {
return new Response(403, "forbidden"); // stops here
}
return chain.next(request); // not my business
Nothing is told that the pipeline ended early. There is no SKIP sentinel and no flag on Request. MiddlewareChain cannot tell whether the link it invoked called next; it only sees a Response coming back. The corpus's own patch note for this makes the same point:
A middleware's decision to short-circuit is a plain
returnwith no call tochain.next. There is noSKIPsentinel, no boolean flag onRequest, nothing a candidate has to remember to check. —corpus/middleware-router/curveballs/03-short-circuit-audit/reference-patch/PATCH.md
So answer these three questions out loud before you write the first link, because the shape cannot answer them for you:
- Who may stop the chain. Here, any link. That is a decision, not a default. A pipeline where only an authenticator may reject is a different contract, and it needs a narrower interface than
Middleware. - What the caller observes. A 403 with body
forbidden, indistinguishable from a 403 a route produced. The caller cannot tell how far the request got, and that is deliberate. - Whether later links still run their "after" half. They do not, and this is the part people get wrong. A short-circuited link is not "skipped on the way in and resumed on the way out" — it was never entered. Block 2 prints
access log holds : []for the rejected request.
That third answer is the requirement, not an implementation detail. Legal asked that a rejected request never appear in the access log as if it had been served. "Never entered" is what delivers that, and it is why the gate is registered before the log rather than after.
Ordering is part of the contract
The rule, in one question you can ask about any pair of links: if B runs and A rejected this request, is that a bug? If yes, A goes before B.
Apply it here. The log records that a request was served. The gate decides whether it may be. A rejected request in the served log is the bug legal named, so the gate goes first. Same rule, different domain: compression before encryption, because encrypted bytes do not compress, and an encrypt-then-compress pipeline ships large ciphertext that looks fine in every test of the response.
Block 5 of Main also shows when order does not matter. Two links that each either answer or pass on untouched, on disjoint paths, give the same result in either order:
/reports as registered : 200 "reports"
/reports swapped : 200 "reports"
Neither reads the other's answer, so there is nothing for order to change. The gate and the log are not like that:
gate then log : status 403, log []
log then gate : status 403, log [GET /admin/dashboard -> 403]
Same status both ways. The difference is invisible to any assertion about the response.
Write the order down where the next reader will be. That is the registration site, not any middleware class, because no middleware knows its own position. contrast/b/Wiring.java carries it as a comment above the use calls, and MiniRouter.use carries it in its javadoc. A middleware class documenting where it belongs is a note in the wrong file.
Which shape this is, and the two it is not
Decorator wraps one object and preserves its interface. CountedHandler implements Handler and holds a Handler. Its caller cannot tell the difference, which is the point.
Chain of responsibility passes a request along until a link handles it. Each link asks "is this mine", and the ones that decline contribute nothing to the answer.
Middleware is a chain where every link may act before and after the next one. That is the only one of the three where ordering is observable in the response, because "after" gives a link a view of what the links behind it decided.
The corpus uses middleware, and Chain.next returning a Response is where you can see it. Block 4 measures the difference in scope:
CountedHandler.calls() : 1 (one route)
access log lines : 2 (every request)
Two requests, one to /health and one to /widgets. The decorator saw one, the link saw both. Then a request to /nope, which matches no route at all, reaches the link and gets a body from it:
unmatched path : 404 "no route for /nope"
access log lines : 3 (a link runs whether or not a route matched)
No decorator could have written that body. There is no handler registered on /nope to wrap. Pick the scope before the shape: one collaborator means a decorator, every request means a link.
What the shape costs
Block 6 counts the frames in the handler's own stack trace:
0 link(s): 6 frames
1 link(s): 8 frames
3 link(s): 12 frames
6 link(s): 18 frames
Two frames per link, and both of them are synthetic. Printing the trace with three links gives:
MiniRouter.dispatch(MiniRouter.java:52)
Probe.lambda$main$1(Probe.java:11)
MiddlewareChain.lambda$wrapping$0(MiddlewareChain.java:22)
Probe.lambda$main$1(Probe.java:11)
MiddlewareChain.lambda$wrapping$0(MiddlewareChain.java:22)
Three links written as lambdas produce three identical Probe.lambda$main$1 lines, because they came from one lambda expression in a loop. Nothing in the trace says which link is which. Write a link as a named class and you get AdminGate.handle back; write it as a lambda and you get a line number. when-not.md has the rest of the price.
When not to
When not to build a pipeline
STANDARD v1.0 is symmetric about this. D3 level 0 is behaviour selected by a conditional in more than one place. D3 level 3 requires that "the seam set is minimal", with no speculative interface that has a single implementation and no foreseeable second one. The standard then says it outright: "level 3 penalises over-abstraction as much as level 0 penalises none". The tag is over-engineered (premature interface).
A chain is the easiest seam in the syllabus to over-apply, because it is the one you have already used at work. Five types is a small price when you are used to app.use() being free.
The concrete bad example
Two cross-cutting steps, fixed, always both, always in that order. Here is the version that looks like the lesson has been learned. Add TwoStepRouter.java next to worked/src/Chain.java, Middleware.java and MiddlewareChain.java, and javac 21 -Xlint:all prints nothing and exits 0.
public final class TwoStepRouter {
private final Map<String, Handler> routes = new LinkedHashMap<>();
private final List<Middleware> pipeline = new ArrayList<>();
private final List<String> accessLog = new ArrayList<>();
public TwoStepRouter() {
pipeline.add(new PathValidator());
pipeline.add(new AccessLogMiddleware(accessLog));
}
/** Nobody calls this. */
public void use(Middleware middleware) {
pipeline.add(middleware);
}
public Response handle(Request request) {
return MiddlewareChain.wrapping(pipeline, this::dispatch).next(request);
}
}
Five types (Chain, Middleware, MiddlewareChain, and the two steps) plus a use method with no caller — to run two statements. Here are the two statements:
public Response handle(Request request) {
if (request.path().isEmpty()) {
return new Response(400, "bad path");
}
Response response = dispatch(request);
accessLog.add(request.path() + " -> " + response.status());
return response;
}
What a reviewer sees. A pipeline whose contents are decided in a constructor and never change. use is public API that nothing calls, so it is a promise the design does not keep. The next person adds a MiddlewareRegistry so the order can be configured, and now there is a configuration format for two steps that have never moved.
What the grader sees. Behaviour is composed rather than conditional, so D3 clears level 2, and level 3 is out of reach because the seam set is not minimal. over-engineered is a tracked failure tag.
What the shape costs, measured
Three prices. The middle one is the expensive one.
It costs code before any requirement arrives. From node lessons/B7/contrast/measure.mjs:
size of a 6 file(s) 91 normalised lines
size of b 12 file(s) 155 normalised lines
Read the shape rather than the multiplier: both trees are commented for teaching, so those counts include javadoc. The chain doubled the file count for behaviour that is identical, and BaseTest.java passes 6 out of 6 against both.
It costs more than no pipeline when the requirement changes the dispatch. Also from measure.mjs, and written up in contrast/curveball.md:
| A, hand-wired sequence | B, chain | |
|---|---|---|
| one more step | 7 | 1 |
| the matched route selects its steps | 33 | 42 |
The 42 is the number that matters here. The chain lost by 9 lines on a requirement that moved the dispatch rather than adding a step. It lost because handle had to be turned inside out: routing moved from the terminal of the pipeline to in front of it. At full scale the same shape of change is corpus/middleware-router/curveballs/02-method-aware-routing, at reference_diff: 47. That is the priciest absorption in the corpus, against a design whose other two curveballs cost 16 and 0.
It costs you the stack trace. From block 6 of worked/src/Main.java:
0 link(s): 6 frames
1 link(s): 8 frames
3 link(s): 12 frames
6 link(s): 18 frames
Two frames per link, and both are synthetic. A pipeline of six lambdas produces six identical lambda$main$1 lines and six MiddlewareChain.lambda$wrapping$0 lines. Nothing in the trace names the link that misbehaved.
And no single file says what happens to a request. In design A you read handle top to bottom and you know. In design B the pipeline exists only as the order of use calls, so answering "what runs for /admin/dashboard" means finding the wiring, then reading each link. That is the real cost of the shape at one in the morning, and it is why the ordering rule belongs in a comment at the registration site.
The threshold
Build the pipeline when either of these is true:
- Three or more steps that compose independently. Independently means each one is meaningful without the others, and the set is expected to grow. Three is the point where the ordering question stops being obvious and starts needing to be written down.
- The set of steps is configured rather than fixed. If a caller, a config file or a test decides which steps apply, the steps have to be data. A fixed sequence cannot accept a step it has never heard of.
Leave it as statements otherwise. Two hard-coded steps are two method calls. That is not a compromise; contrast/a/ is the smaller, clearer design, and it is 33 against 42 on the change that moved the dispatch.
The corpus has a problem where the honest answer was no pipeline at all. corpus/rate-limiter/problem.json records it against this very syllabus item:
No decorator or chain in the base design, and none of the three curveballs turned out to want one.
The note goes on to say which seams did the work: the algorithm seam and the scope seam absorbed all three between them. Three measured curveballs, no chain. A pipeline is a shape, not a virtue.
The axis this design deliberately did not chain
Route precedence. When several patterns match a path, something has to pick the winner. A chain of matchers is the nearest shape to reach for: each matcher tries in turn, and the first one to claim the path wins.
corpus/middleware-router does not do that. It injects MatchPolicy, described in its problem.json as "given every candidate compatible with a path, decides the winner" — one comparator, tested on its own. The contract explains why a chain would be wrong here:
Two patterns that are not duplicates may otherwise be registered freely, whatever they look like and in whatever order — registration order carries no precedence meaning for routing. —
corpus/middleware-router/contract/MiddlewareRouterApi.java
A chain's answer depends on the order its links were registered. That is the whole reason ordering is a contract for middleware. For routing, order-independence is the requirement, so the shape that makes ordering observable is the wrong one. Two seams in one class, one chained and one compared, and the difference is which of them is allowed to care what order things arrived in.
02-method-aware-routing's PATCH.md then records what protecting that comparator was worth. An earlier draft "measured smaller" and was rejected for making Router reach past MatchPolicy into one concrete implementation. The author paid 47 lines to keep the comparator the only thing deciding path precedence.
What this file is not saying
It is not saying prefer the smaller diff. 03-short-circuit-audit cost 0 because the pipeline was already there, and its PATCH.md names what a candidate is most likely to get wrong instead:
Building a bespoke two-step pipeline specifically for "auth then logging" [...] rather than reaching for
use()twice. That would still pass this curveball's tests, but it duplicates a mechanism the design already has.
Both mistakes are on the table in the same round. Reaching for a pipeline you do not need, and hand-rolling one when you already have it. The threshold above is how you tell which mistake you are about to make.
The contrast pair
The measured pair: one pipeline, two requirement changes, four numbers
Two designs of the same router. Both run three cross-cutting steps on every request: a gate on /admin, an access log, and a filler for the empty body a 404 comes back with.
a/ writes those three as what they are — statements around the dispatch, inside SequenceRouter.handle. b/ holds them as a List<Middleware> and wraps them with the corpus's own MiddlewareChain.wrapping. Both compile under -Xlint:all with no warnings, and both pass BaseTest.java 6 out of 6. A grader looking only at behaviour cannot tell them apart.
Design A is not a straw man. Three fixed steps in a fixed order is a reasonable thing to write, and when-not.md argues it is the right thing to write when the set really is fixed.
Then two requirement changes arrive. The first is another step. The second is not.
Change one, in the interviewer's words
One client is hammering us. Cap it: the same path may be served at most three times, and the fourth gets a 429 with body
slow down. A capped request must not reach the route, and it must not show up in the access log — the same rule legal gave us for rejected admin requests. And do not let a request the admin gate is going to reject burn quota. There is no point rate-limiting traffic we were never going to serve.
That last sentence fixes the position: after the gate, before the log. CapTest.java asserts all four claims, and it is green against a-cap/ and b-cap/.
Change two, in the interviewer's words
Two things came up. Monitoring hits
/healthevery second and it is drowning the access log, so/healthshould not be logged at all. And the gate is only meant to protect/admin. I do not want it deciding anything about routes that never asked for it. So when a route is registered, it says which of the cross-cutting steps apply to it. A step the matched route did not ask for must not run for that request. And if nothing matches the path, nothing cross-cutting runs either, because there is no route to have asked. An unmatched path still needs itsno route for Xbody, so put that wherever it belongs now.
PerRouteTest.java asserts those four claims. It does not compile against a/ or b/ at all:
error: method register in interface RouterApi cannot be applied to given types;
wiring.router().register("/health", request -> new Response(200, "ok"), Set.of());
^
required: String,Handler
found: String,(request)-[...]"ok"),Set<Object>
That compile error is part of the cost. The requirement changes the registration API in both designs, which is the first sign that it is not another link.
The numbers
Run it yourself:
node lessons/B7/contrast/measure.mjs
Real output, from exactly these directories:
one more step (on B's axis) a -> a-cap diffLines 7 touched 1 new 0 [SequenceRouter.java +7/-0]
one more step (on B's axis) b -> b-cap diffLines 1 touched 1 new 1 [Wiring.java +1/-0]
per-route steps (off B's axis) a -> a-perroute diffLines 33 touched 3 new 0 [RouterApi.java +3/-0, SequenceRouter.java +19/-9, Wiring.java +2/-0]
per-route steps (off B's axis) b -> b-perroute diffLines 42 touched 3 new 0 [ChainRouter.java +22/-9, RouterApi.java +3/-0, Wiring.java +5/-3]
size of a 6 file(s) 91 normalised lines
size of b 12 file(s) 155 normalised lines
| Change | Design A, hand-wired sequence | Design B, chain |
|---|---|---|
| one more step, may short-circuit | 7 lines, 1 file | 1 line, 1 file, 1 new file |
| the matched route selects its steps | 33 lines, 3 files | 42 lines, 3 files |
measureChange is the same function that scores D4 in a graded attempt, so these are the numbers the grader would produce. New files cost nothing. Edits to files that already existed are charged, additions included. See the header of server/lib/diff.mjs for why.
What each number means
B's 1 is one line in Wiring.java: router.use(new RateCap(3));. RateCap.java is a new file and free, and ChainRouter.java was not opened. The corpus records the same absorption at 0, in corpus/middleware-router/curveballs/03-short-circuit-audit/budget.json. It reaches zero because there the new steps live in the caller's own code, not in a wiring class that ships with the design. Its note: "No file in reference/src changes and none is added."
A's 7 is not a disaster, and that is the honest reading. Seven lines in one method, all additive, and a reviewer reads them as one commit. The cost is not the seven lines. It is that handle is now five steps long, and every future step lands in that same method. The ordering rule lives there too, with nothing enforcing it.
A's 33 against B's 42 is the finding this lesson exists for. The chain lost. Both designs had to change the same three files, and the chain's version of the change is 9 lines bigger.
Where B's extra lines went: use(Middleware) became use(String, Middleware), because a route now has to name the steps it wants, and an anonymous position in a list cannot be named. Then handle had to be turned inside out. It used to resolve the route in the terminal step, after every link had run. Now it resolves the route first, selects that route's links, and builds a chain whose terminal is the route's own handler. Routing moved from the back of the pipeline to the front.
A paid for the same requirement with three contains checks in a method it already had.
NotFoundBody survives the change and stops being useful. It is still registered, and it still fixes a 404 that a route returned with an empty body. The case it was written for was "nothing matched", and that case now returns before any step runs. So the body is manufactured in handle itself, in both designs. A step made unreachable by a dispatch change is a cost no line count shows.
The corpus at full scale
The same shape, on a real problem, is corpus/middleware-router/curveballs/02-method-aware-routing/budget.json: reference_diff: 47, the priciest absorption in the corpus. Its PATCH.md names the reason in the same terms this lesson uses:
The catch-all curveball added a new value the matcher could see (a fourth segment kind); this one adds a new dimension to precedence itself — method, ahead of path shape.
Three curveballs, one design: 0 for another step, 16 for a fourth kind of segment (01-catch-all), 47 for a new dispatch dimension. The spread is the lesson, and it is a 47x range across one seam.
The alternatives, so the choice is a choice
Could B have absorbed the per-route change by adding a file? Only if Middleware had carried a predicate from the start — something like "which requests do I apply to". That is the speculative widening when-not.md is about, and it would have been guessing. The corpus contract says the opposite in plain words: "Middleware runs whether or not a route ultimately matches." The requirement overturned a stated rule, and a seam does not protect you from that.
Could a link have opted out by checking the path itself? For /health, yes: the log could skip paths it does not like. That trades one problem for a worse one. Every route's policy then lives in whichever link happens to care, and the answer to "what runs for /health" is spread across three files instead of one registration.
Is the smaller diff the better design? No, and the corpus has the counter-example in this exact problem. 02-method-aware-routing's PATCH.md records an earlier draft that "measured smaller" and was rejected, because it made Router reach past MatchPolicy into one concrete implementation. The author took 47 lines on purpose.
What a diff cannot see here
Two of this lesson's claims are not line counts, and the diff is the wrong instrument for both.
The first is the leak. Registering the log before the gate returns the same 403 to the caller and puts the rejected request in the access log. BaseTest.accessLogNeverRecordsARejectedRequest is what catches that; no diff of any size would. Block 3 of worked/src/Main.java prints both orders side by side.
The second is the price of indirection, which when-not.md measures in stack frames rather than in lines.
Suite results, for reproducing
| tree | suite | result |
|---|---|---|
a/, b/ | BaseTest | 6 successful, 0 failed |
a/, b/ | CapTest | 1 successful, 3 failed |
a-cap/, b-cap/ | BaseTest + CapTest | 10 successful, 0 failed |
a/, b/ | PerRouteTest | does not compile |
a-perroute/, b-perroute/ | BaseTest + PerRouteTest | 9 successful, 1 failed |
The one failure in the last row is BaseTest.stepsRunEvenWhenNothingMatched, and it fails identically in both designs. It asserts the contract clause the requirement overturned. A green base suite after that change would have meant the change had not really been made.
Worked source
The 12 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/AccessLog.java24 linesworked/src/AdminGate.java27 linesworked/src/Chain.java18 linesworked/src/CountedHandler.java35 linesworked/src/FriendlyNotFound.java18 linesworked/src/Handler.java14 linesworked/src/Middleware.java17 linesworked/src/MiddlewareChain.java28 linesworked/src/MiniRouter.java66 linesworked/src/Request.java23 linesworked/src/Response.java13 linesworked/src/Main.java199 lines
worked/src/AccessLog.java24 lines
// AccessLog.java — the link that has to run after the rest of the pipeline.
//
// It cannot record the status without waiting for chain.next() to return, which is what a plain
// "run these filters, then handle" loop cannot express. The loop hands each filter the request on
// the way in; there is no way to hand it the response on the way out.
import java.util.ArrayList;
import java.util.List;
public final class AccessLog implements Middleware {
private final List<String> lines = new ArrayList<>();
@Override
public Response handle(Request request, Chain chain) {
Response response = chain.next(request);
lines.add(request.method() + " " + request.path() + " -> " + response.status());
return response;
}
/** What was served. A copy: the caller of a log does not get to rewrite it. */
public List<String> lines() {
return List.copyOf(lines);
}
}
worked/src/AdminGate.java27 lines
// AdminGate.java — the link that may stop the pipeline.
//
// The requirement is corpus/middleware-router/curveballs/03-short-circuit-audit: an access gate in
// front of everything under /admin, and a rejected request must never reach the access log. The
// curveball's own test writes this gate as a lambda; it is a class here so NOTES.md can annotate
// the two return statements separately.
public final class AdminGate implements Middleware {
private final String validToken;
public AdminGate(String validToken) {
this.validToken = validToken;
}
@Override
public Response handle(Request request, Chain chain) {
if (request.path().startsWith("/admin") && !validToken.equals(request.method())) {
// The short-circuit, and it is a plain return. No flag is set, nothing is told that
// the pipeline ended early, and nothing after this line runs for this request.
return new Response(403, "forbidden");
}
// Not this link's business. Pass the request on untouched and hand back whatever the rest
// of the pipeline decided, unread.
return chain.next(request);
}
}
worked/src/Chain.java18 lines
// Chain.java — copied from corpus/middleware-router/contract/Chain.java, javadoc trimmed.
//
// One method, and the whole design rests on what it does NOT say. There is no proceed() that
// returns void, no boolean saying "keep going", no SKIP sentinel to check for. A link either calls
// next and gets an answer back, or it does not call it and answers by itself.
@FunctionalInterface
public interface Chain {
/**
* Hands the request to the rest of the pipeline and returns what came back.
*
* <p>Two facts about this signature carry the whole seam. It takes a {@link Request}, so a link
* may pass on a different request than the one it received. It returns a {@link Response}, so
* a link gets to see the answer and may act on it. A void {@code next()} would forbid both.
*/
Response next(Request request);
}
worked/src/CountedHandler.java35 lines
// CountedHandler.java — a decorator, for comparison with the links above.
//
// Three differences from a Middleware, and they are the whole distinction:
//
// 1. It implements Handler, the interface of the thing it wraps. A Middleware implements
// Middleware, which is not the interface of anything in the pipeline.
// 2. It wraps ONE handler, chosen at registration. A link wraps every request the router takes.
// 3. Its constructor takes the thing it wraps, so the wrapping is visible at the call site:
// register("/health", new CountedHandler(health)). A link's position is decided by the order
// of use() calls, somewhere else entirely.
//
// A decorator cannot stop anything either. It has no next() to withhold — it holds a Handler and
// calling it is the only way to get a Response. Withholding the call means inventing a response
// out of nothing, which is a different design and usually a cache.
public final class CountedHandler implements Handler {
private final Handler wrapped;
private int calls;
public CountedHandler(Handler wrapped) {
this.wrapped = wrapped;
}
@Override
public Response handle(Request request) {
calls++;
return wrapped.handle(request);
}
/** How many requests reached this one route. Not how many the router took. */
public int calls() {
return calls;
}
}
worked/src/FriendlyNotFound.java18 lines
// FriendlyNotFound.java — a link whose whole job is in the "after" half.
//
// dispatch() manufactures new Response(404, "") for a path nothing matched. Turning that empty
// body into something a browser can show is not the router's decision, and it is not the handler's
// either, because for an unmatched path there is no handler. It belongs to whoever wraps the
// pipeline.
public final class FriendlyNotFound implements Middleware {
@Override
public Response handle(Request request, Chain chain) {
Response response = chain.next(request);
if (response.status() == 404 && response.body().isEmpty()) {
return new Response(404, "no route for " + request.path());
}
return response;
}
}
worked/src/Handler.java14 lines
// Handler.java — corpus/middleware-router/contract/Handler.java, with pathParams dropped.
//
// The corpus signature is handle(Request, Map<String,String> pathParams), because its router
// compiles :name segments and captures them. Pattern matching is not this lesson's subject, so
// the second parameter is gone and every route here is a literal path. Everything else — the
// interface being functional, the Response coming back rather than being written to a stream — is
// the corpus's shape unchanged.
@FunctionalInterface
public interface Handler {
/** Serves one request that the whole middleware pipeline let through. */
Response handle(Request request);
}
worked/src/Middleware.java17 lines
// Middleware.java — copied from corpus/middleware-router/contract/Middleware.java, javadoc trimmed.
//
// Read the two parameters as two questions a link is allowed to answer: what is this request, and
// what would the rest of the pipeline do with it. A link that only ever answers the first is a
// filter. A link that answers both is what makes this shape a middleware rather than a chain of
// responsibility.
@FunctionalInterface
public interface Middleware {
/**
* @param request the request as it stands when this link's turn comes
* @param chain call {@link Chain#next(Request)} to continue; do not call it to stop here.
* Either way, return the {@link Response} for this request
*/
Response handle(Request request, Chain chain);
}
worked/src/MiddlewareChain.java28 lines
// MiddlewareChain.java — corpus/middleware-router/reference/src/MiddlewareChain.java, unchanged
// except for the comments.
//
// Eight lines of body. Every design decision in this lesson is one of them, which is why the
// faded/ stage takes this file apart rather than a bigger one.
import java.util.List;
final class MiddlewareChain {
/**
* Wires a list of links around a terminal step into one {@link Chain}, in registration order.
*
* <p>{@code terminal} is what runs once every link has called {@code next} — routing and the
* matched handler, here. It is a {@link Chain} like any other link's continuation, so the last
* middleware cannot tell that it is last, and no link needs a special case for the end.
*/
static Chain wrapping(List<Middleware> middlewares, Chain terminal) {
Chain chain = terminal;
for (int i = middlewares.size() - 1; i >= 0; i--) {
Middleware middleware = middlewares.get(i);
Chain rest = chain;
chain = request -> middleware.handle(request, rest);
}
return chain;
}
private MiddlewareChain() {}
}
worked/src/MiniRouter.java66 lines
// MiniRouter.java — corpus/middleware-router/reference/src/Router.java with the routing half cut
// down to a map of literal paths. register/use/handle keep their contract meaning; Segment,
// PatternCompiler, CompiledRoute and MatchPolicy are all gone, because precedence is C2's subject
// and this lesson is about what happens before a route is reached.
//
// What survives is the part that matters here: handle() builds the chain fresh on every call and
// hands dispatch in as the terminal step.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class MiniRouter {
private final Map<String, Handler> routes = new LinkedHashMap<>();
private final List<Middleware> middlewares = new ArrayList<>();
/** Registers a literal path. A duplicate is refused, the way the corpus refuses a duplicate shape. */
public void register(String path, Handler handler) {
Objects.requireNonNull(handler, "handler");
String key = normalise(path);
if (routes.containsKey(key)) {
throw new IllegalStateException("\"" + path + "\" is already registered");
}
routes.put(key, handler);
}
/**
* Adds a link. The order of these calls is the order requests run through them, and that
* ordering is a contract the caller relies on — see NOTES.md.
*/
public void use(Middleware middleware) {
middlewares.add(Objects.requireNonNull(middleware, "middleware"));
}
/** The full pipeline: every link, in order, then routing, unless a link stopped it. */
public Response handle(Request request) {
Chain chain = MiddlewareChain.wrapping(middlewares, this::dispatch);
return chain.next(request);
}
/**
* The terminal step. A path nothing is registered for is an ordinary 404, not an exception —
* a router's job is to answer a request it cannot place, not to fail its caller for one.
*/
private Response dispatch(Request request) {
Handler handler = routes.get(normalise(request.path()));
if (handler == null) {
return new Response(404, "");
}
return handler.handle(request);
}
/** Leading and trailing slashes mean nothing, as in the corpus contract. */
private static String normalise(String path) {
String trimmed = Objects.requireNonNull(path, "path");
while (trimmed.startsWith("/")) {
trimmed = trimmed.substring(1);
}
while (trimmed.endsWith("/")) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
return trimmed;
}
}
worked/src/Request.java23 lines
// Request.java — copied from corpus/middleware-router/contract/Request.java, javadoc trimmed.
//
// Two fields, and the second one is doing double duty in this lesson. The corpus's own
// curveball test for 03-short-circuit-audit uses method() to carry the auth token, because the
// base contract has no headers and the curveball did not add any:
//
// if (request.path().startsWith("/admin") && !"secret-token".equals(request.method()))
//
// That is the corpus's line, not an invention here, and the gate in this lesson matches it.
import java.util.Objects;
public record Request(String path, String method) {
public Request {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(method, "method");
}
/** A request with no method-specific meaning; equivalent to {@code new Request(path, "GET")}. */
public static Request of(String path) {
return new Request(path, "GET");
}
}
worked/src/Response.java13 lines
// Response.java — copied from corpus/middleware-router/contract/Response.java, javadoc trimmed.
//
// A value, so a middleware that wants to change the response builds a new one rather than
// mutating what came back from chain.next(). That is what makes the "after" half of a middleware
// safe to write: nothing it does to its own copy can surprise the link that produced it.
import java.util.Objects;
public record Response(int status, String body) {
public Response {
Objects.requireNonNull(body, "body");
}
}
worked/src/Main.java199 lines
// Main.java — run this. Six blocks, each one claim from the lesson, each printing the evidence.
import java.util.ArrayList;
import java.util.List;
public final class Main {
private Main() {
}
private static final String TOKEN = "secret-token";
public static void main(String[] args) {
eachLinkRunsBeforeAndAfterTheNextOne();
aLinkThatStopsThePipeline();
theSameTwoLinksInTheWrongOrder();
aDecoratorWrapsOneRouteALinkWrapsEveryRequest();
whenOrderIsObservableAndWhenItIsNot();
whatSixLinksCostAtOneInTheMorning();
}
/** 1 — the nesting. Registration order in, reverse order out. */
private static void eachLinkRunsBeforeAndAfterTheNextOne() {
System.out.println("--- 1. three links, one request: what runs, and in what order");
List<String> trace = new ArrayList<>();
MiniRouter router = new MiniRouter();
router.register("/health", request -> {
trace.add("handler");
return new Response(200, "ok");
});
router.use(tracing("first", trace));
router.use(tracing("second", trace));
router.use(tracing("third", trace));
Response response = router.handle(Request.of("/health"));
System.out.println(" status : " + response.status());
System.out.println(" order : " + trace);
System.out.println(" the first link registered is the outermost, and the last to finish");
}
/** 2 — the short-circuit, and what the links behind it observe. */
private static void aLinkThatStopsThePipeline() {
System.out.println("--- 2. a rejected request: who runs, who does not");
List<String> served = new ArrayList<>();
AccessLog log = new AccessLog();
MiniRouter router = new MiniRouter();
router.register("/admin/dashboard", request -> {
served.add(request.path());
return new Response(200, "dashboard");
});
router.use(new AdminGate(TOKEN));
router.use(log);
Response rejected = router.handle(new Request("/admin/dashboard", "GET"));
System.out.println(" no token, status : " + rejected.status()
+ " \"" + rejected.body() + "\"");
System.out.println(" handler ran : " + served.size() + " time(s)");
System.out.println(" access log holds : " + log.lines());
Response allowed = router.handle(new Request("/admin/dashboard", TOKEN));
System.out.println(" with token, status : " + allowed.status());
System.out.println(" handler ran : " + served.size() + " time(s)");
System.out.println(" access log holds : " + log.lines());
System.out.println(" the gate returned without calling next, so the log's \"after\" half");
System.out.println(" never ran either; the log was never entered at all");
}
/** 3 — the leak. Same two links, order swapped, one line of difference. */
private static void theSameTwoLinksInTheWrongOrder() {
System.out.println("--- 3. the same two links, registered the other way round");
AccessLog log = new AccessLog();
MiniRouter router = new MiniRouter();
router.register("/admin/dashboard", request -> new Response(200, "dashboard"));
router.use(log);
router.use(new AdminGate(TOKEN));
Response rejected = router.handle(new Request("/admin/dashboard", "GET"));
System.out.println(" no token, status : " + rejected.status());
System.out.println(" access log holds : " + log.lines());
System.out.println(" the caller still gets 403, so no test of the response catches this.");
System.out.println(" legal's requirement is broken and the status code agrees with itself");
}
/** 4 — decorator against link, measured on the same two requests. */
private static void aDecoratorWrapsOneRouteALinkWrapsEveryRequest() {
System.out.println("--- 4. a decorator on one route, a link on every request");
AccessLog log = new AccessLog();
CountedHandler health = new CountedHandler(request -> new Response(200, "ok"));
MiniRouter router = new MiniRouter();
router.register("/health", health);
router.register("/widgets", request -> new Response(200, "widgets"));
router.use(log);
router.use(new FriendlyNotFound());
router.handle(Request.of("/health"));
router.handle(Request.of("/widgets"));
System.out.println(" CountedHandler.calls() : " + health.calls() + " (one route)");
System.out.println(" access log lines : " + log.lines().size() + " (every request)");
System.out.println(" same wrapping shape, two different scopes. Pick the scope first");
Response missing = router.handle(Request.of("/nope"));
System.out.println(" unmatched path : " + missing.status()
+ " \"" + missing.body() + "\"");
System.out.println(" access log lines : " + log.lines().size()
+ " (a link runs whether or not a route matched)");
System.out.println(" a decorator could not have written that body: there is no handler on");
System.out.println(" /nope to wrap. Only something outside routing sees a request that missed");
}
/** 5 — when swapping two links changes the answer, and when it cannot. */
private static void whenOrderIsObservableAndWhenItIsNot() {
System.out.println("--- 5. which pairs of links are order-sensitive");
System.out.println(" two links that each answer or pass through, on disjoint paths:");
System.out.println(" /reports as registered : " + claimingPair(false, "/reports"));
System.out.println(" /reports swapped : " + claimingPair(true, "/reports"));
System.out.println(" /exports as registered : " + claimingPair(false, "/exports"));
System.out.println(" /exports swapped : " + claimingPair(true, "/exports"));
System.out.println(" order is unobservable here, because neither link reads the other's answer");
System.out.println(" a gate and a log, which both touch the same request:");
System.out.println(" gate then log : " + gateAndLog(false));
System.out.println(" log then gate : " + gateAndLog(true));
System.out.println(" order decides what the log contains. That is a contract, not a detail");
}
/** 6 — the price of the shape, in frames. */
private static void whatSixLinksCostAtOneInTheMorning() {
System.out.println("--- 6. what the indirection costs when something breaks");
for (int links : new int[] {0, 1, 3, 6}) {
System.out.println(" " + links + " link(s): " + depthWith(links)
+ " frames in the handler's own stack trace");
}
System.out.println(" and no single file says what happens to a request. The pipeline exists");
System.out.println(" only as the order of use() calls, which is why NOTES.md puts the order");
System.out.println(" rule at the registration site rather than in any middleware class");
}
// --- helpers -----------------------------------------------------------------------------
/** Records that it was entered, then that it finished — so the nesting is visible. */
private static Middleware tracing(String name, List<String> trace) {
return (request, chain) -> {
trace.add(name + " in");
Response response = chain.next(request);
trace.add(name + " out");
return response;
};
}
/**
* Two links in the chain-of-responsibility shape: each one either answers or passes the
* request on untouched, and neither looks at what came back.
*/
private static String claimingPair(boolean swapped, String path) {
Middleware reports = (request, chain) -> request.path().equals("/reports")
? new Response(200, "reports")
: chain.next(request);
Middleware exports = (request, chain) -> request.path().equals("/exports")
? new Response(200, "exports")
: chain.next(request);
MiniRouter router = new MiniRouter();
router.use(swapped ? exports : reports);
router.use(swapped ? reports : exports);
Response response = router.handle(Request.of(path));
return response.status() + " \"" + response.body() + "\"";
}
/** The gate and the log, in either order, reporting what the log ended up holding. */
private static String gateAndLog(boolean swapped) {
AccessLog log = new AccessLog();
MiniRouter router = new MiniRouter();
router.register("/admin/dashboard", request -> new Response(200, "dashboard"));
if (swapped) {
router.use(log);
router.use(new AdminGate(TOKEN));
} else {
router.use(new AdminGate(TOKEN));
router.use(log);
}
Response rejected = router.handle(new Request("/admin/dashboard", "GET"));
return "status " + rejected.status() + ", log " + log.lines();
}
/** Stack depth measured inside the handler, with n pass-through links in front of it. */
private static int depthWith(int links) {
int[] depth = new int[1];
MiniRouter router = new MiniRouter();
router.register("/deep", request -> {
depth[0] = new Throwable().getStackTrace().length;
return new Response(200, "ok");
});
for (int i = 0; i < links; i++) {
router.use((request, chain) -> chain.next(request));
}
router.handle(Request.of("/deep"));
return depth[0];
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.