Objects that hold their shape · chapter 17 of 33
Interface segregation: the smallest useful contract
Chapter 2.7 · Part 2, Objects that hold their shape · about 25 minutes
What you need before this chapter: chapters 2.1 through 2.6. You also need lambdas and method references from Part 1.
When you finish this chapter you will be able to:
- Watch a wide interface force a test double to implement methods it will never use
- Narrow an interface to what one caller actually calls, and turn a class into a lambda by doing it
- State interface segregation as a rule about callers, not about interface size
- Recognise a real exception to the rule, using an interface from this app's own corpus
1. Working code, for one real destination
A logger needs somewhere to send a finished line of text: a console, a file, a list a test can inspect. The first interface anyone reaches for tries to cover a destination's whole lifecycle at once.
import java.util.List;
public interface Destination {
void write(String formattedLine);
void flush();
void close();
List<String> registeredNames();
}
For a destination backed by a real file, every method has an obvious body.
public final class FileDestination implements Destination {
private final StringBuilder file = new StringBuilder();
@Override
public void write(String formattedLine) {
file.append(formattedLine).append('\n');
}
@Override
public void flush() {
System.out.println("flushed " + file.length() + " chars to disk");
}
@Override
public void close() {
System.out.println("closed");
}
@Override
public List<String> registeredNames() {
return List.of("app.log");
}
}
There is nothing wrong with this interface for a design that only ever plugs in files.
2. The new requirement, and the cost of a wide contract
A test wants a destination that is nothing more than a list: whatever gets logged, check it afterward. With four methods on Destination, that destination cannot be a single expression. It has to be a full class.
import java.util.ArrayList;
import java.util.List;
final class CapturingDestination implements Destination {
private final List<String> lines = new ArrayList<>();
@Override public void write(String line) { lines.add(line); }
@Override public void flush() { }
@Override public void close() { }
@Override public List<String> registeredNames() { return List.of(); }
List<String> captured() { return lines; }
}
CapturingDestination test = new CapturingDestination();
test.write("[INFO] started");
System.out.println("captured: " + test.captured());
captured: [[INFO] started]
This runs correctly, and it costs three methods that do nothing, written only because Destination demands a body for each of them. Every future test that wants to see what was logged pays the same tax. Every new kind of destination, a network socket, a rolling file, has to invent an answer for close() and registeredNames() whether or not the question means anything there. Nobody calling write can tell, from Destination's type alone, that the other three methods are safe to ignore in a given context. Implementing the interface commits a class to methods it may never use, and commits every caller of the interface to a promise about that class it has no way to check.
3. The move: narrow to what one caller actually calls
A logger calling write needs nothing about flushing, closing, or listing anything. Split the interface by who actually calls what.
public interface Appender {
void write(String formattedLine);
}
CapturingDestination is no longer a class. It is an expression.
List<String> lines = new ArrayList<>();
Appender captured = lines::add;
captured.write("[INFO] started");
System.out.println("captured: " + lines);
captured: [[INFO] started]
Whatever else a real file destination needs, closing or flushing, lives in whatever concrete type wraps the file, reachable by whoever manages that file's lifecycle and invisible to the logger. The logger only ever holds an Appender, and an Appender only ever has one thing it can be asked to do. That is Interface Segregation, stated as a rule you can check. No caller should be forced to depend on methods it does not use. No implementer should be forced to write bodies for methods its callers never call.
4. The version of this that is wrong
Taken further than any caller needs, segregation splits a class's methods into one interface each, even when nothing ever asks for less than the full set.
interface Writable { void write(String formattedLine); }
interface Flusher { void flush(); }
interface Closer { void close(); }
final class FileDestination implements Writable, Flusher, Closer {
@Override public void write(String formattedLine) { System.out.println("wrote: " + formattedLine); }
@Override public void flush() { System.out.println("flushed"); }
@Override public void close() { System.out.println("closed"); }
}
wrote: [INFO] started
flushed
closed
This compiles. Nothing in the design ever holds a Writable without also needing it to be a Closer. Whatever opens the file is the one thing that also has to close it. Three files exist where one interface would do, and no second implementation of any of the three justifies the split. A reader who wants to know what a file destination can do now opens three definitions instead of one. Segregation earns its keep when a real caller wants less than the whole set. CapturingDestination wanted only write, which is why splitting write out was worth doing. Nobody in this design ever wants flush without close, so splitting those two apart bought nothing.
Your turn
corpus/logger also has a Formatter interface, one method, String format(LogRecord record), completely separate from Appender. Explain in a sentence why formatting and writing are two interfaces instead of one, given that section 4 just argued against splitting for its own sake.
The answer. Two different destinations can share one formatter, and one destination can be registered twice under two different formatters; the corpus states this explicitly as a design goal. That is a real caller wanting the two capabilities independently, not a hypothetical one. A single FormattedAppender interface with both methods would force every registration to pair one specific format with one specific write target, which is exactly the flexibility the real design needs to keep.
Going deeper
A single-method interface is, by construction, a functional interface, which is why Appender and Formatter both accept a lambda anywhere Java expects one. This is not a coincidence to admire and move past. Narrowing an interface to the one thing a caller actually calls and making that interface usable as a lambda turn out to be the same piece of work, seen from two directions. `corpus/logger/ contract/Appender.java` says as much in its own documentation: a single-method interface is whatever lambda you like. Segregating an interface aggressively is, in Java, indistinguishable from making it functional.
That does not make a wide interface always wrong. corpus/rate-limiter's KeyBudget keeps lock() and unlock() beside hasRoom() and charge() on purpose, wider than a strict reading of this chapter would allow. A single request may need to check and then charge several rate limits at once, and that check-then-charge sequence is only atomic if one lock covers the whole thing. No single method on KeyBudget could enforce that by itself; only a caller holding the lock across several calls can. Splitting lock/unlock away from hasRoom/charge into a narrower interface would make each piece easier to read in isolation, and would throw away the one property the class exists to guarantee. The rule this leaves you with: narrow to exactly what each caller calls, and widen only when narrowing would take a correctness guarantee somewhere no single method can see the whole operation.
Why this matters in an interview
Interface segregation is easy to over-apply visibly, because every extra interface looks like more discipline on a whiteboard. The question worth asking out loud, the one this chapter is built around, is whether any real caller wants less than the whole interface. CapturingDestination did. Nothing in the FileDestination design ever did. An interviewer who watches you split Writable from Closer without naming a caller that needs one without the other is watching the same mistake STANDARD v1.0 tags over-engineered (premature interface) for a speculative Strategy.
Next: chapter 2.8, Dependency inversion, and injecting the clock — where a class stops reaching for a collaborator and starts asking for one instead.
← 2.6 Liskov substitution: keeping a promise a caller relies on · All chapters · 2.8 Dependency inversion, and injecting the clock →