Chapter 11 of 18
11 — Dependency injection
Constructor injection, @FunctionalInterface, and the composition root.
Kata: a Greeter that depends on how it sends a message — and never builds it itself.
New ideas: constructor injection, a @FunctionalInterface, the composition root, and why in
Java “inject” is just “pass a dependency in”.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished class also ships in
src/main/java/di— don’t open it yet. Type the code from the steps below and run the tests as you go. The failing runs are the point.
The problem with System.out.println
You probably already guessed the lazy version:
public final class Greeter {
public void greet(String name) {
System.out.println("Hello, " + name);
}
}
Works. But how do you test it? You’d have to capture System.out — global mutable state. The
solution: depend on how you send, not what you send to. Define a seam — an interface — and
pass the sink in.
Step 1 — RED: write the test that forces the injection
The test needs a way to observe the output. So the first test written is also the one that decides the design — pass the sink in:
@Test
void sendsTheGreeting() {
List<String> sent = new ArrayList<>();
Greeter greeter = new Greeter(sent::add);
greeter.greet("Ada");
assertEquals(List.of("Hello, Ada"), sent);
}
Run it with gradle test --tests "di.GreeterTest". Greeter doesn’t exist yet, so the test
cannot even compile — that is your RED:
error: cannot find symbol
Greeter greeter = new Greeter(sent::add);
^
symbol: class Greeter
A test that doesn’t compile is the most honest failing test there is: it says “this behaviour doesn’t exist.”
Step 2 — let the test shape the API
new Greeter(sent::add) only compiles if two things exist: a constructor that takes a
“send a message” and something that matches the lambda. Both are being decided by the test.
Define the seam:
@FunctionalInterface
public interface Sender {
void send(String message);
}
A @FunctionalInterface is Java’s function-typed seam: any single-method interface can be
supplied by a lambda or method reference. sent::add matches List.add against send.
Now the smallest Greeter — constructor present, body empty — gets the test to run instead of
fail at compile:
public final class Greeter {
private final Sender sender;
public Greeter(Sender sender) {
this.sender = sender;
}
public void greet(String name) {
// nothing yet
}
}
RED again, but now for real:
GreeterTest > sendsTheGreeting() FAILED
org.opentest4j.AssertionFailedError: expected: <[Hello, Ada]> but was: <[]>
The test describes the behaviour; the object watches it fail. That’s the loop.
Step 3 — GREEN: make it pass
public void greet(String name) {
sender.send("Hello, " + name);
}
GREEN. But — a second test already exists in this chapter’s suite, and it knows you’re
cheating. defaultsToHelloWorld greets "" and expects the greeting to fall back:
@Test
void defaultsToHelloWorld() {
List<String> sent = new ArrayList<>();
Greeter greeter = new Greeter(sent::add);
greeter.greet("");
assertEquals(List.of("Hello, World"), sent);
}
"Hello, " + "" is "Hello, ", not Hello, World. Run gradle test --tests "di.GreeterTest":
RED:
GreeterTest > defaultsToHelloWorld() FAILED
org.opentest4j.AssertionFailedError: expected: <[Hello, World]> but was: <[Hello, ]>
Step 4 — the second test earns the real implementation
public void greet(String name) {
if (name == null || name.isBlank()) {
name = "World";
}
sender.send("Hello, " + name);
}
Run: GREEN. The first test drove the shape (constructor + seam); the second drove the
behaviour (the fallback). final field + constructor-assigned once — the dependency cannot be
swapped later. That’s constructor injection: the object states “I need a Sender, always”.
Step 5 — PROD wiring is the composition root
Somewhere the real world has to supply the real dependency. One place, as far from the logic as possible — the composition root:
public final class Application {
public static void main(String[] args) {
Greeter greeter = new Greeter(System.out::println);
greeter.greet("Ada");
}
}
Now tests wire a capture list, production wires stdout. Same Greeter, zero changes. Run:
gradle -q run won’t work (no application plugin) — run it from your IDE, or add
id 'application' to build.gradle. The point isn’t the main; it’s that wiring lives outside.
REFACTOR — a fake that counts (the mocking preview)
A third test in the suite doesn’t care about what is sent, only how many times:
private static final class CountingSender implements Sender {
private int count;
@Override public void send(String message) { count++; }
int count() { return count; }
}
@Test
void canCountWithoutCaringAboutWhatsSent() {
Greeter greeter = new Greeter(new CountingSender());
greeter.greet("A");
greeter.greet("B");
assertEquals(2, greeter.count());
}
The test asserts how many times — not what was sent. That’s a test double with spy-like behaviour, which chapter 12 builds into full fakes and spies.
Java-specific notes
- Frameworks do this for you. Constructor injection is exactly what dependency-injection frameworks (e.g. Spring) call “injection”: you write the interface, the framework supplies the implementation at the composition root. The idea is what matters, not the annotations.
@FunctionalInterfaceis a lint annotation — a compile error if you add a second abstract method.System.out::printlnmatchesvoid send(String)(println has an overload forString).- “Program to an interface, inject the dependency” — the same muscle as
Shapein chapter 06. The difference: 06 wired inside the same class; here the caller supplies it.
Run it
gradle test --tests "di.GreeterTest"
Key takeaway: if code is hard to test, it’s not the tests’ fault — it’s the code’s. Hardwiring
a side effect (System.out.println) outside the object’s control is the smell; injecting a seam
makes behaviour observable and swap-in by design. And notice what just happened above: every
line of Greeter was written after a test described it first and watched it fail.
Next: 12 — mocking — full hand-rolled doubles: fakes + spies.