Syllabus · C3
Dependency inversion and injection — the clock is a dependency
The idea
The clock is a dependency
A rate limiter is a pile of rules about time. Two a minute, a thousand an hour, and a denial that reports how long to wait. The first implementation reads Instant.now() where it needs the time, and for an afternoon that is fine.
Then you write the test. How do you watch a window turn? You wait for one.
contrast/ holds both designs and measures the difference. a/ reads the machine's clock, b/ takes a Clock in its constructor, and they are one line apart. The suite against a/ spends 1.3 to 2.2 seconds on two tests. The suite against b/ stays under 0.06 on four, and asserts more. a/ can only check that a retryAfter is no longer than one window, because it cannot choose where in the window a request lands.
Then the interviewer adds a 30-second cool-off after a denial. Against b/ that is checked in under 0.03 seconds. Against a/ there is no test under 30 seconds, because a test parameter can be shrunk and a requirement cannot. corpus/rate-limiter is this at full size: 28 base tests, every one driving a ManualClock, none of them asleep.
The diff instrument sees none of it. That cool-off cost the same 8 lines in both designs.
Inject what a test has to control, or what a second implementation already exists for. Construct the rest in place. corpus/file-system depends on the PathResolver type and builds its own TreeResolver, and left C3 off its tag list saying so.
Worked walkthrough
Line by line: what each one guarantees, and what breaks without it
Read worked/src/ first and come back. Six files, and two lines in them are the lesson. The rest is here because a rate limiter with no rules in it would not let you see why those two lines matter.
Everything below compiles. From the repository root:
.toolchain/jdk-21/bin/javac -Xlint:all -d workspace/_c3out lessons/C3/worked/src/*.java
.toolchain/jdk-21/bin/java -cp workspace/_c3out Main
-Xlint:all prints nothing and javac exits 0.
Clock.java — one method, and that is the seam
public interface Clock {
Instant now();
}
What it guarantees. There is exactly one place in this design that knows what time it is, and it is a type you can hand something else. That is the whole of dependency inversion here: FixedWindowLimiter depends on Clock, and Clock is satisfied by the test's fake and by production alike. Neither one is more real than the other from the limiter's side.
What breaks with more methods on it. Every double has to implement all of them. Add zones, calendars, formatting and a sleep, and ManualClock grows four stub methods that throw, which is how a seam turns into paperwork. corpus/rate-limiter/contract/Clock.java says this out loud under the heading "One method, on purpose". The limiter needs to know what time it is. It does not need time zones, calendars, formatting or sleeping.
The naming trap, because you will hit it. This is the default package's own Clock, not java.time.Clock. Add import java.time.Clock; to any file in the directory and the import wins, and javac then reports a type mismatch on a line you did not touch. The corpus contract warns about the same thing in the same words. corpus/trip-state-machine/contract/StepClock.java goes the other way and extends java.time.Clock. That is defensible: the problem timestamps transitions and never does arithmetic on windows, so the standard type's extra surface costs it nothing. Two shapes, one idea. What separates them is what each problem does with the time once it has it.
ManualClock.java — the substitute the seam exists for
private Instant now;
private int reads;
What reads guarantees. "One request reads the clock once" becomes an assertion instead of a comment. faded/GapTest.java uses it: assertEquals(1, clock.reads()) after one tryAcquire with two rules configured. Without a counter that property is invisible, and a design that reads the clock once per rule passes every other test you can think of.
Why the field is not volatile here, when the corpus makes it so. Nothing in this lesson reads the clock from two threads. corpus/rate-limiter/contract/ManualClock.java does mark it volatile, and its javadoc gives the reason. Its concurrency suite reads a frozen clock from a couple of hundred threads at once. "A clock that serialised its readers would be measuring the harness rather than your limiter." Copying the volatile here would be copying a decision without its reason.
if (by.isNegative()) {
throw new IllegalArgumentException("time does not run backwards: " + by);
}
What it guarantees. Every window index this clock can produce is greater than or equal to the last one. That is what makes "has this window rolled over?" a question with an answer.
The bug it prevents is not in the limiter — it is in the test that would otherwise be written. A double that accepts a negative step lets a suite assert a bucket refilling by winding time backwards into the previous window. That test passes, and it describes nothing that can happen in production.
FixedWindowLimiter.java — the two lines
Line one: the field and the constructor
private final Clock clock;
public FixedWindowLimiter(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
What final guarantees. There is no moment in this object's life when it exists without a clock. javac proves the assignment happens exactly once on every path through the constructor. lessons/J4/ has the two compiler messages that enforce it: `variable clock might not have been initialized, and variable clock might already have been assigned`. That proof is what makes an injected dependency trustworthy. No method in this class has to consider the case where there is no clock yet.
Why not a setter. setClock(Clock) costs three things at once. Every method has to tolerate a null clock, because there is now a window where the object exists and the clock does not. Two callers can disagree about which clock is installed, and the later one wins silently. And the question "what does this limiter measure time with" stops having one answer, which is exactly the question the seam was opened to make answerable. Constructor injection makes the dependency part of the type's contract; setter injection makes it a suggestion.
What requireNonNull buys over plain assignment. The failure moves from the first request to the construction site. new FixedWindowLimiter(null) throws immediately, with the parameter named, at the line that made the mistake. Without it the first tryAcquire throws a NullPointerException from inside the limiter, and the stack trace points at code that is correct.
Line two: where the clock is read
public Decision tryAcquire(String client) {
Instant now = clock.now();
What it guarantees. Every rule for this client is judged against one instant.
The bug it prevents, concretely. A client with a per-second rule and an hourly rule is checked twice. If each check reads the clock itself, the two readings differ by however long the first check took. A request can then be inside the second-window and outside the hourly one at once. The Decision assembled from those two readings is not a decision about any moment that existed. corpus/rate-limiter/reference/DECISION_LOG.md states it in the same terms. It then adds the part that matters in a round: reading it in one place makes that structural "instead of something every algorithm has to remember."
That is also the answer to the question an interviewer asks next — "why not have each rule read the clock itself?" It can. Then not doing it becomes something every rule has to remember.
Note where the instant goes after that. Into windowIndex(now, …) and untilNextWindow(now, …), both static. A static method cannot reach a field, so neither of them can read a clock. That is the same discipline corpus/rate-limiter/reference/src applies to RateLimitAlgorithm, whose stated terms forbid an implementation from reading a clock at all: the instant arrives as a parameter.
The two-pass loop, which is not about time
for (Rule rule : forClient) { // would anybody refuse?
...
}
long headroom = Long.MAX_VALUE;
for (Rule rule : forClient) { // nobody refused, so spend
...
}
What it guarantees. A denied request spends nothing. Check-then-spend in one pass would charge the per-second rule before discovering that the hourly rule refuses. A client hammering a wall would then have its allowance eaten by requests that were never served.
Both passes use the same now, so both agree about which window they are in. That is the point above, paying off in the same method.
windowIndex, and the field that is deliberately absent
static long windowIndex(Instant at, Duration window) {
return Math.floorDiv(epochNanos(at), window.toNanos());
}
What it guarantees. A window is a function of the instant alone. There is no "when did this window start" field, so two limiters started an hour apart agree about where the boundaries are. A restart does not hand every client a fresh allowance either. corpus/rate-limiter/reference/DECISION_LOG.md makes the same call for the same reason.
Why floorDiv rather than /. Java's / truncates towards zero. For any instant before 1970 the division rounds the wrong way, so the window boundaries sit in different places on the two sides of the epoch. Instant.parse("1969-12-31T23:59:00Z") is a legal argument. This is a one-token fix for a class of bug otherwise found by a test nobody writes.
remaining reads the clock too, and that is where designs leak. It is a second entry point, and it needs to know which window's counters to look at. One Instant.now() left in this method is the most common way this design fails in a real submission. It fails quietly, too: tryAcquire still behaves, so every test aimed at the interesting part stays green.
Main.java — the driver, and the second implementation
Clock production = Instant::now;
What it guarantees. The interface has two implementations, so it is not speculative. That counts for scoring, not only for design. STANDARD v1.0's D3 level 3 requires a minimal seam set, and when-not.md is about a seam with one implementation and no second one in sight.
One line, and no factory, no configuration file, no registry. If wiring the real clock needed more than this, that would be an argument for looking at the seam again.
What the driver shows that a wall-clock driver cannot. Run it and read the timestamps:
-- 10:00:20, two per minute and three per hour --
2026-08-18T10:00:20Z ALLOWED, 1 left
2026-08-18T10:00:20Z ALLOWED, 0 left
2026-08-18T10:00:20Z DENIED by burst, retry after PT40S
-- wind on 40s, which is exactly what the denial asked for --
2026-08-18T10:01:00Z ALLOWED, 0 left
2026-08-18T10:01:00Z DENIED by hourly, retry after PT59M
Three windows, two rules and an hour-scale limit, in a program that finishes instantly. PT40S is the interesting number: the denial at 10:00:20 says wait 40 seconds, and 40 seconds later the request is allowed. On the wall clock you could print that number but not demonstrate it.
Demonstrating it is the difference between D2 level 2 and level 3. Level 3 wants the base suite green "with the clock still running, and the driver shows the interesting cases rather than the happy path only."
The hourly denial is the case worth pointing at in a round. It is reachable in a driver only because time is a parameter of the program rather than a property of the machine.
When not to
When not to inject
Inversion is not "put an interface in front of every collaborator". STANDARD v1.0 says so with a number attached. D3 level 2 wants "dependencies are injected, including time". D3 level 3 wants "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". The standard then states the symmetry outright: "level 3 penalises over-abstraction as much as level 0 penalises none." The failure tag is over-engineered (premature interface).
So level 2 asks you to inject the clock. Level 3 asks you to stop there.
The corpus refused, and it is worth reading how
corpus/file-system is the case. Its one field and its only constructor, in reference/src/InMemoryFileSystem.java:
private final PathResolver tree;
public InMemoryFileSystem() {
this.tree = new TreeResolver(new DirectoryNode());
}
The field is the abstraction. PathResolver is an interface, and not one of the eight operations in that class ever holds a Node it found by walking children itself. Every one of them asks the resolver. That is inversion working, and reference/DECISION_LOG.md shows the payoff three times over. The shortcuts curveball taught seven operations about a new kind of node with zero lines changed in any of them, because only the resolver had to learn.
And the constructor builds its own TreeResolver anyway.
That is not an oversight, and the syllabus tag proves it. corpus/file-system/problem.json lists A3 A5 A6 A7 · B5 · C1 C2 · D1 D2 D3 · F1 F2 F3 F4 F5. C3 is not on it. The problem depends on the right type and declines to add the injection point. The decision log states the test it applied one paragraph over, about the factory it also refused, under "What was deliberately not built":
No factory. Nothing about what a standard file system is made of varies — there are no policies to wire, no starting contents, no configuration. [...] A factory earning its keep needs a decision to hide; this problem's construction has none.
Read what it checked for: no policies to wire, no starting contents, no configuration. Add the one it did not have to mention, because the problem statement scopes out timestamps: no clock. So there is nothing a test would want to substitute, and nothing that varies. A PathResolver parameter on that constructor would let a caller pass a different tree, and no caller ever would.
What it would have cost. Every construction site would name a resolver. Entry.create() becomes new InMemoryFileSystem(new TreeResolver(new DirectoryNode())), and the class stops being able to guarantee that its resolver is the one it started with. A caller could hand it a half-built tree. The invariant "this file system's tree is rooted at a directory that exists" moves out of the constructor and becomes the caller's problem. D1 level 3 asks for the opposite: invariants in constructors, so no invalid instance can exist.
The concrete bad example
Here is the version that looks like this lesson has been learned. Applied to worked/src, it compiles: javac 21 with -Xlint:all prints nothing and exits 0.
public interface CounterStore {
long get(Object key);
void put(Object key, long value);
}
public final class InMemoryCounterStore implements CounterStore {
private final java.util.Map<Object, Long> counters = new java.util.HashMap<>();
@Override public long get(Object key) { return counters.getOrDefault(key, 0L); }
@Override public void put(Object key, long value) { counters.put(key, value); }
}
public final class CounterStoreFactory {
public static CounterStore forLimiter(String limiterName) {
return new InMemoryCounterStore();
}
}
Then FixedWindowLimiter takes a CounterStore beside its Clock, and the argument for it sounds identical to the argument for the clock. It is a collaborator. It holds state. Redis exists.
What a reviewer sees. Three files and one behaviour. forLimiter("edge") and forLimiter("internal") return the same kind of object, so the parameter is a promise the code does not keep. Object as a key type is the tell: the real key is the private Counter record, and an interface that cannot name its own key type has not found a boundary. The next person adds a CounterStoreRegistry so stores can be configured, and now there is a configuration format for a system with one store.
Why the clock is different, in one sentence each. The clock has two implementations in the lesson, ManualClock and the Instant::now lambda in Main.java, and one of them is the only way to observe the behaviour at all. CounterStore has one implementation, no second one named in any requirement, and a HashMap that is already deterministic, so a substitute would prove nothing a test cannot prove today.
What the grader sees. Behaviour is behind an interface, so D3 reaches level 2. Level 3 is now out of reach, because the seam set is not minimal. over-engineered routes to lessons/B1 at the when-not stage. This is a case where doing more scores less.
The axis this lesson deliberately did not seam
worked/src/FixedWindowLimiter.java has three time-related helpers and none of them is injectable:
static long windowIndex(Instant at, Duration window)
static Duration untilNextWindow(Instant at, Duration window)
private static long epochNanos(Instant at)
All three are static, which means they cannot reach a field, which means they cannot read a clock even by accident. That is not a missing seam. It is the compiler enforcing the rule the class exists to hold. A WindowStrategy interface here would be an implementation nobody has asked for. It would also hand back the guarantee the static keyword is buying. An implementation of it could read a clock, and then a request would no longer be judged at one instant.
Sliding windows are the obvious "but what about" here. They are a real variation, and corpus/rate-limiter's reference does put algorithms behind RateLimitAlgorithm for exactly that reason. Two points about that. It has a second implementation, TokenBucketAlgorithm, so it passes the test. And its stated terms forbid implementations from reading a clock: the instant arrives as a parameter. The seam is on the counting rule. It is still not on the source of time.
What the seam costs, measured
Three prices, and the middle one is the expensive one.
It costs code before any requirement arrives. From node lessons/C3/contrast/measure.mjs:
size of a 2 file(s) 44 normalised lines
size of b 5 file(s) 82 normalised lines
Both directories are commented for teaching, so read the shape rather than the multiplier. The seam added three files and a constructor parameter for behaviour that did not change.
It costs more than no seam when the requirement wants a different kind of time. Also measured:
a/, wall clock inside | b/, clock injected | |
|---|---|---|
| cool-off after a denial | 8 | 8 |
| monotonic uptime | 5 | 28 |
The 28 is the number that matters here. Uptime needs a monotonic tick count, and Clock carries Instant now() and nothing else. Widening it touched the interface, the manual implementation, the limiter and the wiring — four files, where the design with no seam touched one. Eleven of those lines are one nested class replacing a lambda, because a two-method interface is not a functional interface:
Wiring.java:11: error: incompatible types: Clock is not a functional interface
Clock system = Instant::now;
Note also the tie on the row above. The cool-off change cost the same 8 lines in both designs, so injection bought nothing that measureChange can see. What it bought was under 0.03 seconds instead of 30, and only the D2 instrument reports that.
It costs the ability to construct the object casually. new FixedWindowLimiter(clock) has no no-argument form, on purpose. Every test, every driver and every call site now names a clock. That is the right trade for a rate limiter, where time is the subject. It is the wrong trade for a class whose collaborator never varies, and corpus/file-system is what the wrong trade avoided.
Constructor, not setter, and the reason is one keyword
private final Clock clock;
public FixedWindowLimiter(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
final plus a constructor assignment is what makes an injected dependency trustworthy. javac proves the field is assigned exactly once on every path. lessons/J4/ has the two messages: variable clock might not have been initialized, and `variable clock might already have been assigned. So there is no moment when a FixedWindowLimiter` exists without a clock, and no method has to consider one.
A setClock(Clock) gives all of that back. Every method has to tolerate a null clock, because the object can now exist before the clock does. Two callers can disagree about which clock is installed, and the later one wins silently. Worst of all, a test that swaps the clock halfway through leaves the object holding counters from one timeline and reading another. Constructor injection makes the dependency part of the type. Setter injection makes it a suggestion.
The threshold, from both sides
Inject when either half is true.
The collaborator reads something outside the program that a test needs to control. Time, randomness, the network, the file system, an id generator. One test is the whole justification, and for the clock the test does not otherwise exist: you cannot assert a 30-second freeze in under 30 seconds without it.
Or a second implementation exists, or a requirement sentence names one. Two that exist, or one plus one named, is enough. corpus/rate-limiter/contract/Clock.java clears this twice over: the grader hands the design a ManualClock, and "production would hand you Instant::now as a one-line lambda."
Construct in place when neither is true. The collaborator is a pure function of its inputs, it has one implementation, and no requirement names a second. Then `new TreeResolver(new DirectoryNode())` in the constructor is the right answer, and the invariant stays where it can be enforced.
Never inject to make something testable that is already deterministic. A HashMap needs no substitute. worked/src keeps spent and rules as plain fields for that reason, and every assertion in faded/GapTest.java reaches them through the limiter's own methods.
What this file is not saying
It is not saying inject less. The single highest-frequency reason an LLD design becomes untestable is a call to Instant.now() buried inside a method that decides something, and one line fixes it. corpus/cost-explorer/contract/TimeSource.java puts the consequence of skipping it in the plainest terms in the corpus. A design that reads the wall clock inside a proration calculation "will fail the suite in a way that looks like an arithmetic bug."
Inject time. Then look at the next collaborator and ask what a test would want to substitute, and be willing to answer nothing.
The contrast pair
The measured pair: one line of difference, three instruments, and only one of them can see it
Two limiters. a/WallClockLimiter.java calls Instant.now() inside tryAcquire. b/InjectedClockLimiter.java takes a Clock in its constructor and calls clock.now() there instead. Read them side by side. Apart from the class name, the field, the constructor parameter and that one call, they are the same file.
Both compile clean under javac -Xlint:all. Both work. a/ is not a straw man: it reads the time once per request and threads the instant into judge. That is what a competent engineer writes under pressure, and it is what most submissions look like.
Then three things happen to them.
Change one, in the interviewer's words
One more thing on the limiter. When you refuse a client, refuse it for the next thirty seconds whatever happens — even if a new window starts in the middle. We had a customer retrying in a tight loop and the per-window reset was letting them straight back in.
Change two, in the interviewer's words
Ops wants uptime out of this thing. Careful though: not from the wall clock. Our boxes get NTP corrections and we have seen negative durations in logs before. Use a monotonic source.
The numbers
node lessons/C3/contrast/measure.mjs
Real output from one run. The four diffLines numbers are the same on every run. The three timing rows are not, and the spread is discussed below.
cool-off after a denial (about time) a -> a-cooloff diffLines 8 touched 1 new 0 [WallClockLimiter.java +7/-1]
cool-off after a denial (about time) b -> b-cooloff diffLines 8 touched 1 new 0 [InjectedClockLimiter.java +7/-1]
monotonic uptime (new source) a -> a-monotonic diffLines 5 touched 1 new 0 [WallClockLimiter.java +5/-0]
monotonic uptime (new source) b -> b-monotonic diffLines 28 touched 4 new 0 [Clock.java +2/-0, InjectedClockLimiter.java +6/-0, ManualClock.java +6/-0, Wiring.java +12/-2]
size of a 2 file(s) 44 normalised lines
size of b 5 file(s) 82 normalised lines
a/ with tests-sleep 2 tests, both sleep 2/2 pass green true junit reports 1.989s in tests whole call 3.27s
b/ with tests-manual 4 tests, none sleep 4/4 pass green true junit reports 0.018s in tests whole call 1.30s
b-cooloff/ tests-cooloff a 30s freeze 1/1 pass green true junit reports 0.028s in tests whole call 1.31s
a/, wall clock inside | b/, clock injected | |
|---|---|---|
| fixed cost before any change | 2 files, 44 lines | 5 files, 82 lines |
| cool-off after a denial | 8 lines, 1 file | 8 lines, 1 file |
| monotonic uptime | 5 lines, 1 file | 28 lines, 4 files |
| checking the behaviour | 1.3 to 2.2 s, 2 tests | under 0.06 s, 4 tests |
| checking a 30-second freeze | at least 30 s per assertion | under 0.03 s |
What each number means
The cool-off change costs 8 lines either way, and that is the finding. Identical edits, in one existing file, in both designs. measureChange is the function that scores D4 in a graded attempt, and here it reports a tie. If you came to this lesson expecting injection to shrink a diff, it does not. What it changes is elsewhere, and no diff instrument can see it.
Where it does change is the last row. tests-cooloff/CooloffTest.java asserts the interesting case: a freeze that starts ten seconds before a window boundary and outlasts it, so a fresh window opens while the client is still refused. Against b-cooloff/ that is four assertions in under 0.03 seconds. Against a-cooloff/ there is no version of it under 30 seconds, and the reason is not laziness. COOL_OFF is Duration.ofSeconds(30) in both trees because the requirement said thirty seconds. A test parameter can be shrunk. A requirement cannot.
The 1.989 against 0.018 is measured, and the 1.989 is not even stable. Six runs of tests-sleep reported 1.257, 1.355, 1.492, 1.670, 1.989 and 2.153 seconds in tests. The spread is the second test waiting out a retryAfter whose length depends on where in the window the run happened to start. tests-manual stayed under 0.06 seconds in all six. A suite whose duration you cannot predict is a suite that eventually gets a longer timeout instead of a fix.
The diffLines numbers, by contrast, are identical on every run. That is the difference between measuring structure and measuring behaviour, and it is worth knowing which of the two a number is.
And tests-sleep asserts less, not the same. Read the two files. SleepTest can only check that retryAfter is no longer than one window, because it cannot choose where in the window a request lands. ManualTest asserts the exact value, 40 seconds from 20 seconds into a minute, and then asserts that one nanosecond less is not enough. A design that returned the whole window length passes SleepTest and fails ManualTest. The sleep suite is slower and weaker.
b/ costs 38 normalised lines and three extra files before anything happens. That is the seam's fixed price: Clock.java, ManualClock.java, Wiring.java, a field and a constructor parameter. Both directories are commented for teaching, so read the shape rather than the multiplier. when-not.md is about when that price is not worth paying.
The 28 against 5 is the change that goes the wrong way. Uptime needs a monotonic tick count, and Clock carries Instant now() and nothing else. Widening it touches the interface, every implementation, and the wiring — four files. a-monotonic/ adds a field and a method to the one class that needed them: five lines.
There is a compile error inside that 28 worth seeing, because it is the seam's cost showing up as a type error rather than as a design opinion. b/Wiring.java reads:
Clock system = Instant::now;
Add a second abstract method to Clock and that line stops compiling:
Wiring.java:11: error: incompatible types: Clock is not a functional interface
Clock system = Instant::now;
^
multiple non-overriding abstract methods found in interface Clock
1 error
Wiring.java is 12 added and 2 removed, and 11 of those additions are the nested SystemClock class that replaces the lambda. This is the same shape corpus/lru-cache/curveballs/02-per-entry-ttl measures at full size. Its reference_diff is 9, and its budget.json note says all nine are "a single unreachable stub method forced by widening LruCacheApi". A class that implements an interface directly pays for every method added to it, whether it uses the method or not.
The alternatives, so the choice is a choice
Could b/ have absorbed the uptime requirement by adding a file? Yes, and it is the better answer. Inject a second, separate dependency, a Ticker with long nanoTicks(), and leave Clock alone. Nothing already written changes. That is not what was measured here, and the reason is worth stating. The 28 is the price of the reflex to widen the interface you already have, which is what happens with twelve minutes left. Interface segregation is syllabus item C5, and this is the measurement that motivates it.
Could a/ have been tested without sleeping? Only by changing the design, which is the point. The usual attempts each fail somewhere specific. A static Instant fakeNow that tests overwrite makes the clock global, so two tests cannot run in parallel and one that forgets to reset it breaks the next. Mocking Instant.now() needs a bytecode-rewriting mock framework, and no LLD round lets you add one. Passing the instant into tryAcquire does work. corpus/rate-limiter/contract/Clock.java explains at length why it takes the other road. The invariant stops being yours: "this client gets a hundred a minute" becomes "provided every caller passes an honest timestamp".
Is a/ therefore wrong? Not in every problem. corpus/file-system has no clock anywhere, and corpus/file-system/reference/src/InMemoryFileSystem.java constructs its own collaborator in one line for that reason. when-not.md is about that refusal.
What this pair does not show
Two files against five, and 44 normalised lines against 82. In a real problem the fixed cost of the seam stays about the same and everything else grows around it, which makes the ratio flatter and the testability argument stronger. corpus/rate-limiter is the full-scale version: one Clock interface, one ManualClock, and 28 base tests, not one of which sleeps. Its reference/DECISION_LOG.md records what that bought for a system whose rules are measured in minutes and hours: "the whole base suite finishes in under two seconds".
Worked source
The 6 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/Clock.java20 linesworked/src/Decision.java21 linesworked/src/FixedWindowLimiter.java95 linesworked/src/ManualClock.java49 linesworked/src/Rule.java22 linesworked/src/Main.java56 lines
worked/src/Clock.java20 lines
import java.time.Instant;
/**
* Where the limiter reads "now" from. The only source of time in this design.
*
* One method, and that is the whole seam. Every test double has to implement everything on this
* interface, so a type offering zones, calendars, formatting and sleeping would be a type nobody
* wants to fake. The limiter needs to know what time it is and nothing else.
*
* This is the shape corpus/rate-limiter/contract/Clock.java hands the candidate, and its javadoc
* argues the case in writing. Read it — the argument is the lesson.
*
* Naming note: this is the default package's own Clock, not java.time.Clock. Adding
* `import java.time.Clock;` to any file here shadows this type and nothing compiles.
*/
public interface Clock {
/** What time it is now. Never null, and never earlier than a previous call. */
Instant now();
}
worked/src/Decision.java21 lines
import java.time.Duration;
/**
* What one request got told: allowed or not, how much is left, how long to wait, and which rule
* refused.
*
* retryAfter is the field that makes the clock's placement observable from outside. It is not
* "the window length" — it is the distance from this instant to the start of the next window, so
* a caller that waits exactly that long is allowed and one that waits a nanosecond less is not.
* A design that cannot control its clock cannot assert that number, only that it looks plausible.
*/
public record Decision(boolean allowed, long remaining, Duration retryAfter, String deniedBy) {
public static Decision allow(long remaining) {
return new Decision(true, remaining, Duration.ZERO, null);
}
public static Decision deny(Rule rule, Duration retryAfter) {
return new Decision(false, 0L, retryAfter, rule.name());
}
}
worked/src/FixedWindowLimiter.java95 lines
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A fixed-window rate limiter. Several rules per client, all judged together.
*
* Two facts about this class are the lesson, and both are one line each:
*
* 1. it takes a Clock in its constructor and holds it in a final field
* 2. tryAcquire calls clock.now() once, on its first line, and passes that instant downwards
*
* Nothing else in this file reads a clock, and nothing below tryAcquire is allowed to. The same
* split is in the corpus at a larger size: RateLimitAlgorithm's stated terms in
* corpus/rate-limiter/reference/src forbid an implementation from reading a clock at all, and the
* instant arrives as a parameter instead.
*/
public final class FixedWindowLimiter {
/** Which counter a request touches: this client, this rule, this window. */
private record Counter(String client, String ruleName, long windowIndex) {}
private final Clock clock;
private final Map<String, List<Rule>> rules = new HashMap<>();
private final Map<Counter, Long> spent = new HashMap<>();
public FixedWindowLimiter(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Replaces this client's rules. An unconfigured client is unlimited. */
public void configure(String client, List<Rule> forClient) {
Objects.requireNonNull(client, "client");
rules.put(client, List.copyOf(forClient));
}
/**
* One request. Every rule is judged against one instant, and budget is spent only if all of
* them would allow it.
*/
public Decision tryAcquire(String client) {
Instant now = clock.now();
List<Rule> forClient = rules.getOrDefault(client, List.of());
for (Rule rule : forClient) {
long index = windowIndex(now, rule.window());
if (used(client, rule, index) >= rule.limit()) {
return Decision.deny(rule, untilNextWindow(now, rule.window()));
}
}
long headroom = Long.MAX_VALUE;
for (Rule rule : forClient) {
Counter counter = new Counter(client, rule.name(), windowIndex(now, rule.window()));
headroom = Math.min(headroom, rule.limit() - spent.merge(counter, 1L, Long::sum));
}
return Decision.allow(headroom);
}
/** How many more requests this client has, on its tightest rule. */
public long remaining(String client) {
Instant now = clock.now();
long headroom = Long.MAX_VALUE;
for (Rule rule : rules.getOrDefault(client, List.of())) {
long index = windowIndex(now, rule.window());
headroom = Math.min(headroom, rule.limit() - used(client, rule, index));
}
return headroom;
}
private long used(String client, Rule rule, long windowIndex) {
return spent.getOrDefault(new Counter(client, rule.name(), windowIndex), 0L);
}
/**
* Which window an instant falls in: a function of the instant alone, with no "when did this
* window start" field anywhere.
*/
static long windowIndex(Instant at, Duration window) {
return Math.floorDiv(epochNanos(at), window.toNanos());
}
/** From this instant to the start of the next window. */
static Duration untilNextWindow(Instant at, Duration window) {
long windowNanos = window.toNanos();
return Duration.ofNanos((windowIndex(at, window) + 1) * windowNanos - epochNanos(at));
}
private static long epochNanos(Instant at) {
return at.getEpochSecond() * 1_000_000_000L + at.getNano();
}
}
worked/src/ManualClock.java49 lines
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* A Clock that only moves when it is told to. This is the substitute the whole seam exists for.
*
* Single-threaded on purpose: this lesson never reads it from two threads, so the field is a
* plain field. corpus/rate-limiter/contract/ManualClock.java makes the same field volatile,
* because its concurrency suite reads a frozen clock from a couple of hundred threads at once.
*
* It also counts its reads, which corpus/trip-state-machine/contract/StepClock.java gets at a
* different way — that one moves on by a fixed step every time it is read, so the timestamps
* themselves tell you how many reads happened. Either way the count is assertable, and
* "one request reads the clock once" stops being a comment and becomes a test.
*/
public final class ManualClock implements Clock {
private Instant now;
private int reads;
/** A clock stopped at {@code start}. */
public ManualClock(Instant start) {
this.now = Objects.requireNonNull(start, "start");
}
@Override
public Instant now() {
reads++;
return now;
}
/** How many times anything has asked what time it is. */
public int reads() {
return reads;
}
/**
* Winds the clock forward. Zero is allowed, negative is not: a clock that can run backwards
* makes "has this window rolled over?" unanswerable.
*/
public void advance(Duration by) {
Objects.requireNonNull(by, "by");
if (by.isNegative()) {
throw new IllegalArgumentException("time does not run backwards: " + by);
}
now = now.plus(by);
}
}
worked/src/Rule.java22 lines
import java.time.Duration;
import java.util.Objects;
/**
* One limit: a name, how many calls, and over what stretch of time.
*
* The name is here so a denial can say which rule refused. A client with a per-second rule and an
* hourly rule that is told only "denied" cannot tell "slow down" from "come back tomorrow".
*/
public record Rule(String name, long limit, Duration window) {
public Rule {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(window, "window");
if (limit <= 0) {
throw new IllegalArgumentException("a limit of " + limit + " denies everything; say so directly");
}
if (window.isZero() || window.isNegative()) {
throw new IllegalArgumentException("a window has to have length, got " + window);
}
}
}
worked/src/Main.java56 lines
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/**
* The driver. It shows a window rolling over, twice, without waiting for one.
*
* corpus/rate-limiter/reference/src/Demo.java does the same thing for the same reason: a demo of a
* rate limiter on the wall clock would have to sleep for a minute to show one window turn, and an
* interviewer running it would watch a blank terminal.
*/
public final class Main {
private static final String ACME = "acme";
public static void main(String[] args) {
ManualClock clock = new ManualClock(Instant.parse("2026-08-18T10:00:20Z"));
FixedWindowLimiter limiter = new FixedWindowLimiter(clock);
limiter.configure(ACME, List.of(
new Rule("burst", 2, Duration.ofMinutes(1)),
new Rule("hourly", 3, Duration.ofHours(1))));
System.out.println("-- 10:00:20, two per minute and three per hour --");
knock(limiter, clock, 3);
System.out.println("-- wind on 40s, which is exactly what the denial asked for --");
clock.advance(Duration.ofSeconds(40));
knock(limiter, clock, 2);
System.out.println("-- the minute rule has room again; the hourly rule does not --");
clock.advance(Duration.ofMinutes(1));
knock(limiter, clock, 1);
System.out.println(" remaining " + limiter.remaining(ACME));
// The second implementation, and the reason this interface is not speculative. Production
// hands over one line; every alternative to injection has to replace this line with a
// build flag or a static that tests reach in and overwrite.
Clock production = Instant::now;
FixedWindowLimiter live = new FixedWindowLimiter(production);
live.configure(ACME, List.of(new Rule("burst", 1, Duration.ofMinutes(1))));
System.out.println("-- wired to the system clock, first call --");
System.out.println(" allowed " + live.tryAcquire(ACME).allowed());
}
private static void knock(FixedWindowLimiter limiter, ManualClock clock, int times) {
for (int i = 0; i < times; i++) {
Decision d = limiter.tryAcquire(ACME);
if (d.allowed()) {
System.out.println(" " + clock.now() + " ALLOWED, " + d.remaining() + " left");
} else {
System.out.println(" " + clock.now() + " DENIED by " + d.deniedBy()
+ ", retry after " + d.retryAfter());
}
}
}
}
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.