Designing for Extension & Polymorphism — Find the Bug¶
Each snippet has an extensibility defect: a rigid/repeated conditional, a leaky abstraction, an unsafe-for-subclassing base class, a wrong-closure decision, or a broken seam evolution. Read, diagnose, then check the reveal.
Bug 1 — The conditional that re-opens on every feature¶
class Discount {
Money apply(Cart cart, String code) {
if (code.equals("SUMMER")) return cart.total().times(0.90);
else if (code.equals("VIP")) return cart.total().times(0.80);
else if (code.equals("BLACKFRI")) return cart.total().times(0.50);
else return cart.total();
}
}
Reveal
`apply` is **closed to extension, open to modification**: every new promo re-opens this tested method, risking the cases that already work. The same `code` branching will reappear wherever discounts are described (a label method, an analytics tag) and drift out of sync. **Fix:** a `DiscountRule` seam — `interface DiscountRule { boolean matches(String code); Money apply(Money total); }` — one implementation per promo, resolved from a registry/map. Adding a promo is a new class; `Discount` is never reopened. (Replace Conditional with Polymorphism, Fowler ch. 10.)Bug 2 — Leaky abstraction¶
interface ReportExporter {
GZIPOutputStream export(Report r); // returns a concrete implementation type
}
Reveal
The "abstraction" leaks its implementation: `GZIPOutputStream` welds every implementer to gzip. A plain or zstd exporter cannot satisfy this contract honestly, and callers now depend on a specific compression class too. **Fix:** return the *domain/standard* type — `OutputStream export(Report r)`, or better `void export(Report r, OutputStream sink)` so the caller owns the stream. A seam must mention only stable, abstract types.Bug 3 — Overridable call in the constructor¶
public class Widget {
public Widget() { render(); } // calls an overridable method
protected void render() {}
}
public class LabeledWidget extends Widget {
private final String label = "OK";
@Override protected void render() {
System.out.println(label.toUpperCase());
}
}
// new LabeledWidget();
Reveal
`new LabeledWidget()` throws **NullPointerException**. `super()` runs `Widget()`, which calls the overridden `render()` *before* `LabeledWidget`'s `label` field is initialized (JLS §12.5) — so `label` is `null`. Bloch Item 19: never invoke an overridable method from a constructor. **Fix:** don't call overridable methods during construction. Do explicit post-construction initialization, or make `render` non-overridable (`final`/`private`), or use a factory that constructs first, then renders.Bug 4 — Undocumented self-use (fragile base class)¶
public class AuditList<E> extends ArrayList<E> {
private int writes = 0;
@Override public boolean add(E e) { writes++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c){ writes += c.size(); return super.addAll(c); }
public int writeCount() { return writes; }
}
Reveal
`addAll` **double-counts**: `ArrayList.addAll` internally calls `add` per element, so each element bumps `writes` twice — once in the override of `addAll`, once via the inherited self-use of `add`. The subclass's correctness depends on `ArrayList`'s undocumented internal call pattern (the fragile base class problem). If the JDK rewrites `addAll` to `arraycopy`, the count silently changes again. **Fix:** **compose** instead of extend — hold a `private final ListBug 5 — Sealed the plugin point¶
// In a published library that customers extend:
public sealed interface PaymentGateway permits StripeGateway, PaypalGateway { }
public final class StripeGateway implements PaymentGateway { }
public final class PaypalGateway implements PaymentGateway { }
Reveal
This is a **plugin/SPI point** sealed shut. A customer who needs `AdyenGateway` *cannot* add it — `permits` excludes them and they're in a different module. You closed the exact axis the abstraction exists to open. Sealing is for *complete, known* domain sets you own, not for outsider extension. **Fix:** make it a plain (non-sealed) interface, discover providers via `ServiceLoader`/DI. Reserve `sealed` for things like `Result = Ok | Err` where the set is genuinely complete.Bug 6 — Adding an abstract method breaks implementers¶
// v1, implemented by 5 external teams:
public interface MetricSink { void record(String name, double value); }
// v2 — "just one more method":
public interface MetricSink {
void record(String name, double value);
void flush(); // new ABSTRACT method
}
Reveal
Adding an `abstract` method is **binary-incompatible for implementers** (JLS §13). Every external `MetricSink` no longer satisfies the interface — compile error on recompile, `AbstractMethodError` at link time. You broke five teams. **Fix:** add it as a `default` with a safe body — `default void flush() {}` — which *is* binary-compatible (§13.5.6). If `flush` is genuinely mandatory, version the SPI (`MetricSinkV2 extends MetricSink`) and migrate deliberately. Adding methods breaks implementers, not callers.Bug 7 — Polymorphism where a value-check belongs¶
abstract class Account { abstract double interestRate(); }
class PositiveBalanceAccount extends Account { double interestRate() { return 0.02; } }
class NegativeBalanceAccount extends Account { double interestRate() { return 0.00; } }
// ...elsewhere, the program must REPLACE the object's class each time
// the balance crosses zero — swapping PositiveBalanceAccount <-> NegativeBalanceAccount
Reveal
The variation is over a **value** (sign of the balance), not a stable *type*. Modeling it as subclasses means an account must *change its class* when the balance crosses zero — impossible in Java without recreating the object, and it scatters identity. This is over-engineering: a class hierarchy for an `if`. **Fix:** one `Account` with `double interestRate() { return balance >= 0 ? 0.02 : 0.00; }`. Polymorphism replaces *type* switches, not arithmetic on changing values.Bug 8 — Repeated type-switch drifting out of sync¶
String icon(Node n) {
return switch (n.type()) { case FILE -> "📄"; case DIR -> "📁"; case LINK -> "🔗"; };
}
long size(Node n) {
return switch (n.type()) { case FILE -> n.bytes(); case DIR -> n.childBytes(); };
// LINK case forgotten — compiles because enum switch isn't exhaustive-checked the same way
}
Reveal
The *same* `type()` branching is duplicated across `icon` and `size`, and `size` silently **omits `LINK`** — the duplication let the two switches drift. Each new `Node` kind forces edits to every switch, and nothing forces them to stay complete. **Fix:** make behaviour live with the type. If the set is open/growing, use polymorphism: `Node.icon()` and `Node.size()` per subtype. If the set is closed and the operations external, make `Node` a **sealed** type and use exhaustive `switch` (no `default`) so a missing `LINK` case is a *compile error*.Bug 9 — Too many tiny hooks freeze the implementation¶
public abstract class HttpClient {
public final Response get(URI uri) {
var conn = openSocket(uri); // hook
var hdrs = buildHeaders(uri); // hook
var raw = writeRequestLine(conn); // hook
var status = readStatusLine(conn); // hook
var body = readBody(conn); // hook
return assemble(status, hdrs, body); // hook
}
protected abstract Socket openSocket(URI uri);
protected abstract Map<String,String> buildHeaders(URI uri);
protected abstract Object writeRequestLine(Socket s);
protected abstract String readStatusLine(Socket s);
protected abstract byte[] readBody(Socket s);
protected abstract Response assemble(String s, Map<String,String> h, byte[] b);
}
Reveal
Every internal step is a `protected abstract` hook, so the *entire* HTTP implementation is now frozen public API. You can never refactor `get`'s internals (merge steps, reorder, change a signature) without breaking every subclass. The seam granularity is far too fine — these are implementation steps, not decision points. **Fix:** expose *few, meaningful* hooks at real decision points (e.g. `authenticate(req)`, `onRetry(attempt)`), keep the wire mechanics `private`. Prefer composing a `Transport` strategy over subclassing the whole client. Each `protected` member is a forever-promise — spend them sparingly.Bug 10 — instanceof ladder that should be exhaustive¶
sealed interface Shape permits Circle, Square, Triangle {}
double area(Shape s) {
if (s instanceof Circle c) return Math.PI * c.r() * c.r();
else if (s instanceof Square q) return q.side() * q.side();
else return 0; // catch-all "else"
}
Reveal
The type is `sealed` — the compiler *could* prove exhaustiveness — but the `else { return 0; }` **throws that guarantee away**. `Triangle` falls into the catch-all and silently returns `0`. Adding a 4th variant also silently hits `else`. The whole benefit of sealing (compiler-checked completeness) is lost. **Fix:** use an exhaustive pattern `switch` with **no `default`**: Now omitting `Triangle` won't compile, and a new variant produces an error at every switch. See [../../05-advanced-language-features/01-sealed-classes-and-pattern-matching/](../../05-advanced-language-features/01-sealed-classes-and-pattern-matching/).Bug 11 — Non-final class with a protected field, no design¶
public class RateLimiter {
protected long tokens; // exposed mutable internal
protected long capacity;
public boolean tryAcquire() { if (tokens > 0) { tokens--; return true; } return false; }
}
Reveal
`RateLimiter` is implicitly open to subclassing and exposes `tokens`/`capacity` as `protected` mutable fields — accidental, undesigned inheritance surface. A subclass can corrupt the invariant (`tokens <= capacity`), and you can never change the internal representation (e.g. to a timestamp-based algorithm) without breaking subclasses. This is fragile-base-class bait. **Fix:** mark the class `final` (the default for an ordinary class, Bloch Item 19) and make the fields `private`. If extension is genuinely needed, *design* it: a documented `protected` hook, `private` state, documented self-use — not raw exposed fields.Bug 12 — The "flexible" config seam nobody uses¶
public interface IdGenerator { String next(); }
public final class UuidIdGenerator implements IdGenerator {
public String next() { return UUID.randomUUID().toString(); }
}
// ...wired through 30 classes via constructor injection.
// There is exactly ONE implementation and no plan for another.
Reveal
This is **speculative generality**: a seam with a single implementation, no second on the roadmap, threaded through 30 constructors. It adds indirection, a wider surface, and reading cost while protecting *nothing* — OCP protects callers from *variation that exists*, and there is none. (Protected Variations requires *predicted* variation, not imagined.) **Fix:** inline it — call `UUID.randomUUID().toString()` (or a static helper) directly. Introduce the `IdGenerator` seam *when* a real second generator appears; the refactor is cheap with tests. "We might need it" is not evidence.How to practice¶
For each bug, before reading the reveal, name (a) the defect category — rigid conditional / leaky abstraction / unsafe inheritance / wrong closure / bad seam evolution / speculative seam — and (b) the axis of change it gets wrong. That two-part diagnosis is exactly what a senior reviewer produces in seconds.
In this topic
Modes