OOPIntermediate 12 min Lesson 8 of 9

Composition — Building From Parts

Assemble behaviour by holding other objects instead of inheriting from them, and keep each part small, focused and replaceable.

OOP · Lesson 8 of 9
0/9 done(0%)

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 #

PYTHON
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.

Quick quiz

  1. 1. What relationship does composition model?

  2. 2. Why pass collaborators into the constructor?

  3. 3. What is a Null Object?

  4. 4. Why is composition more flexible than inheritance?

  5. 5. What is the cost of composition?

Summary

  • Composition builds behaviour by holding and delegating to other objects.
  • Small collaborators are easy to test and reuse.
  • Pass dependencies in so they can be substituted.
  • A do-nothing implementation removes conditionals.
  • The cost is more classes and explicit wiring.