Where do you find abstraction in a real system, and how do you introduce it without rewriting everything at once?
Abstraction shows up at the boundaries where a component meets something that will change: a notification channel, a payment gateway, a storage engine, a clock, a random source. Those are the places where you want a named contract so the rest of the system does not have to be rewritten when the choice behind it changes.
public class Main {
public static void main(String[] args) {
Notifier notifier = new ConsoleNotifier();
notifier.notifyUser("build finished");
notifier = new CountingNotifier(new ConsoleNotifier());
notifier.notifyUser("deploy started");
}
}
interface Notifier {
void notifyUser(String message);
}
class ConsoleNotifier implements Notifier {
public void notifyUser(String message) { System.out.println("notify: " + message); }
}
class CountingNotifier implements Notifier {
private final Notifier delegate;
private int count;
CountingNotifier(Notifier delegate) { this.delegate = delegate; }
public void notifyUser(String message) {
count++;
delegate.notifyUser(message);
}
public int count() { return count; }
}The counting notifier is the pattern to point at. It implements the same contract as the console notifier and wraps another notifier, adding behaviour without changing it. That is possible only because the interface exists. Before you had the interface, adding a counter meant editing the notifier class and every place that used it.
Introducing an abstraction incrementally is easier than it sounds. Write the interface with the methods the current implementation already has, make the existing class implement it, and change the callers to depend on the interface type rather than the concrete class. None of that changes behaviour, so it can be done in small reviewable steps. Once the seam exists, the second implementation, in this case a real email or push notifier, is a small addition.
The discipline that keeps this from turning into abstraction for its own sake is to add an interface when you can name the second implementation or the test double you need, not before. An interface with exactly one implementation forever is a layer that costs indirection and buys nothing.