JAVA / ABSTRACT CLASSES AND INTERFACES
Designing extension points others can implement
Design a seam others can implement: fix the sequence in a final method, expose one narrow protected hook, and check what implementers hand back.
What you will learn
- Split code into a final method you own and one protected hook others fill
- Give optional hooks an empty protected body so implementers override only what they need
- Validate values returned by a hook; that body is code you did not write
- Never call an overridable method from a constructor: fields are still null and 0
Understanding Designing extension points others can implement
An extension point is a hole you deliberately leave in working code, and every hole is a promise: once someone else's class fills it, you cannot change its shape without breaking them. So start from a concrete, finished implementation and ask which single step you genuinely cannot decide on their behalf. That step, and nothing more, becomes the extension point; the ordering, the argument checks and the cleanup stay in code you control, because those are the parts you will want to fix later without asking anyone's permission.
In Java the split is mechanical. The method callers use is public final, so no subclass can drop the validation or reorder the steps, while the varying step is protected abstract, so it is visible to implementers but not part of the API outsiders call. Hooks the average implementer should ignore get a protected body that does nothing, which is why adding one later does not break existing subclasses. And because a hook's body is code you did not write, treat its return value like input from outside and check it before relying on it.
The other half of the design is stating when the hook runs and what state it may observe. Pass it the data it needs as arguments, ideally one small immutable object, so you can add a field later without changing a signature every implementer already compiled against. Never call an overridable method from a constructor or field initializer: the base constructor finishes before the subclass's fields are assigned, so the override sees null and 0 and fails in code that reads perfectly. If the set of implementations must stay closed, say so structurally with a final class, a package-private constructor, or a sealed hierarchy, rather than hoping a comment will hold.
abstract class RequestHandler {
// Fixed part: the promise made to callers. Not overridable.
public final String handle(String path) {
if (path == null || path.isEmpty()) {
throw new IllegalArgumentException("path must not be empty");
}
String body = respond(path);
if (body == null) {
throw new IllegalStateException(getClass().getSimpleName() + ".respond returned null");
}
onHandled(path, body.length());
return "200 " + body;
}
// Required extension point: one decision, visible only to implementers.
protected abstract String respond(String path);
// Optional extension point: override only if you care.
protected void onHandled(String path, int bytes) {
}
}
class ClockHandler extends RequestHandler {
@Override
protected String respond(String path) {
return "now=12:00";
}
}
class EchoHandler extends RequestHandler {
private final StringBuilder log = new StringBuilder();
@Override
protected String respond(String path) {
return "echo:" + path;
}
@Override
protected void onHandled(String path, int bytes) {
log.append(path).append('=').append(bytes).append(' ');
}
String log() {
return log.toString().trim();
}
}
class BrokenHandler extends RequestHandler {
@Override
protected String respond(String path) {
return null;
}
}
public class Main {
public static void main(String[] args) {
EchoHandler echo = new EchoHandler();
System.out.println(new ClockHandler().handle("/clock"));
System.out.println(echo.handle("/hello"));
System.out.println(echo.handle("/hi"));
System.out.println("log: " + echo.log());
try {
new BrokenHandler().handle("/oops");
} catch (IllegalStateException e) {
System.out.println("caught: " + e.getMessage());
}
}
}
An extension point is the smallest overridable hole you can leave in code that still enforces its own invariants, which is why the entry method stays final and only one narrow protected method varies.
Worked examples
Why a constructor must not call the hook
Shows the override running before the subclass's own fields exist, so the extension point observes null and 0.
abstract class Report {
Report() {
// Dangerous: title() may be overridden, and the subclass is not built yet.
System.out.println("from constructor: " + title());
}
protected abstract String title();
}
class SalesReport extends Report {
private final String name = "Q3 Sales";
private final int rows = 12;
@Override
protected String title() {
return name + " (" + rows + " rows)";
}
}
public class Main {
public static void main(String[] args) {
SalesReport r = new SalesReport();
System.out.println("after construction: " + r.title());
}
}
Example explained
Line 1new SalesReport() runs Report's constructor first, before any SalesReport field initializer.
Line 2title() dispatches to the override immediately, so name is still null and rows is still 0.
Line 3The same override returns correct data one line later, which is why this bug looks impossible when reading the subclass alone.
Line 4Fix: do the work in the final entry method after construction, not in the constructor.
A hook signature you can grow
Passing one immutable parameter object instead of loose arguments lets the framework add data without touching any implementation.
final class Attempt {
private final String key;
private final int number; // added after release; implementers unchanged
Attempt(String key, int number) {
this.key = key;
this.number = number;
}
public String key() {
return key;
}
public int number() {
return number;
}
}
interface RetryPolicy {
boolean retry(Attempt attempt);
}
class Retrier {
private final RetryPolicy policy;
Retrier(RetryPolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("policy required");
}
this.policy = policy;
}
int run(String key) {
int n = 1;
while (n < 5 && policy.retry(new Attempt(key, n))) {
n++;
}
return n;
}
}
class TwiceOnly implements RetryPolicy {
@Override
public boolean retry(Attempt a) {
return a.number() < 2;
}
}
class NeverForCache implements RetryPolicy {
@Override
public boolean retry(Attempt a) {
return !a.key().startsWith("cache:");
}
}
public class Main {
public static void main(String[] args) {
System.out.println("TwiceOnly db:users attempts=" + new Retrier(new TwiceOnly()).run("db:users"));
System.out.println("NeverForCache db:users attempts=" + new Retrier(new NeverForCache()).run("db:users"));
System.out.println("NeverForCache cache:users attempts=" + new Retrier(new NeverForCache()).run("cache:users"));
}
}
Example explained
Line 1retry(Attempt) has one parameter, so adding Attempt.number() later did not change the hook signature or recompile any implementation.
Line 2Attempt is final with getters only, so an implementer can read the facts but cannot alter the loop's state.
Line 3The Retrier constructor rejects null once, which is why run() needs no null check around the hook call.
Line 4run() owns how often the hook is called and the ceiling of 5; the implementer only answers yes or no.
Important notes
protected in Java also means visible to the whole package, so anything in your own package can call the hook directly; keep the seam in its own package if that matters.
Exceptions from a hook are part of its contract: decide whether one aborts the operation or is caught, and make sure the framework is never left half-updated when the implementer throws.
Common mistakes
Leaving the whole algorithm overridable instead of final: a subclass overrides it, quietly drops the argument checks, and callers get corrupt results from an object that still passes as your type.
Declaring the hook public rather than protected: outside code starts calling respond("/x") directly, bypassing the validation in handle(), and now you can never rename or remove it.
Adding a parameter to an already released abstract hook: existing implementations stop compiling, and any that lack @Override silently become overloads that are never called.
Try it yourself
Change, predict, then run
Write an abstract Validator with a final check(String input) that rejects null, calls a protected abstract String problem(String value) returning null when the value is fine, and calls a protected no-op onRejected(String value, String problem). Implement one validator for empty text and one for a length limit of 8, then run both over "", "ok" and "averylongvalue".
Open the Java workspaceCheck your understanding
You ship abstract class Exporter with public final void export(Path p) that opens a file, calls protected abstract writeRows(Writer w), then closes it. A user asks you to remove final so they can write a header first. What is the real cost?
- Nothing serious: an override still has to call super.export(p), so the open/close guarantee survives.
- The guarantee that the file is always closed disappears, because nothing forces an override to call super; a protected writeHeader() with an empty body is the cheaper extension point.
- writeRows would have to become public, since a non-final method cannot call a protected one.
- Subclasses could then instantiate Exporter directly, exposing the internal file handle.
Show answer
Java has no way to require a subclass to call super, so dropping final converts a guarantee into a hope: an override can replace the sequence entirely and never close the file, while still type-checking as an Exporter. Option 0 is tempting because well-behaved subclasses do call super, but the compiler does not enforce it, and even a super call can swallow exceptions around it. Option 2 is simply false, since final has no effect on visibility rules.