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.
How do you know a window has passed? 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. Instant.now() is the call you reach for first, and you would be right to.
The trouble starts 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. Look at the last line.
Two seconds to prove one fact. Proving that a two-second window resets means waiting two real seconds.
Now scale that up. A production window is a minute, or an hour. Your honest options are to wait an hour, or to shrink the window so the test is bearable — and shrinking it means the test no longer checks the configuration you actually ship.
2. The move: ask for the clock instead of reaching for it
Here is the reframe. Treat Instant.now() as a collaborator, exactly like a database connection or a network call. RateLimiter secretly depends on it in every method.
The whole fix is to name that dependency and ask for it in the constructor, instead of reaching for it inside the method body.
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;
}
}
Now your test gets a ManualClock that only moves when told.
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. The whole test finishes in the time the JVM takes to start, and nothing sleeps anywhere.
Watch what inverted. RateLimiter used to depend on a concrete fact, the machine's own clock. Now it depends on an interface whose shape it defines, and both the real clock and ManualClock depend on that same interface.
Production hands it 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, expressed in code instead of as a slogan. Your high-level code — the rule about counting requests — stops reaching down into a low-level detail. The detail gets written to satisfy the shape the high-level code asked for.
And because Clock has one method, the production wiring costs you a method reference. No class required.
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. Take the clock argument as far as it seems to invite, and 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
It compiles. Now find what is wrong with it.
Nothing in the requirements names a second CounterStore. A HashMap is already deterministic, so no test needs a substitute to make it behave.
The real tell is in the key type. The actual key would be something specific to one client and one rule. An interface that can only describe its key as Object has not found a boundary in your design. It has hidden one behind a generic name.
Compare that with the clock. The clock earned injection because a test needed to control it, and because a second real implementation existed the day the interface was written. CounterStore fails both checks. Nothing needs to substitute it, and nothing else implements it.
So ask two questions of any collaborator before you reach for a constructor parameter:
Does a test need to control this? Does a second version exist, or get named in a requirement?
Two noes, and you 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, and what it stores paths in never varies for this problem. So no second implementation is being asked for at this particular call site. PathResolver being a real interface elsewhere does not oblige every user of it to accept one as a constructor parameter. You ask the two questions once per collaborator, never once for the whole codebase.
Going deeper
An injected clock is not just easier to test once. It stays easier to test as requirements change, and a diff count will hide that from you.
lessons/C3 in this app's corpus adds a 30-second cool-off rule to a real rate limiter, after the first version already shipped.
Against the injected clock, the new rule is checked in under 0.03 seconds. The test advances the clock and asserts.
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.
Now here is the part worth remembering. That gap does not always show up as a line-count difference. The same lesson found a requirement costing the identical 8 lines in both designs.
Count changed lines and you would call those two designs equal. They are not. One can be tested honestly in milliseconds. The other cannot be tested honestly at all, without waiting for real time or weakening what the test 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.
Show the moment from section 1 instead. A class reaches for Instant.now(). A requirement demands simulated time. One constructor parameter fixes both.
Then do the thing almost nobody does. Name, 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 →