What are the trade-offs of composition-plus-delegation at scale, and how do modern Java features change the calculus?
answer
- Benefits: coupling, flexibility, testability, narrow API
- Costs: boilerplate, indirection, wiring, no free open recursion
- Default methods = safe mixins (type, not base class)
- Records implicitly final → nudge to compose
- Sealed + pattern switch = add-operation alternative to inheritance
- Proxies/ByteBuddy + DI make composition near-free at scale
basics
~20 sComposition is safer and more flexible but adds forwarding boilerplate and an extra indirection. Interfaces with default methods, records, sealed types, and dependency injection reduce the cost and make composition the practical default in large systems.
solid answer
~50 sAt scale, composition's benefits — loose coupling, swappable collaborators, testability via injected fakes, and preserved encapsulation — usually dominate. The costs are real but manageable: forwarding boilerplate (mitigated by reusable Forwarding* bases, IDE generation, or dynamic proxies), an extra layer of indirection, and the discipline of wiring collaborators (often handled by a DI container). Modern Java reshapes the trade-off. Default methods let interfaces carry shared behavior without a base class, giving 'mixin-like' reuse without the fragile-base-class risk. Records give you cheap immutable value holders that compose naturally and discourage inheritance (they're implicitly final). Sealed interfaces plus pattern-matching switch make polymorphism over a closed set of types exhaustive and add-operation-friendly, an alternative to deep inheritance trees. For frameworks, dynamic proxies and bytecode-generated delegation make wrapping (transactions, logging, security) nearly free. The net: inheritance is reserved for genuine, designed-for-extension IS-A hierarchies; nearly everything else composes.
go deeper
Aware that composition needs more wiring/forwarding code but is generally safer.
Lists concrete pros and cons (flexibility/testability vs boilerplate/indirection) and uses constructor injection.
Mitigates boilerplate (Forwarding base, proxies), connects composition to testability and DI, and knows default methods as a reuse alternative.
Sets architecture-wide policy: program-to-interfaces + DI, sealed hierarchies and pattern matching for closed variants, records for value types, proxies for cross-cutting concerns, and reserves inheritance for designed-for-extension cases.
## The honest trade-off ledger Composition-via-delegation isn't free; a principal-level answer weighs both sides. **Benefits (why composition wins by default at scale):** - **Loose coupling:** the wrapper depends only on the delegate's *public contract* (ideally an interface), not its internals — so the delegate can evolve independently. - **Runtime flexibility:** the collaborator is chosen at construction, so you can swap implementations, decorate (stack wrappers), or vary behavior per instance. - **Testability:** because collaborators are injected, you can pass a stub/fake/mock in tests — far easier than overriding pieces of a superclass. - **Encapsulation & narrow surface:** you expose only the methods you forward, keeping each class's API intentional. - **No single-slot tax:** composition doesn't consume the one `extends` slot, and you can compose *many* collaborators. **Costs (what to manage):** - **Boilerplate:** every delegated method is a forwarding method. For wide interfaces this is real volume. Mitigations: a reusable `Forwarding*` base class, IDE delegate-method generation, or a `java.lang.reflect.Proxy`/bytecode library (ByteBuddy/CGLIB) that forwards generically. - **Indirection:** one extra call hop and one extra object per layer; usually negligible (the JIT often inlines), but deep decorator stacks add cognitive and minor runtime overhead. - **Wiring complexity:** something must construct and connect collaborators. At scale this is why dependency-injection frameworks (Spring, Guice, CDI) exist — they automate the composition graph. - **No automatic open recursion:** with inheritance, a superclass method that calls an overridable method dispatches to the subclass override 'for free.' Composition has no implicit back-call; if you need it you must wire it explicitly. (This *absence* is also what removes the fragile-base-class bug.) ## How modern Java shifts the calculus - **Default methods on interfaces (Java 8+):** an interface can provide a *default* implementation of a method. This lets you share behavior across implementers **without** a common base class — reuse of behavior with inheritance-of-*type* only, sidestepping the fragile-base-class problem. It's the closest Java has to safe mixins. (Caveat: default methods can't access instance state, and multiple-inheritance-of-default conflicts must be resolved explicitly.) - **Records (Java 16+):** concise, immutable data carriers that are *implicitly final* — you literally cannot subclass them, which nudges designs toward composition (records *implement interfaces* and *hold* other records). They make value-type composition cheap. - **Sealed classes/interfaces + pattern-matching switch (Java 17/21):** a sealed hierarchy enumerates its permitted subtypes, so a `switch` can be checked *exhaustively* at compile time. This supports the *Visitor*-style 'add a new operation without touching the types' need that people historically reached for inheritance to solve — but in a closed, type-safe, composition-friendly way. It's a modern alternative to broad polymorphic class trees. - **Dynamic proxies & bytecode generation:** `java.lang.reflect.Proxy` (for interfaces) and libraries like ByteBuddy generate forwarding wrappers at runtime, so cross-cutting concerns (transactions, caching, logging, security) are applied by *composition/decoration* with no hand-written boilerplate. This is how Spring AOP and JPA proxies work. ## Strategic guidance for a large codebase 1. **Default to composition; program to interfaces.** Hold collaborators behind interfaces and inject them. 2. **Use inheritance only for designed, documented, ideally sealed IS-A hierarchies** — and prefer `final` classes by default. 3. **Push shared behavior into default methods or small composed helpers**, not deep base-class chains. 4. **Let a DI container own the composition graph** so wiring boilerplate doesn't become the bottleneck. 5. **Model closed variant sets with sealed types + pattern matching** instead of open inheritance when you expect to add *operations* more than *types*. ## Terms - **Open recursion:** a base method dispatching to a subclass override; inheritance gives it implicitly, composition does not. - **Default method:** an interface method with a body, shared by all implementers unless overridden. - **Dynamic proxy:** a runtime-generated object implementing given interfaces and routing all calls through an `InvocationHandler`. - **Sealed type:** a class/interface that explicitly permits a fixed set of subtypes, enabling exhaustive switches. - **Dependency injection (DI):** supplying a class's collaborators from outside instead of constructing them internally.
- How do default methods change the composition-vs-inheritance trade-off?They let an interface carry shared behavior, so implementers reuse code through inheritance of type only — no common base class and no fragile-base-class coupling. They can't hold state and multiple-default conflicts must be resolved explicitly, but they make behavior reuse possible without concrete-class inheritance.
- What capability does inheritance give 'for free' that composition does not, and is that good or bad?Open recursion: a superclass method that calls an overridable method automatically dispatches to the subclass's override. It's convenient but is precisely the mechanism behind fragile-base-class self-use bugs. Composition's lack of it removes that whole class of surprises, at the cost of having to wire any needed back-calls explicitly.
saying these in an interview costs you the question
- Claiming composition has zero cost — boilerplate, indirection, and wiring are real.
- Believing default methods reintroduce the fragile-base-class problem — they share type-level behavior, not implementation-inheritance internals (though conflicts must be resolved).
- Forgetting that inheritance's implicit open recursion is exactly what causes the self-use bug composition avoids.
- Treating DI frameworks as the only way to compose — plain constructor injection works fine for many systems.