Unit 11 · lesson

Constructor Injection Makes Dependencies Visible

Compare two classes.

Hidden dependency:

class ReportService {
    private final MessageSink sink = new ConsoleSink();
}

Visible dependency:

class ReportService {
    private final MessageSink sink;

    ReportService(MessageSink sink) {
        this.sink = sink;
    }
}

In the second design, construction tells you what the service requires.

That is constructor injection: a dependency is supplied to the object instead of secretly created inside it.

Why visibility matters

A caller cannot create a ReportService without deciding what sink it should use.

That improves:

  • testability;
  • substitution;
  • configuration visibility;
  • responsibility separation.

The service owns using the sink. It does not necessarily own choosing the concrete sink for the whole application.

Required dependencies can be final

private final MessageSink sink;

If the service requires one sink throughout its lifetime, final communicates that the field reference is assigned once.

This does not automatically make the referenced sink object immutable. It means the sink field cannot later be reassigned to another reference.

Guard required collaborators

A constructor that accepts a required dependency should decide what null means.

ReportService(MessageSink sink) {
    if (sink == null) {
        throw new IllegalArgumentException("sink is required");
    }
    this.sink = sink;
}

Later you may use Objects.requireNonNull. The design principle is the same: required state should fail at a clear boundary, not explode deep inside unrelated behavior later.

Composition root

At some point, something creates the concrete objects:

void main() {
    MessageSink sink = new ConsoleSink();
    Formatter formatter = new SimpleFormatter();
    var service = new NotificationService(formatter, sink);

    service.notify("SCORE", 95);
}

This top-level construction area is where concrete implementation choices can be visible.

The rest of the system can depend on interfaces.

Test with a recording collaborator

Inject RecordingSink into the same service and verify the message without changing service logic.

That gives you evidence that the dependency boundary works.

Evidence

Take one class that currently creates a collaborator internally. Refactor it so the collaborator arrives through its constructor.

Show:

  • before construction;
  • after construction;
  • a production-style collaborator;
  • a recording/test collaborator;
  • the unchanged method that uses the interface.

Explain which decision moved out of the class and why that makes the boundary clearer.