Composition over Inheritance
Build behaviour by holding and delegating to other objects instead of extending classes, and recognise the cases where extends is still right.
class Wrapper implements Api {
private final Api delegate;
Wrapper(Api delegate) { this.delegate = delegate; }
public R call(A a) { /* extra */ return delegate.call(a); }
}Composition means a class has another object in a field and calls it, instead of being a subclass of it. The advice to prefer it comes from a concrete problem: inheritance ties your class to the superclass's implementation, not just its documented behaviour.
The fragile base class
Suppose you extend HashSet to count how many elements were added, overriding both add and addAll. Adding three items with addAll reports six, because HashSet inherits an addAll that calls add for each element, and your override counts them again. Nothing in your code is wrong; you depended on a detail you could not see, and a JDK update could change it either way.
A wrapper that holds a Set and forwards calls has no such problem. It only sees the methods it calls, and it works with any Set implementation, not just HashSet.
Composition in service code
Back-end code is mostly composition already. A service receives its repository, clock and HTTP client through the constructor and calls them. Cross-cutting behaviour such as caching, retries and metrics fits the decorator shape: a class that implements the same interface, holds another implementation and adds one concern around each call.
interface PriceService { long price(String sku); }
class CachingPriceService implements PriceService {
private final PriceService delegate;
private final Map<String, Long> cache = new HashMap<>();
...
public long price(String sku) {
return cache.computeIfAbsent(sku, delegate::price);
}
}Decorators stack in any order and each is testable alone. The inheritance version, CachingRetryingDbPriceService extends RetryingDbPriceService, needs a new class for every combination.
When extends is still right
- The is-a relationship is real and permanent:
NotFoundExceptionis anApiException. - The superclass was designed and documented for extension, such as
AbstractListor a framework base class. - Both classes are in your code and change together.
Mistakes people make
class UserList extends ArrayList<User>to add one helper. Callers can nowclear()oradd(null)past your rules.- Extending a concrete service to change one method, then breaking when the original gets a new internal call.
- Leaving classes open for extension by default. Mark classes
finalunless you mean them to be extended.
Example
When to use it
- Wrap a repository in a caching decorator without touching the repository class or its callers.
- Add retries with a limit around an HTTP client for a flaky upstream service, configured at startup.
- Send notifications through a list of channels chosen per tenant instead of one subclass per combination.
More examples
A caching decorator
Callers only see PriceService, so caching can be added or removed in one line of wiring. A real cache shared by request threads also needs a concurrent map (or a cache library), expiry and a size limit; this shows the shape, not a production cache.
interface PriceService {
long price(String sku);
}
class DbPriceService implements PriceService {
int queries = 0;
public long price(String sku) {
queries++;
return sku.length() * 100L; // stands in for a database lookup
}
}
class CachingPriceService implements PriceService {
private final PriceService delegate;
private final Map<String, Long> cache = new HashMap<>();
CachingPriceService(PriceService delegate) { this.delegate = delegate; }
public long price(String sku) {
return cache.computeIfAbsent(sku, delegate::price);
}
}
void main() {
DbPriceService db = new DbPriceService();
PriceService prices = new CachingPriceService(db);
for (String sku : List.of("KB-1", "MOUSE-2", "KB-1", "KB-1")) {
IO.println(sku + " = " + prices.price(sku));
}
IO.println("database queries: " + db.queries);
}
// Output:
// KB-1 = 400
// MOUSE-2 = 700
// KB-1 = 400
// KB-1 = 400
// database queries: 2A retry decorator
Retry logic lives in one class and wraps any RateClient, including the caching one above. Only retry operations that are safe to repeat, and add a back-off delay in real code.
interface RateClient {
double rate(String currency);
}
class FlakyRateClient implements RateClient {
private int calls = 0;
public double rate(String currency) {
calls++;
if (calls < 3) throw new IllegalStateException("timeout on call " + calls);
return 1.25;
}
}
class RetryingRateClient implements RateClient {
private final RateClient delegate;
private final int maxAttempts;
RetryingRateClient(RateClient delegate, int maxAttempts) {
this.delegate = delegate;
this.maxAttempts = maxAttempts;
}
public double rate(String currency) {
for (int attempt = 1; ; attempt++) {
try {
return delegate.rate(currency);
} catch (IllegalStateException e) {
IO.println("attempt " + attempt + " failed: " + e.getMessage());
if (attempt == maxAttempts) throw e;
}
}
}
}
void main() {
RateClient client = new RetryingRateClient(new FlakyRateClient(), 3);
IO.println("GBP rate " + client.rate("GBP"));
}
// Output:
// attempt 1 failed: timeout on call 1
// attempt 2 failed: timeout on call 2
// GBP rate 1.25Composing channels instead of subclassing
With inheritance, every combination of channels would need its own subclass. With composition, the combination is data you can read from configuration at startup.
interface Channel {
String send(String user, String text);
}
class Notifier {
private final List<Channel> channels;
Notifier(List<Channel> channels) { this.channels = List.copyOf(channels); }
void publish(String user, String text) {
for (Channel c : channels) IO.println(c.send(user, text));
}
}
void main() {
Channel email = (user, text) -> "email to " + user + ": " + text;
Channel sms = (user, text) -> "sms to " + user + ": " + text;
Channel webhook = (user, text) -> "POST /hooks {\"user\":\"" + user + "\"}";
// No EmailAndSmsNotifier subclass: just a different list.
new Notifier(List.of(email, sms)).publish("ada", "Order shipped");
new Notifier(List.of(webhook)).publish("grace", "Order shipped");
}
// Output:
// email to ada: Order shipped
// sms to ada: Order shipped
// POST /hooks {"user":"grace"}
Discussion