Refused Bequest — Professional Level¶
At the professional level, refused bequest is not a curiosity you spot in a code review — it is inheritance debt that the team has been paying interest on for years. Your job is to find it programmatically, quantify it, and migrate away from it without breaking the world.
This document covers:
- Automated detection with ArchUnit and static analyzers.
- Real refused-bequest cases in the JDK itself.
- A migration playbook for legacy inheritance debt.
- Quick rules and a checklist.
1. Why "professional" means "automated"¶
Junior engineers find refused bequest by reading code. Senior engineers find it during reviews. Professional engineers find it before the commit lands — through CI gates, ArchUnit tests, and metric thresholds.
The reason is simple: every refused bequest is a future LSP bug waiting to be triggered by a polymorphic caller who trusted the supertype. The cost of detection grows linearly with the number of downstream callers, so you want to catch it on the day it's introduced.
2. The smell in the JDK itself¶
The JDK is the canonical case study, partly because it was written before generics, partly because backward compatibility froze early mistakes.
Stack extends Vector¶
public class Stack<E> extends Vector<E> {
public E push(E item) { addElement(item); return item; }
public synchronized E pop() { /* ... */ }
public synchronized E peek() { /* ... */ }
}
Stack inherits every method of Vector — add(int, E), insertElementAt, get(int), set(int, E), remove(int). A LIFO stack has no business exposing random-access mutation. Stack doesn't throw on these methods; it simply leaves them open, which is arguably worse: callers can corrupt the stack invariant silently.
Joshua Bloch openly recommends using Deque instead in Effective Java. The fix that the JDK could not apply is exactly the playbook in section 4 below — extract an interface (Deque), provide a fresh implementation (ArrayDeque), and deprecate the inheritance.
Properties extends Hashtable
public class Properties extends Hashtable<Object, Object> {
public synchronized Object setProperty(String key, String value) { ... }
public String getProperty(String key) { ... }
}
Properties is documented to hold String -> String only, yet it inherits put(Object, Object) from Hashtable. The class refuses the type contract of its parent — put(42, new Date()) compiles and runs, then explodes when something calls store(...) and tries to write a Date to a .properties file.
This is refused bequest at the type level: the subtype quietly narrows what is acceptable but cannot remove the inherited method.
Other JDK examples worth knowing¶
Collections.unmodifiableList(...)returns aListthat throwsUnsupportedOperationExceptiononadd,remove,set,clear. Every mutator method is refused.Arrays.asList(...)returns a fixed-size list that refusesaddandremovebut acceptsset. It refuses part of the bequest.Collections.emptyList()refuses every mutator.AbstractList.add(E)throwsUnsupportedOperationExceptionby default — explicitly inviting refused bequest in subclasses.
3. Detection — ArchUnit rules¶
ArchUnit lets you encode design rules as JUnit tests. Three rules catch the majority of refused bequests in production code.
Rule 1 — Production code must not throw UnsupportedOperationException¶
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.example.domain")
class RefusedBequestRules {
@ArchTest
static final ArchRule no_unsupported_operation_in_domain =
noClasses()
.should()
.accessClassesThat()
.areAssignableTo(UnsupportedOperationException.class)
.because("Throwing UnsupportedOperationException in production domain " +
"code is a refused bequest — prefer composition over inheritance.");
}
You whitelist test fixtures and immutable-collection factories, but the domain layer must be free of refused bequests.
Rule 2 — No empty overrides¶
@ArchTest
static final ArchRule no_empty_overrides =
methods()
.that().areDeclaredInClassesThat().resideInAPackage("..domain..")
.and().areAnnotatedWith(Override.class)
.should(new ArchCondition<JavaMethod>("not have empty body") {
@Override
public void check(JavaMethod m, ConditionEvents events) {
if (m.getMethodCallsFromSelf().isEmpty()
&& m.getFieldAccessesFromSelf().isEmpty()
&& !m.getRawReturnType().getName().equals("void")) {
events.add(SimpleConditionEvent.violated(m,
m.getFullName() + " is an empty override (refused bequest)"));
}
}
});
Rule 3 — Subclasses must use a configurable percentage of inherited API¶
This rule requires you to compute the NORM (Number of Refused Methods) ratio. We'll define it in specification.md; here is the enforcement:
@ArchTest
static final ArchRule subclasses_must_use_parent =
classes()
.that().areNotInterfaces()
.and().areNotAnnotations()
.should(new UsesAtLeastHalfOfParentApi());
PMD and SonarJava rules¶
- SonarJava
S1185— "Overriding methods should do more than simply call the same method in the super class." Catches refused bequest of the form@Override void foo() { super.foo(); }. - SonarJava
S1186— "Methods should not be empty." - PMD
EmptyMethodInAbstractClassShouldBeAbstract— empty methods in abstract classes are usually a refused bequest waiting to happen. - PMD
UnusedFormalParametercombined with empty body — strong signal. - SonarJava
S2386— "Mutable fields should not be public static" (often surfaces refused immutability contracts).
4. Migration playbook — legacy inheritance debt¶
You inherit a 200K-line codebase. PaymentMethod is extended by 14 subclasses, three of which throw UnsupportedOperationException on refund(). You cannot delete the parent. Here is the playbook.
Step 1 — Inventory¶
Use a script to list every method that throws UnsupportedOperationException or has an empty body across the hierarchy. Map the call graph: which callers depend on which inherited methods?
Step 2 — Extract the honest interface¶
For each refused method, ask: "Is the subclass refusing because it shouldn't have this responsibility?" If yes, the bequest itself is wrong.
// Before
abstract class PaymentMethod {
abstract void charge(Money m);
abstract void refund(Money m); // GiftCard refuses this
abstract void preAuthorize(Money m); // Cash refuses this
}
// After — split the bequest along refusal lines
interface Chargeable { void charge(Money m); }
interface Refundable { void refund(Money m); }
interface PreAuthorizable { void preAuthorize(Money m); }
class CreditCard implements Chargeable, Refundable, PreAuthorizable { ... }
class Cash implements Chargeable, Refundable { ... }
class GiftCard implements Chargeable { ... }
Each class now implements only what it honors. Refusal disappears because there is nothing to refuse.
Step 3 — Introduce composition where behavior is shared¶
If subclasses shared real behavior (not just signatures), extract it into a collaborator and inject it:
class CreditCard implements Chargeable, Refundable {
private final PaymentGateway gateway;
private final RefundPolicy policy;
// delegate, don't inherit
}
Step 4 — Migrate callers incrementally with a Strangler Fig¶
You cannot do this in one pull request. Use the Strangler Fig pattern:
- Introduce the new interfaces alongside the old base class.
- Make the old base class implement the new interfaces (adapter layer).
- Migrate callers one by one to depend on
Chargeableinstead ofPaymentMethod. - Once all callers are migrated, remove the old base class.
Step 5 — Add ArchUnit guards before deletion¶
Before deleting the legacy class, add an ArchUnit rule:
@ArchTest
static final ArchRule no_one_extends_payment_method =
noClasses().should().beAssignableTo(PaymentMethod.class)
.because("PaymentMethod is deprecated. Use Chargeable / Refundable.");
This makes the migration monotonic — no one can re-introduce the debt.
Step 6 — Use @Deprecated(forRemoval = true) and JDK 17's sealed types¶
If you cannot delete yet, seal the hierarchy so no new subclasses can be added:
Sealing is a powerful guard against further refused-bequest debt in the same family.
5. Quick rules¶
- Run ArchUnit in CI. Refused bequest detection that lives on someone's laptop is not detection.
- Treat every
throw new UnsupportedOperationException()in domain code as a bug ticket, not a design choice. - Inherited mutators on immutable types are non-negotiable refused bequests. Use
List.copyOf(...)and return interface types, never the mutable subtype. - Sealed hierarchies first, open inheritance second. If you must inherit, restrict the permitted set.
- Migrate by extracting interfaces, then composing, never by deleting the parent class first.
- No PRs that increase NORM, only PRs that decrease it.
- JDK precedent is not blanket permission.
Stack extends Vectoris a known mistake; don't cite it as justification. - Document refusals you cannot fix. If a third-party API forces you into a refused bequest, write a comment explaining why and a ticket for the day the constraint lifts.
Checklist¶
- ArchUnit rule blocks
UnsupportedOperationExceptionin production packages - SonarJava
S1185andS1186are enabled and not suppressed - Every subclass uses at least 50% of its parent's API (NORM ≤ 0.5)
- No abstract class has an
@Overridemethod whose body is empty or onlysuper.foo() - Mutable subtypes do not extend immutable supertypes, and vice versa
- Inheritance is sealed or final unless
extendsis explicitly part of the API - Each refused bequest in legacy code has a deprecation date
What's next¶
Refused bequest is the surface symptom; the disease underneath has a name and a fix.
- Fragile Base Class Problem — when changes in the parent break subclasses you don't even own. Refused bequest is the warning sign that you're already exposed.
- Liskov Substitution Principle (LSP) — the formal rule that refused bequest violates. Every refused method is a witness that the subtype is not substitutable for the supertype.
- Composition Over Inheritance — the structural fix. The next file in your roadmap shows you how to design new hierarchies so refused bequest never appears in the first place.
Read them in that order: diagnosis (fragile base class) → principle (LSP) → cure (composition).
Memorize this¶
Refused bequest is inheritance debt with compound interest. The professional move is not to argue about whether it is acceptable — it is to gate it at the CI boundary, quantify it with NORM, and pay it down through extracted interfaces and composition. Every
UnsupportedOperationExceptionin domain code is a payment you forgot to make.