What is it? #
Composition means an object holds other objects and delegates work to them. It models "has a" rather than "is a".
A car has an engine. An order has line items. A report generator has a data source and a formatter.
Each part stays small and independently testable. Swapping one part does not disturb the others, and a part can be reused by several different wholes.
Composition is more flexible than inheritance because the relationship is decided at runtime. You can pass in a different engine; you cannot change what a class inherits from once it is written.
Think of it like this #
A desktop computer is assembled from parts: a power supply, a drive, memory, a graphics card. Each part does one job and can be swapped without rebuilding the machine.
Inheritance would be more like a laptop with everything soldered to one board. It works, it is compact, and upgrading one component means replacing the whole thing.
Simple example #
An order needs a price calculated with tax, a discount applied, and a notification sent. Inheritance would force these into one hierarchy. Composition gives the order three small collaborators.
Code #
class TaxPolicy:
def __init__(self, rate: float):
self.rate = rate
def apply(self, amount: float) -> float:
return round(amount * (1 + self.rate), 2)
class PercentageDiscount:
def __init__(self, percent: float):
self.percent = percent
def apply(self, amount: float) -> float:
return round(amount * (1 - self.percent / 100), 2)
class NoDiscount:
def apply(self, amount: float) -> float:
return amount
class EmailNotifier:
def send(self, to: str, message: str) -> None:
print(f"[email to {to}] {message}")
class Order:
def __init__(self, customer_email, lines, tax_policy, discount, notifier):
self.customer_email = customer_email
self.lines = lines # has-a: order lines
self.tax = tax_policy # has-a: a tax policy
self.discount = discount # has-a: a discount rule
self.notifier = notifier # has-a: a way to notify
def subtotal(self) -> float:
return round(sum(qty * price for _, qty, price in self.lines), 2)
def payable(self) -> float:
after_discount = self.discount.apply(self.subtotal())
return self.tax.apply(after_discount)
def confirm(self) -> None:
self.notifier.send(self.customer_email, f"Order confirmed: {self.payable()}")
order = Order(
customer_email="[email protected]",
lines=[("P-1", 2, 250.0), ("P-2", 1, 499.0)],
tax_policy=TaxPolicy(0.18),
discount=PercentageDiscount(10),
notifier=EmailNotifier(),
)
print(order.subtotal()) # 999.0
print(order.payable()) # 1061.02
order.confirm()
# Change behaviour by changing parts, not by editing Order
festive = Order("[email protected]", order.lines, TaxPolicy(0.05), NoDiscount(), EmailNotifier())
print(festive.payable()) # 1048.95
How it works #
Order holds four collaborators and delegates to them. It knows the sequence — discount, then tax — but not the details of either.
Each collaborator is tiny and testable on its own. TaxPolicy can be tested without creating an order; PercentageDiscount needs no email system.
NoDiscount is worth noticing. Rather than writing if self.discount is not None, there is an object that does nothing. This is the Null Object pattern, and it removes a conditional from Order.
Behaviour changes by passing different parts. A festive sale is a different TaxPolicy and a different discount, with no change to Order at all.
The collaborators are passed into the constructor rather than created inside. That is dependency injection, and it is what makes substitution possible — a test can pass a recording notifier instead of a real email sender.
Composition does have a cost: more small classes, and the wiring has to happen somewhere. Larger applications usually centralise that wiring in a factory or a startup module so the assembly is visible in one place.
Real-world use #
Most modern application code is composed rather than inherited. A service holds a repository, a client, a cache and a logger, and coordinates them.
Web frameworks pass request handlers their dependencies. Testing frameworks substitute fakes for those dependencies. Configuration decides which implementation gets wired in for which environment.
The Null Object idea shows up widely: a no-op logger, an empty cache, a discount of zero. It removes null checks from the code that uses them.
The practical signal for composition over inheritance is when behaviours vary independently. Payment method, shipping option and discount type multiply against each other — as subclasses, that means a class per combination; as collaborators, it is three small sets of parts.
Common mistakes #
- Creating collaborators inside the class instead of passing them in, which blocks substitution.
- Building deep chains of delegation where a call passes through five objects doing nothing.
- Giving one collaborator too many responsibilities, recreating the problem inside a part.
- Adding null checks everywhere instead of supplying a do-nothing implementation.
- Wiring dependencies ad hoc across the codebase rather than in one place.
Practice #
Build a ReportGenerator composed of a data source, a formatter and a delivery method. Write two formatters (CSV and plain text) and two delivery methods (print and collect-to-list). Generate the same report four ways by swapping parts, without modifying ReportGenerator.