Composition Over Inheritance — Practice Tasks¶
Eight exercises that force the heuristic into real Java code. Each is a refactor that compiles under inheritance but bites the moment a second variant arrives or a wrapper is added. Work each in three passes: (1) name the smell (extends for reuse, fragile base, leaked API, broken substitutability), (2) sketch the new shape on paper, (3) write code and a test that catches the original failure mode.
Task 1 — Flatten the Vehicle tree¶
abstract class Vehicle {
abstract void start();
abstract void stop();
}
abstract class LandVehicle extends Vehicle {
int wheels;
}
abstract class Car extends LandVehicle {
int doors;
}
class GasolineCar extends Car { /* petrol */ }
class DieselCar extends Car { /* diesel */ }
class ElectricCar extends Car { /* battery */ }
class HybridCar extends Car { /* gas + electric, somehow */ }
Objective. Replace the four-level hierarchy with a final class Car that composes its variable parts (engine, drivetrain).
Constraints. - Car becomes a single final class with final fields. - The variation axis (engine) becomes an interface with three or four implementations, including HybridEngine that composes two engines. - A new engine type (say, HydrogenEngine) is one new file, no edit to Car.
Acceptance criteria. - new Car(new HybridEngine(new GasolineEngine(), new ElectricEngine()), 4) works. - A unit test substitutes a FakeEngine and verifies Car.start() calls engine.start() exactly once. - The string extends does not appear in the refactored file except on records implementing the engine interface. - Adding a hybrid plug-in scenario doesn't require any new class — new HybridEngine(...) covers it.
Task 2 — Replace the BaseService reuse¶
abstract class BaseService {
protected final Logger log = LoggerFactory.getLogger(getClass());
protected void audit(String action, String entity) {
log.info("[AUDIT] action={} entity={}", action, entity);
}
protected void emit(DomainEvent e) { EventBus.global().publish(e); }
}
class CheckoutService extends BaseService {
public void checkout(Cart c) { audit("checkout", c.id()); emit(new Checkout(c.id())); }
}
class RefundService extends BaseService {
public void refund(Order o) { audit("refund", o.id()); emit(new Refund(o.id())); }
}
class CancelService extends BaseService {
public void cancel(Order o) { audit("cancel", o.id()); emit(new Cancel(o.id())); }
}
Objective. Replace inheritance-for-reuse with composition. No service should extend any base class.
Constraints. - The two reused behaviours (audit, emit) become a single final class ServiceTelemetry injected via constructor. - Static singletons (LoggerFactory.getLogger(getClass()), EventBus.global()) become injected collaborators. - Each service class is final and has no extends clause.
Acceptance criteria. - No BaseService in the codebase after the refactor. - A test for CheckoutService substitutes a fake ServiceTelemetry and asserts both audit("checkout", …) and emit(new Checkout(…)) are called. - Adding a new service (PromotionService) requires no new base class — it just takes a ServiceTelemetry in its constructor. - Searching for getClass() in service files returns no hits.
Task 3 — Stop extending ArrayList¶
public class TaskQueue<T> extends ArrayList<T> {
private final long createdAt = System.currentTimeMillis();
public void enqueue(T t) { add(t); }
public T dequeue() { return remove(0); }
public long ageMs() { return System.currentTimeMillis() - createdAt; }
}
Objective. A TaskQueue<T> is not a List<T>. Callers should not be able to add(0, t), remove(i), subList(...), etc.
Constraints. - TaskQueue becomes final and does not implement List<T>. - It holds an ArrayList<T> (or ArrayDeque<T>, your choice) as a private final field. - It exposes only enqueue, dequeue, size, isEmpty, ageMs.
Acceptance criteria. - The line taskQueue.add(0, t) no longer compiles. - taskQueue.dequeue() returns elements in enqueue order. - A test exercises FIFO behaviour and confirms size() shrinks as expected. - Replacing the backing storage from ArrayList to ArrayDeque requires zero edits to any caller — only TaskQueue itself.
Task 4 — Compose a decorator chain (cross-cutting on Repository)¶
public interface OrderRepository {
Order load(OrderId id);
void save(Order o);
}
public class JdbcOrderRepository implements OrderRepository { /* ... */ }
You need to add three cross-cutting concerns: structured logging, timing metrics, and retry on transient failures. The team's first attempt:
public class LoggingOrderRepository extends JdbcOrderRepository { /* ... */ } // wrong: extends concrete
public class TimingOrderRepository extends LoggingOrderRepository { /* ... */ } // wrong: chains via extension
Objective. Build the three concerns as composable wrappers that work for any OrderRepository, not just JdbcOrderRepository.
Constraints. - Each wrapper is a final class implementing OrderRepository, holding private final OrderRepository delegate. - No wrapper extends another wrapper or the JDBC class. - The chain is wired in a single CompositionRoot method. - Order matters: retry must be innermost, so failed attempts are timed and logged for each retry.
Acceptance criteria. - new LoggingRepository(new TimingRepository(new RetryingRepository(new JdbcOrderRepository(...)))) is the chain produced by the composition root. - A test substitutes an InMemoryOrderRepository and confirms load() still works through all four layers. - The LoggingRepository can wrap an InMemoryOrderRepository directly — proof that the wrapper composes with anything implementing the interface. - Removing the RetryingRepository from the chain doesn't break any other wrapper.
Task 5 — Customer is not an Account¶
A junior wrote:
public class Account {
private String id;
private BigDecimal balance;
/* getters, setters */
}
public class Customer extends Account {
private String name;
private String email;
/* getters, setters */
}
The reasoning: "a customer has an account, so customer is an account". The runtime symptom: Customer.getId() returns the account id, not a customer id. Every place that used getId() mixed the two meanings.
Objective. Re-model as composition: a Customer has an Account (or even has many), and each has its own identity.
Constraints. - Customer and Account become two unrelated final classes. - Customer holds an Account (or List<Account>) as a field. - Each has its own id of a distinct type (CustomerId, AccountId) — no string overlap.
Acceptance criteria. - customer.getId() returns a CustomerId; customer.account().getId() returns an AccountId. The two types are not assignable. - A test enforces that you can't pass a CustomerId to a method expecting AccountId at compile time. - Searching the codebase for extends Account returns zero hits in production code. - Adding a second account per customer (joint accounts) requires no schema change to Customer — just changing the field type to List<Account>.
Task 6 — Replace getClass() equality with composition¶
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
}
public class ColoredPoint extends Point {
private final Color color;
public ColoredPoint(int x, int y, Color c) { super(x, y); color = c; }
public boolean equals(Object o) {
if (!super.equals(o)) return false;
if (!(o instanceof ColoredPoint cp)) return false;
return color.equals(cp.color);
}
}
Symptom. new Point(1,2).equals(new ColoredPoint(1,2,RED)) is false (different getClass()). The two types share a parent but cannot be substituted in a HashSet<Point>.
Objective. Re-model so that "a coloured point is not a kind of point; it has a point and a colour". Composition replaces the broken inheritance.
Constraints. - Point becomes a record. - ColoredPoint becomes a record holding a Point and a Color. - Neither extends the other.
Acceptance criteria. - Point.equals is correct by value. - ColoredPoint.equals is correct by value (both fields). - coloredPoint.point().equals(plainPoint) works as expected — one axis of equality is preserved without the inheritance trap. - A test puts a Point and a ColoredPoint(samePoint, RED) into separate sets and confirms they don't accidentally collide.
Task 7 — Plug a Strategy into PricingEngine¶
public class PricingEngine {
public BigDecimal price(Order order, String type) {
return switch (type) {
case "STANDARD" -> order.subtotal().multiply(new BigDecimal("1.10"));
case "PROMO" -> order.subtotal().multiply(new BigDecimal("0.85"));
case "VIP" -> order.subtotal().multiply(new BigDecimal("0.70"));
default -> throw new IllegalArgumentException(type);
};
}
}
A teammate proposes:
abstract class PricingEngine {
public abstract BigDecimal price(Order o);
}
class StandardPricingEngine extends PricingEngine { /* ... */ }
class PromoPricingEngine extends PricingEngine { /* ... */ }
class VipPricingEngine extends PricingEngine { /* ... */ }
Objective. This is closer, but still inheritance-for-strategy. Refactor into composition: PricingEngine has a PricingStrategy.
Constraints. - PricingStrategy is an interface with one method, BigDecimal apply(Order o). - PricingEngine is a final class with a PricingStrategy field. - Three strategies (Standard, Promo, Vip) implement the interface; each is final. - The engine can switch strategies at runtime by being reconstructed (or, if you must, by injection of a new engine instance).
Acceptance criteria. - Adding a "BLACK_FRIDAY" strategy is one new class, zero edits to PricingEngine. - A test passes a fake PricingStrategy that returns a sentinel and verifies the engine returns the sentinel. - The class diagram has one PricingEngine, three PricingStrategy implementations, and zero extends between any two. - Strategies can be composed: new ChainedStrategy(promo, vip) applies both discounts in sequence.
Task 8 — Untangle the framework-mandated extends¶
@WebServlet("/checkout")
public class CheckoutServlet extends HttpServlet {
private DataSource ds = new HikariDataSource(/* prod URL */);
private OkHttpClient http = new OkHttpClient();
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 60 lines: parse body, validate, charge payment, write to DB, render JSON
}
}
Symptom. Tests can't run without a Servlet container, the prod DB URL is baked in, retry/timing logic is buried inside one method, and the servlet "is" the application.
Objective. The extends HttpServlet cannot be removed — the framework demands it. Move everything else to composition so the servlet is a thin adapter.
Constraints. - CheckoutServlet becomes the smallest possible adapter: parses input, calls one collaborator, writes output. ~10 lines. - The collaborator (CheckoutFlow) is a final class composed from OrderRepository, PaymentGateway, Validator. No extends anywhere in its tree. - Tests exercise CheckoutFlow directly with in-memory fakes — no Servlet container needed. - The servlet builds the flow once in init(), holds it in a final field, and reuses it across requests.
Acceptance criteria. - A unit test for CheckoutFlow runs in milliseconds without any HTTP, JDBC, or HTTP-client classes on the test classpath. - The servlet class is under 20 lines (excluding annotations and imports). - The servlet's doPost does no business logic — it only marshals between HTTP and the domain. - Swapping HttpServlet for Spring's @RestController later is purely a different adapter; CheckoutFlow is unchanged.
Validation¶
| Task | How to verify the fix |
|---|---|
| 1 | new Car(engine, 4) works for every engine variant; extends count in the file is 0. |
| 2 | BaseService does not appear in the codebase; service tests use a fake ServiceTelemetry. |
| 3 | taskQueue.add(0, x) does not compile. |
| 4 | The composition root assembles four layers in one method, all tests pass. |
| 5 | CustomerId and AccountId are not interchangeable at compile time. |
| 6 | Point and ColoredPoint are independent records; equality is exactly the components. |
| 7 | Adding a "Black Friday" strategy requires one new file. |
| 8 | CheckoutFlow test runs without javax.servlet.* on the classpath. |
Worked solution sketch — Task 4 (Repository decorator chain)¶
public interface OrderRepository {
Order load(OrderId id);
void save(Order o);
}
public final class JdbcOrderRepository implements OrderRepository {
private final DataSource ds;
public JdbcOrderRepository(DataSource ds) { this.ds = ds; }
public Order load(OrderId id) { /* JDBC */ return null; }
public void save(Order o) { /* JDBC */ }
}
public final class RetryingRepository implements OrderRepository {
private final OrderRepository delegate;
private final int maxAttempts;
public RetryingRepository(OrderRepository d, int n) { delegate = d; maxAttempts = n; }
public Order load(OrderId id) { return run(() -> delegate.load(id)); }
public void save(Order o) { run(() -> { delegate.save(o); return null; }); }
private <T> T run(Supplier<T> work) {
RuntimeException last = null;
for (int i = 0; i < maxAttempts; i++) {
try { return work.get(); } catch (TransientException e) { last = e; }
}
throw last;
}
}
public final class TimingRepository implements OrderRepository {
private final OrderRepository delegate;
private final MetricsRegistry metrics;
public TimingRepository(OrderRepository d, MetricsRegistry m) { delegate = d; metrics = m; }
public Order load(OrderId id) { return timed("load", () -> delegate.load(id)); }
public void save(Order o) { timed("save", () -> { delegate.save(o); return null; }); }
private <T> T timed(String op, Supplier<T> work) {
long t = System.nanoTime();
try { return work.get(); }
finally { metrics.recordNanos("repo." + op, System.nanoTime() - t); }
}
}
public final class LoggingRepository implements OrderRepository {
private final OrderRepository delegate;
private final Logger log;
public LoggingRepository(OrderRepository d, Logger l) { delegate = d; log = l; }
public Order load(OrderId id) { log.info("load {}", id); return delegate.load(id); }
public void save(Order o) { log.info("save {}", o.id()); delegate.save(o); }
}
// Composition root
public final class CompositionRoot {
public static OrderRepository buildRepository(DataSource ds, MetricsRegistry m, Logger l) {
return new LoggingRepository(
new TimingRepository(
new RetryingRepository(
new JdbcOrderRepository(ds), 3),
m),
l);
}
}
Notice four things in the sketch: 1. Each wrapper is final and implements OrderRepository by holding a final delegate. 2. Wrappers compose with any OrderRepository — including InMemoryOrderRepository for tests. 3. The retry layer is the innermost: each retry attempt is timed and logged independently. A reordering would change semantics. 4. The composition root is the only place that knows the layer order. Changing it is a one-method edit, reviewed in isolation.
Memorize this: inheritance problems don't show up as compiler errors — they show up the second time someone needs to add a wrapper, swap an implementation, or test a class without spinning up a database. Each task above gives you that "second time" up front. If, after the refactor, the next plausible change touches one class instead of every level of a hierarchy, you have applied the heuristic correctly.