JAVA / ABSTRACT CLASSES AND INTERFACES
Small focused contracts and interface segregation
Split a wide interface into role interfaces and take the narrowest parameter type each method needs, so implementers never stub methods they cannot honour.
What you will learn
- Split an interface as soon as one implementer has to stub a method it cannot honour
- Declare each parameter as the narrowest role interface the method body actually calls
- Use <T extends Sized & Clearable> when one method genuinely needs two roles
- Keep the old wide interface extending the new small ones so existing code still compiles
Understanding Small focused contracts and interface segregation
Every method you put on an interface is a bill that each implementing class has to pay. A class that cannot honestly pay one of them will still make the compiler happy by writing a body that throws UnsupportedOperationException, returns null, or does nothing at all. From that moment the interface no longer tells the truth: the type says read, write and print are all available, and only running the program reveals which of them are real. Size an interface by asking whether every class that should implement it can implement all of it.
The second half of the cost lands on callers, because a parameter type is a list of requirements. If a method body only calls read() but the parameter is declared as a three-method Document, the method is demanding write and print from every argument it will ever receive, and read-only types are locked out for no reason. Narrowing that parameter to a one-method TextSource does not change a single line of the body, yet it widens the set of arguments the method accepts and stops the caller depending on methods it never uses.
The mental model is to name interfaces after a role a caller needs, not after the class that happens to provide it. The JDK is built this way: Comparable, Iterable, Runnable, Closeable and Flushable each carry one job, so one class can implement whichever combination is truthful. Splitting is not the same as making every method its own interface, though; the unit is a group of methods that real call sites use together, so let the call sites show you the seam. When one method needs two roles at once, combine them at the signature with an intersection bound or a small interface that extends both, rather than merging them permanently.
public class Main {
interface TextSource {
String read();
}
interface TextSink {
void write(String text);
}
static class MemoNote implements TextSource, TextSink {
private String text = "";
public String read() {
return text;
}
public void write(String t) {
this.text = t;
}
}
static class LicenseFile implements TextSource {
public String read() {
return "MIT";
}
}
static int countChars(TextSource src) {
return src.read().length();
}
static void stamp(TextSink sink) {
sink.write("APPROVED");
}
public static void main(String[] args) {
MemoNote memo = new MemoNote();
LicenseFile license = new LicenseFile();
stamp(memo);
System.out.println("memo: " + memo.read());
System.out.println("memo chars: " + countChars(memo));
System.out.println("license chars: " + countChars(license));
// stamp(license) would not compile: LicenseFile never claimed it can be written to.
}
}
An interface's width is paid for twice, by implementers who must keep every promise and by callers who must supply every promise, so it should be no wider than the role a caller uses.
Worked examples
What a fat interface costs at runtime
A read-only class forced to implement write() compiles cleanly and fails only when the call happens.
public class Main {
interface Document {
String read();
void write(String text);
}
static class ReadOnlyDoc implements Document {
public String read() {
return "spec v1";
}
public void write(String text) {
throw new UnsupportedOperationException("read-only");
}
}
static void appendStamp(Document d) {
d.write(d.read() + " [reviewed]");
}
public static void main(String[] args) {
Document d = new ReadOnlyDoc();
System.out.println(d.read());
try {
appendStamp(d);
} catch (UnsupportedOperationException e) {
System.out.println("failed at runtime: " + e.getMessage());
}
}
}
Example explained
Line 1ReadOnlyDoc compiles because write has a body; the body refuses to work, so the class satisfies the compiler while breaking the contract.
Line 2appendStamp declares Document, so d.write(...) is a legal call as far as the compiler can see, and no warning is possible.
Line 3The problem only appears when that line executes, and no caller has a compile-time way to ask whether this document is writable.
Line 4Had Document been split into TextSource and TextSink, passing ReadOnlyDoc to appendStamp would fail to compile instead.
Requiring two roles without merging them
An intersection type bound lets one method demand both capabilities while the interfaces stay separate.
public class Main {
interface Sized {
int size();
}
interface Clearable {
void clear();
}
static class Basket implements Sized, Clearable {
private int items = 3;
public int size() {
return items;
}
public void clear() {
items = 0;
}
}
static <T extends Sized & Clearable> void emptyIfNotEmpty(T target) {
if (target.size() > 0) {
System.out.println("clearing " + target.size() + " items");
target.clear();
}
System.out.println("size now " + target.size());
}
public static void main(String[] args) {
emptyIfNotEmpty(new Basket());
}
}
Example explained
Line 1<T extends Sized & Clearable> asks the argument for both roles at once, so no combined SizedAndClearable interface has to exist.
Line 2Inside the body T counts as both types, which is why target.size() and target.clear() both resolve.
Line 3A class that implements only Sized is rejected at the call site, so the requirement is still checked at compile time.
Line 4Sized and Clearable remain independent, so a read-only counter can implement Sized alone.
Important notes
Narrow does not mean one method per interface; an interface with five methods is fine when every implementer supports all five and clients use them as a set.
Removing methods from an already published interface breaks its implementers, so add the small interfaces first, declare the old one as extending them, migrate call sites, and retire it later.
Common mistakes
Keeping the wide interface and silencing the compiler with UnsupportedOperationException stubs, which moves a design problem into runtime and makes every call site unsafe.
Turning the unimplementable method into a default that returns null or does nothing, so classes that should never have had the method quietly inherit wrong behaviour instead of failing to compile.
Declaring parameters as the widest type in reach out of habit, so a method that only reads still refuses read-only arguments and cannot be given a two-line test fake.
Try it yourself
Change, predict, then run
Start from interface Machine { void start(); void stop(); void refuel(); } with a FuelTruck and an ElectricCart that has no tank, split it into role interfaces so ElectricCart never mentions refuel, then write restart(...) with a parameter type that requires start and stop only and pass both classes to it.
Open the Java workspaceCheck your understanding
A method only calls read() on its parameter, but the parameter is declared as Document, an interface with read, write and print. What is the concrete cost of that signature?
- Every argument must also supply write and print, so read-only types are excluded even though the method never writes
- Calls through the wider interface dispatch more slowly because Document declares more methods
- The method must catch UnsupportedOperationException in case some implementer stubs out write
- Document must become an abstract class so the unused methods can be given bodies
Show answer
The parameter type is a requirement list, so a wide type shrinks the set of legal arguments and couples the caller to methods it never touches. Catching UnsupportedOperationException is the tempting answer, but this method never calls write, so no such exception can be thrown here; the cost is paid at compile time, in the arguments that are refused.