Composition over Inheritance: A Practical Design Checklist
A concise design note on choosing collaborators instead of fragile class hierarchies, with a notification example, trade-offs, and interview checkpoints.
The decision in one sentence
Use composition when behavior should vary independently; use inheritance when a subtype can honestly preserve the promises of its base type. “Prefer composition” is a design preference, not a ban on inheritance.
A notification example
An order service needs to notify customers. At first it sends email. Later, some customers want SMS, and the business wants retry rules and audit records.
A hierarchy such as EmailNotifier, RetryingEmailNotifier, AuditedRetryingEmailNotifier and AuditedSmsNotifier mixes several independent decisions. Every new combination encourages another subclass.
With composition, a notification service delegates delivery to a sender. Retry and audit behavior can be separate collaborators or wrappers. The service chooses its collaborators at construction time; it does not need a subclass for every combination.
The key distinction is responsibility: delivery knows how to send, retry knows whether another attempt is allowed, and audit records what happened. Do not put business decisions about customer consent inside a generic retry wrapper.
A five-question checklist
- What varies? If delivery channel and retry policy vary separately, represent them separately.
- What is promised? A subtype must preserve the base contract, including error behavior and side effects callers rely on.
- Who owns lifetime? Decide who creates and disposes collaborators; sharing a stateful object accidentally can introduce bugs.
- How will it be tested? A small delivery interface lets tests simulate success, temporary failure and permanent rejection without sending messages.
- Is the abstraction earned? Two identical lines do not justify a hierarchy. Start with a clear function when no meaningful variation exists.
Costs you should acknowledge
Composition adds indirection and wiring. A chain of wrappers can be harder to debug than a single class. Order matters: auditing each retry attempt differs from recording only the final outcome. Name the policy and test that ordering.
Inheritance can be useful for a stable framework extension point or a genuine shared contract. The danger is inheriting implementation merely to reuse a method, then overriding unrelated behavior to make the subclass fit.
Interview check
Question: A base contract promises that Save is supported for every valid document, but a read-only subclass always throws NotSupportedException. Is inheritance helping?
Answer: Under that stated contract, callers receive a subtype that cannot keep the promise. The exception itself is not the test: if the base contract explicitly permits read-only documents and exposes that capability, the conclusion can differ. Consider a readable document contract and a separate writable capability, rather than advertising an operation the object cannot perform.
Question: Does composition mean an interface for every class?
Answer: No. Introduce a boundary where variation, ownership or testing warrants it. Concrete collaborators and small functions are often enough.
Try it yourself
Sketch a report generator with a data source, formatter and destination. Add a second formatter without changing the data source. Then explain where errors are handled and whether the destination may be called twice. The quality of the boundaries matters more than the number of patterns used.
One possible answer
Let ReportGenerator ask a data source for report rows, pass those rows to a formatter, then pass the formatted result to a destination. Supply a plain-text formatter or an HTML formatter when constructing the generator; neither formatter needs to know how the rows were fetched. A small function is enough for a formatter with no state.
For this exercise, choose a fail-fast contract: if fetching or formatting fails, do not call the destination. If delivery fails, report the failure to the caller. Do not retry blindly: a timeout after the destination accepted a report could produce a second copy. Retry policy belongs at a boundary that knows whether repeating delivery is safe.
Check the boundaries with three tests: changing the formatter leaves source behavior unchanged; a formatter failure makes zero destination calls; a successful run makes one destination call. These are design choices for this report workflow, not guarantees supplied by composition.
Takeaway
Name the changing responsibility first. Choose the smallest design that keeps that responsibility explicit, testable and honest about its contract.
Sources
- Erich Gamma on composition and inheritance: why explicit collaborators reduce dependence on a base class's implementation.
- Liskov and Wing on behavioral subtyping: the contract behind the Save question.
TechInterviewNotes Editorial Team · Updated 7 Oct 2026 ·