Reusing code can create a dependency that lasts much longer than the code being reused. A common example is inheritance: a new class extends an existing class because it needs some of its behavior. The first change is convenient, but later the subclass may inherit assumptions, state, and lifecycle rules that it never wanted.
Composition offers a different relationship. Instead of saying that one type is a specialized form of another, an object receives or owns another object that provides a capability. The behavior can then be replaced without changing the object’s identity.
The useful question is therefore not “is composition better than inheritance?” Both are valid design tools. The practical question is what relationship does the design need to preserve? This article develops a mental model for answering that question and shows why behavior reuse alone is usually not enough reason to create a subtype.
Separate identity from capability
Inheritance normally creates a subtype relationship. If PremiumReport extends Report, callers should be able to treat a PremiumReport as a Report wherever the base type’s contract permits it.
Composition creates a capability relationship. A ReportExporter can use a formatter without claiming that the exporter is a kind of formatter.
That distinction matters because the two relationships make different promises.
Consider a report service that needs to format generated reports as text. A developer notices an existing formatter and writes:
class ReportService extends TextFormatter:
function generate(report):
content = format(report)
save(content)The service obtains format, but the type relationship now says something stronger than “this service needs formatting.” It says that a report service is a text formatter. Callers may start depending on that fact, protected formatter state may become part of the subclass implementation, and future changes to the base class can affect the service.
With composition, the relationship is narrower:
class ReportService:
constructor(formatter):
this.formatter = formatter
function generate(report):
content = this.formatter.format(report)
save(content)This example is deliberately simple. The important change is not syntax or dependency injection. It is the design statement: ReportService needs something that can format a report. Formatting is a collaborator, not part of the service’s identity.
Inheritance carries more than methods
Inheritance is sometimes described as a way to reuse methods, but that description hides much of the coupling involved.
A subclass can depend on several aspects of its base class:
- public behavior and its documented contract;
- protected methods or fields exposed for subclasses;
- constructor requirements;
- initialization and shutdown order;
- methods that the base class calls internally and expects subclasses to override;
- invariants that subclass code must preserve.
The exact mechanisms differ by programming language, but the engineering consequence is general: a subclass is affected by more than the method it originally wanted to reuse.
Suppose TextFormatter later gains a mutable locale field and its format method assumes that initializeLocale() has already run. A subclass that inherited only to reuse formatting now participates in that lifecycle. The dependency became wider even though the report service’s actual requirement did not change.
Composition can keep that dependency closer to the capability being used. If the formatter’s public contract is simply format(report), the report service need not know how the formatter initializes itself or stores its internal state.
Composition does not remove coupling. The service still depends on the formatter’s contract. It changes the shape of the coupling by making the dependency explicit and limiting which behavior is exposed through it.
Ask whether substitution is meaningful
A useful test for inheritance is substitution: if code expects the base type, does supplying the proposed subtype preserve the expectations that matter to that code?
This is stronger than asking whether the subclass happens to have the same methods.
Imagine a base abstraction for a read-only document:
interface Document:
function read()A cached document that still returns the same logical content may be a reasonable implementation of that abstraction. The caller asks for a readable document and does not need to know whether caching is involved.
Now imagine a base class called ResizableRectangle with operations that independently set width and height. Making Square a subclass may look mathematically natural, but the programming contract is awkward. A caller that sets width to 4 and height to 7 may expect those dimensions to remain independent. A square cannot preserve that expectation while also remaining square.
The problem is not that inheritance is technically impossible. The problem is that the proposed subtype cannot honor the behavior that callers reasonably associate with the base abstraction.
Before creating a subclass, write down the base contract in caller terms. If the new type needs to weaken preconditions, change important postconditions, disable operations, or reinterpret core behavior, the subtype relationship is probably misleading.
Use composition for replaceable policies
Composition is especially useful when part of an object’s behavior is a policy: a decision or algorithm that may vary independently from the object using it.
Suppose an invoice service chooses a late-fee calculation. The service coordinates invoice processing, while the fee rule may differ by customer agreement.
interface LateFeePolicy:
function calculate(invoice)
class InvoiceService:
constructor(lateFeePolicy):
this.lateFeePolicy = lateFeePolicy
function close(invoice):
fee = this.lateFeePolicy.calculate(invoice)
return invoice.withLateFee(fee)Now FixedLateFeePolicy and PercentageLateFeePolicy can vary independently of InvoiceService.
An inheritance design could instead create FixedFeeInvoiceService, PercentageFeeInvoiceService, and more subclasses as policies multiply. That may work when there is one stable dimension of variation. It becomes less attractive when another independent capability also varies, such as notification behavior. Combining two dimensions through subclasses can produce types such as PercentageFeeEmailInvoiceService and FixedFeeSmsInvoiceService.
Composition lets those dimensions remain separate:
InvoiceService(lateFeePolicy, notifier)The service can combine a fee policy and notifier without requiring a subclass for every combination.
This does not mean every small behavior needs an interface and a separate object. If a rule is simple, stable, and used in one place, a private function may communicate the design more clearly. Composition earns its cost when independent replacement is a real requirement or a useful boundary.
Prefer narrow collaborator contracts
Composition can still produce poor design when the collaborator exposes too much.
For example, passing a large ApplicationContext into InvoiceService technically uses composition, but the service can now reach configuration, storage, messaging, clocks, and unrelated services. The dependency is explicit only at the container level; the actual requirements remain hidden.
Prefer contracts that describe the capability the caller needs:
InvoiceService(lateFeePolicy, notifier)rather than:
InvoiceService(applicationContext)A narrow contract has two useful effects. First, a reader can see what the service depends on. Second, changing an unrelated capability is less likely to affect this code.
Do not make the contract artificially tiny merely to reduce method count. The methods should form a coherent capability. A formatter may reasonably expose format and supportedFormats if callers genuinely need both.
Recognize when inheritance is the clearer model
Composition is not a rule against inheritance. Inheritance can be appropriate when the subtype relationship is real, stable, and useful to callers.
Framework extension points are one example. A framework may define a base type with a documented lifecycle and specific methods intended for subclasses to override. Extending it deliberately means accepting that lifecycle contract. Replacing the relationship with composition may not be possible or may fight the framework’s intended design.
A domain hierarchy can also be appropriate when callers genuinely reason about a shared abstraction. If several payment instruments all honor a stable PaymentMethod contract, subtype polymorphism can express that relationship clearly. Whether a particular language implements the relationship with an interface, abstract base class, protocol, or another mechanism is an implementation choice.
Inheritance is more defensible when these conditions hold together:
- The subtype genuinely satisfies the base contract.
- Callers benefit from treating instances through the base abstraction.
- The base type is designed to support extension, including its lifecycle and override points.
- The hierarchy has a clear reason beyond sharing implementation.
If the main argument is “this class already contains the code I need,” composition or ordinary function extraction deserves consideration first.
Avoid replacing one rigid hierarchy with object clutter
A mechanical “composition over inheritance” refactor can create unnecessary indirection. Turning every method into a strategy object increases the number of types, construction decisions, and paths a reader must follow.
Consider a calculator with one stable rounding rule used nowhere else. Extracting RoundingPolicy, DefaultRoundingPolicy, and a factory for choosing between them may add flexibility that the system has no reason to use.
The simpler design can remain:
class PriceCalculator:
function calculate(order):
total = ...
return roundCurrency(total)If a second independently changing rounding policy appears later, extracting a collaborator is usually straightforward. Designing for every imaginable variation in advance can make current changes harder without delivering current value.
The goal is not maximum replaceability. It is to place replaceability where the software has a credible reason to need it.
Refactor an accidental hierarchy gradually
When inheritance has already spread through a codebase, replacing it does not require a large rewrite.
Suppose ReportService extends TextFormatter only to call format. A safe sequence is:
- Identify which inherited behavior
ReportServiceactually uses. - Define or reuse a small formatter contract around that behavior.
- Give
ReportServicea formatter collaborator. - Route its formatting calls through the collaborator.
- Verify callers do not rely on treating
ReportServiceas aTextFormatter. - Remove the inheritance relationship once that assumption is no longer needed.
Step five is important. Public inheritance may have become part of the observable API even if the original author never intended it. Removing the base type can therefore be a breaking change for callers that use the subclass through the base type.
Tests should cover behavior at the contract being changed, not merely prove that delegation occurs. If report generation must produce particular content, assert that result. A test that only checks formatter.format was called can preserve the implementation structure while missing a behavioral regression.
Make the choice from the relationship
When choosing between inheritance and composition, start with the relationship the code needs rather than the reuse mechanism that requires the fewest lines today.
Use inheritance when the new type is meaningfully substitutable for the base abstraction and accepting the base contract is part of the design. Use composition when an object needs a capability that should vary independently from its identity, especially when several independent behaviors may be combined.
Then apply a second filter: do not introduce either mechanism without a reason. A private function is often enough for local reuse, and a simple concrete collaborator may be enough when replacement is unlikely.
The durable mental model is straightforward: inheritance commits to a type relationship; composition commits to a dependency. Choose the commitment that matches the problem you actually have.