backend / architecture design / 05_composition_over_inheritance.md

Why prefer composition over inheritance?

4 interview angles 3 min read source

Why prefer composition over inheritance?

Answer

Flexibility and change

  • Composition is “has-a”: the object holds references to other objects (dependencies). You can swap or add behaviors by changing what you inject, without touching the type hierarchy. Inheritance is “is-a”: the subclass is tied to one base; changing behavior often means new subclasses or deeper hierarchies, which get rigid and brittle.

Single responsibility and smaller types

  • A class that composes dependencies does one job and delegates the rest. With inheritance, base classes tend to grow (every subclass adds a “variant”), and you get fat base classes, fragile overrides, and unexpected behavior when multiple levels override the same method.

Testing

  • With composition, you inject mocks or stubs (e.g. a fake repository, a no-op logger). With inheritance, you often need to subclass or patch to isolate behavior, which is harder and more coupled.

Avoiding inheritance pitfalls

  • Deep inheritance leads to fragile base classes, the “diamond” problem when multiple inheritance is used, and unclear overrides (which super() is which?). Composition avoids these by keeping relationships explicit and one-directional.

When inheritance still makes sense

  • Use inheritance when you have a true subtype and a stable, small hierarchy (e.g. a few specialized handlers that share a clear interface). Prefer composition for “using” another capability (logging, storage, transport); prefer inheritance for “is a kind of” with a stable contract.

In code

Same job done two ways — a service that processes data and reports what it did.

Inheritance bakes the behavior into the hierarchy; to change how it reports, you subclass:

class Service:
    def process(self, data):
        result = data.upper()
        self.report(f"processed {data!r}")
        return result
    def report(self, msg):
        print(msg)                      # console, hard-coded

class FileService(Service):             # subclass just to change reporting
    def report(self, msg):
        with open("svc.log", "a") as f:
            f.write(msg + "\n")

Add Slack alerts and a JSON format and you need a subclass per combination — the combinatorial explosion. Reporting can’t change at runtime, and tests must subclass to silence output.

Composition injects the behavior — the service has-a reporter and delegates:

class ConsoleReporter:
    def report(self, msg):
        print(msg)

class FileReporter:
    def __init__(self, path):
        self.path = path
    def report(self, msg):
        with open(self.path, "a") as f:
            f.write(msg + "\n")

class Service:
    def __init__(self, reporter):       # inject the capability
        self.reporter = reporter
    def process(self, data):
        result = data.upper()
        self.reporter.report(f"processed {data!r}")
        return result

Service(ConsoleReporter())              # swap behavior, no new subclass
Service(FileReporter("svc.log"))
# tests: Service(FakeReporter()) — capture messages and assert on them

One Service; behaviors combine by passing different objects — swappable at runtime, trivially tested with a fake reporter, and no hierarchy to grow.

Inheritance done right is a true is-a with a stable contract:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

class Circle(Shape):
    def __init__(self, r):
        self.r = r
    def area(self):
        return 3.14159 * self.r ** 2

class Square(Shape):
    def __init__(self, s):
        self.s = s
    def area(self):
        return self.s ** 2

A Circle genuinely is a Shape; the hierarchy is shallow and subclasses only implement the contract — nothing to override later. That’s where inheritance fits.

Summary

  • Prefer composition to get flexible, testable, and maintainable code; use inheritance sparingly for clear “is-a” relationships and stable hierarchies.

Interview angle

  • “Why prefer composition?” - inheritance couples you to a base class’s entire surface and its future changes, and it’s fixed at class-definition time. Composition lets you assemble behaviour from parts and change it at runtime, and it keeps each part independently testable.
  • “When is inheritance still right?” - genuine is-a substitutability, where a subclass can be used anywhere the base can (Liskov). Framework extension points and abstract base classes defining a contract are legitimate uses.
  • “What’s the smell that you inherited for the wrong reason?” - a subclass that overrides a method to raise NotImplementedError, or one that uses only a fraction of the base class. Both mean you wanted reuse, not substitutability.
  • “How does this look in Python specifically?” - protocols plus injected collaborators rather than deep hierarchies. Mixins are the middle ground and are fine in moderation; a class with five mixins and a confusing MRO is the failure mode.