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
Your logger needs somewhere to send a finished line of text. A console, a file, a list a test can inspect afterwards.
The first interface you reach 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();
}
Back it with a real file and every method gives you 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");
}
}
Nothing is wrong here yet. If your design only ever plugs in files, this interface is fine.
2. The new requirement, and the cost of a wide contract
Now you want a destination for a test. Just a list. Log something, then check afterwards what came out.
Destination has four methods, so that test double 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]
It runs correctly. Now count what it cost you: three methods with empty bodies, written only because Destination demands a body for each.
Every future test that wants to see what was logged pays that same tax. Every new kind of destination pays it too. A network socket, a rolling file — each one has to invent an answer for close() and registeredNames() whether or not the question means anything there.
And here is the subtler cost. Nobody calling write can tell, from Destination's type alone, that the other three methods are safe to ignore. Implementing the interface commits a class to methods it may never use. It also commits every caller to a promise about that class which the caller has no way to check.
3. The move: narrow to what one caller actually calls
Ask what the logger actually calls. You will find it calls write. It needs nothing about flushing, closing, or listing anything.
So split the interface by who calls what.
public interface Appender {
void write(String formattedLine);
}
Now watch what happens to the test double. It stops being a class.
List<String> lines = new ArrayList<>();
Appender captured = lines::add;
captured.write("[INFO] started");
System.out.println("captured: " + lines);
captured: [[INFO] started]
Whatever a real file destination needs for closing and flushing still exists. It lives on the concrete type that wraps the file, reachable by whoever manages that file's lifecycle, and invisible to the logger.
The logger only ever holds an Appender. An Appender only has one thing you can ask it to do.
That is Interface Segregation, and here is the checkable form:
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
Push segregation past what any caller needs and you end up splitting 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. Now find the problem.
Nothing in the design ever holds a Writable without also needing a Closer. Whatever opens the file is the same thing that has to close it.
So three files exist where one interface would do, and no second implementation of any of the three justifies the split. Somebody 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 paid. Nobody in this design ever wants flush without close, so splitting those two apart bought you 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 that as a design goal. So this is a real caller wanting the two capabilities independently, and not a hypothetical one. A single FormattedAppender interface carrying both methods would force every registration to pair one specific format with one specific write target, which is exactly the flexibility the design needs to keep.
Going deeper
A single-method interface is a functional interface by construction. That is why you can pass a lambda to Appender or Formatter anywhere Java expects one.
Do not just admire that and move on. It means something. Narrowing an interface to the one thing a caller calls, and making that interface usable as a lambda, are the same piece of work seen from two directions. corpus/logger/contract/Appender.java says as much in its own documentation. In Java, aggressive segregation and making an interface functional are indistinguishable.
That does not make a wide interface always wrong, and the exception is worth knowing.
Look at corpus/rate-limiter. Its KeyBudget deliberately keeps lock() and unlock() beside hasRoom() and charge(). It is wider than a strict reading of this chapter allows.
Why? Because 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 can enforce that alone. Only a caller holding the lock across several calls can.
Split lock/unlock away from hasRoom/charge and each piece reads more easily on its own. You would also throw away the one property the class exists to guarantee.
So carry this rule out of the chapter: narrow to exactly what each caller calls, and widen only when narrowing would move 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 where it shows. Every extra interface on a whiteboard looks like more discipline.
Ask the question this chapter is built around, and ask it out loud: does any real caller want less than the whole interface?
CapturingDestination did. Nothing in the FileDestination design ever did.
Split Writable from Closer without naming a caller that needs one without the other, and your interviewer 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 →