Objects that hold their shape · chapter 18 of 33
Dependency inversion, and injecting the clock
Chapter 2.8 · Part 2, Objects that hold their shape · about 30 minutes
What you need before this chapter: chapters 2.1 through 2.7, plus java.time.Instant and Duration from Part 1.
When you finish this chapter you will be able to:
- Watch a class that reads the system clock directly force a test to actually wait, in measured real time
- Rebuild it so a test controls time by hand, with no waiting, and measure the difference yourself
- State the principle as a question about who defines the interface, not about interfaces in general
- Recognise a collaborator that does not deserve this treatment, and say why injecting it anyway would be a mistake
1. Working code, and the test it makes hard
A rate limiter allows two requests per window and denies the third. The obvious way to know whether a window has passed is to ask the machine what time it is, right where the decision gets made.
import java.time.Duration;
import java.time.Instant;
public final class RateLimiter {
private final int maxPerWindow;
private final Duration window;
private int count;
private Instant windowStart;
public RateLimiter(int maxPerWindow, Duration window) {
this.maxPerWindow = maxPerWindow;
this.window = window;
this.windowStart = Instant.now();
}
public boolean tryAcquire() {
Instant now = Instant.now();
if (Duration.between(windowStart, now).compareTo(window) >= 0) {
windowStart = now;
count = 0;
}
if (count < maxPerWindow) {
count++;
return true;
}
return false;
}
}
For running the limiter, this is correct, and Instant.now() is the call anyone reaches for first. The trouble shows up the moment you try to prove the window actually resets.
long start = System.currentTimeMillis();
RateLimiter limiter = new RateLimiter(2, Duration.ofSeconds(2));
System.out.println(limiter.tryAcquire());
System.out.println(limiter.tryAcquire());
System.out.println(limiter.tryAcquire());
Thread.sleep(2100); // and now the test waits for a real window to pass
System.out.println(limiter.tryAcquire());
long elapsed = System.currentTimeMillis() - start;
System.out.println("test took " + elapsed + " ms");
true
true
false
true
test took 2124 ms
The test is correct, and it passes. It also now takes over two seconds to prove one fact, because proving a two-second window resets means waiting two real seconds. A production window is a minute or an hour, not two seconds. The honest version of this test either waits an hour or shrinks the window to make the test bearable, and shrinking the window means the test no longer checks the real configuration.
2. The move: ask for the clock instead of reaching for it
Instant.now() is not a detail to accept as a given. It is a collaborator, the same way a database connection or a network call is, and RateLimiter secretly depends on it in every method. Naming that dependency and asking for it in the constructor, instead of reaching for it inside the method body, is the entire fix.
import java.time.Instant;
public interface Clock {
Instant now();
}
public final class ManualClock implements Clock {
private Instant now;
public ManualClock(Instant start) {
this.now = start;
}
@Override
public Instant now() {
return now;
}
public void advance(Duration by) {
now = now.plus(by);
}
}
public final class RateLimiter {
private final Clock clock;
private final int maxPerWindow;
private final Duration window;
private int count;
private Instant windowStart;
public RateLimiter(Clock clock, int maxPerWindow, Duration window) {
this.clock = clock;
this.maxPerWindow = maxPerWindow;
this.window = window;
this.windowStart = clock.now();
}
public boolean tryAcquire() {
Instant now = clock.now();
if (Duration.between(windowStart, now).compareTo(window) >= 0) {
windowStart = now;
count = 0;
}
if (count < maxPerWindow) {
count++;
return true;
}
return false;
}
}
The test gets a ManualClock that only moves when told to.
long start = System.nanoTime();
ManualClock clock = new ManualClock(Instant.parse("2026-01-01T00:00:00Z"));
RateLimiter limiter = new RateLimiter(clock, 2, Duration.ofSeconds(2));
System.out.println(limiter.tryAcquire());
System.out.println(limiter.tryAcquire());
System.out.println(limiter.tryAcquire());
clock.advance(Duration.ofSeconds(2)); // no waiting: the clock is told to move
System.out.println(limiter.tryAcquire());
long elapsedMillis = (System.nanoTime() - start) / 1_000_000;
System.out.println("test took " + elapsedMillis + " ms");
true
true
false
true
test took 56 ms
Same four answers, same rule, and the whole test finishes in the time it takes the JVM to start, with no sleeping anywhere. RateLimiter used to depend on a concrete fact, the machine's own clock. Now it depends on an interface it defines the shape of, and both the real clock and ManualClock depend on that same interface. Production hands the limiter the real one at the single place that builds it.
RateLimiter live = new RateLimiter(Instant::now, 2, Duration.ofSeconds(2));
That is the whole principle, in code rather than as a slogan. High-level code, the rule about counting requests, no longer reaches down into a low-level detail, the system clock. The detail is written to satisfy the shape the high-level code asked for, and Clock having one method means the production wiring above costs a method reference, not a class.
3. Where this is the wrong default
Not every collaborator deserves this. RateLimiter also needs somewhere to keep its counters, and a HashMap is the obvious choice. Taken as far as the clock argument seems to invite, that becomes an interface too.
public interface CounterStore {
long get(Object key);
void put(Object key, long value);
}
public final class InMemoryCounterStore implements CounterStore {
private final Map<Object, Long> counts = new HashMap<>();
@Override
public long get(Object key) {
return counts.getOrDefault(key, 0L);
}
@Override
public void put(Object key, long value) {
counts.put(key, value);
}
}
count: 3
This compiles, and nothing in the requirements names a second CounterStore. A HashMap is already deterministic; it needs no substitute to make a test behave. The tell is in the key type. The real key would be something specific to one client and one rule, and an interface that can only describe its key as Object has not found a boundary in the design. It has hidden one behind a generic name. The clock earned injection because a test needed to control it, and because a second real implementation, the live clock and ManualClock, existed the day the interface was written. CounterStore fails both checks: nothing needs to substitute it, and nothing else implements it.
Ask two questions of any collaborator before reaching for a constructor parameter. Does a test need to control this? Does a second version exist, or get named in a requirement? Answer no to both, and build it in place instead of injecting it.
Your turn
corpus/file-system's real file system depends on a PathResolver interface, but builds its own TreeResolver directly inside its own constructor rather than accepting one as a parameter. Apply the two checks from section 3 and explain, in one sentence, why that is the right call here, even though PathResolver is a real interface with more than one class implementing it.
The answer. Nothing about InMemoryFileSystem's own tests needs to substitute a different tree walker. What it stores paths in never varies for this problem, so there is no second implementation a test or a requirement is asking for at this particular call site. PathResolver being a real interface elsewhere does not obligate every user of it to accept one as a constructor parameter; the two questions are asked per collaborator, not once for the whole codebase.
Going deeper
The clock is not only easier to test once injected. It stays easier to test as requirements change, in a way a diff count alone will not show you. lessons/C3 in this app's own corpus adds a 30-second cool-off rule to a real rate limiter, after the first version already shipped. Tested against the injected clock, the new rule is checked in under 0.03 seconds, because the test only has to advance the clock and assert. Tested against a real clock, there is no honest way to prove it in under 30 seconds. A test's own timeout can shrink; the 30 seconds the requirement asks for cannot.
That gap does not always show up as a line-count difference. The same lesson found one requirement that cost the identical 8 lines in both the injected and the non-injected design. Counting changed lines would call the two designs equal on that one requirement, and they are not. One of them can be tested honestly in milliseconds. The other cannot be tested honestly at all, without either waiting for real time to pass or weakening what the test actually proves. Dependency inversion pays for itself exactly where a diff tool cannot see it.
Why this matters in an interview
"Depend on abstractions, not on concretions" is the textbook line, and reciting it proves nothing. What an interviewer wants to see is the moment from section 1. A class reaches for Instant.now(), a requirement demands simulated time, and one constructor parameter fixes both at once. Just as telling is naming, unprompted, which collaborators do not deserve this treatment. CounterStore is one. A PathResolver a class never needs to swap is another. That is what separates understanding the trade-off from injecting everything on principle and calling it clean design.
Next: Part 3, Patterns you will actually be asked for. It starts with chapter 3.1, Strategy: behaviour you can swap — the pattern PricingPolicy has been all along, named formally now that you have built it three times without the name.
← 2.7 Interface segregation: the smallest useful contract · All chapters · 3.1 Strategy: behaviour you can swap →