Unit 10 · lesson
Program to the Contract, Not the Concrete Class
Suppose a report generator is written like this:
class ReportService {
private ConsoleSink sink;
ReportService(ConsoleSink sink) {
this.sink = sink;
}
void publish(String text) {
sink.send(text);
}
}
This works, but the service is coupled to one concrete sink even though it only needs send behavior.
Use the capability as the dependency:
class ReportService {
private final MessageSink sink;
ReportService(MessageSink sink) {
this.sink = sink;
}
void publish(String text) {
sink.send(text);
}
}
Now ReportService depends on the stable contract it actually uses.
Substitution becomes visible
var consoleService = new ReportService(new ConsoleSink());
var memoryService = new ReportService(new MemorySink());
The service code did not change.
This is a practical form of polymorphism: the runtime object can vary while the caller uses the same interface operations.
Why this helps testing
A memory implementation can make effects observable without relying on real external systems.
For example, a test-friendly sink can record the last message:
class RecordingSink implements MessageSink {
private String lastMessage = "";
public void send(String message) {
lastMessage = message;
}
String lastMessage() {
return lastMessage;
}
}
The report service can now be tested by inspecting the recording sink.
That is not "fake code" if the test double preserves the contract relevant to the test.
Do not abstract before there is a boundary
This is not a rule that every class needs an interface.
If a tiny application has one simple class and no realistic substitution or collaboration boundary, adding UserServiceInterface, UserServiceImpl, AbstractUserService, and factories can make reasoning worse.
Create an abstraction when it expresses a stable capability or decouples a meaningful dependency.
Change-impact experiment
Start with a method that accepts a concrete implementation.
Then change the requirement:
The caller must also support a second implementation without editing the caller's internal logic.
Refactor the parameter to an interface type.
Document:
- files/lines that changed before the interface;
- files/lines that changed after the interface boundary exists;
- what the caller now knows;
- what it no longer knows.
Evidence
Submit one caller that uses two implementations through one interface-typed parameter or field. Run the same caller behavior with each implementation.
Then explain the specific dependency the interface removed. "Interfaces are flexible" is too vague. Name the concrete knowledge that disappeared from the caller.