Skip to main content

Composition vs Inheritance - When to Use Each at Engineering Depth

Reading time: ~30 minutes | Level: Intermediate → Engineering

Before reading further, consider what is wrong with this design:

class Animal:
def breathe(self): ...
def eat(self): ...

class FlyingAnimal(Animal):
def fly(self): ...

class SwimmingAnimal(Animal):
def swim(self): ...

class Duck(FlyingAnimal, SwimmingAnimal):
def quack(self): ...

class RubberDuck(???):
# Can quack. Cannot fly. Cannot eat. Is not alive.
# Which class does it inherit from?
pass

The RubberDuck problem is not contrived. It reveals a fundamental flaw: the inheritance hierarchy is modelling capabilities as a type tree, and the tree collapses the moment reality does not fit it. You end up either inheriting methods that do not apply, or creating bizarre workarounds like raising NotImplementedError in overridden methods - which itself violates the Liskov Substitution Principle (covered in Lesson 11).

This is precisely the problem that composition solves.

What You Will Learn

  • What "is-a" and "has-a" relationships mean and how to identify them in code
  • Why "favour composition over inheritance" is the most important OOP heuristic
  • How the delegation pattern implements composition in Python
  • What mixins are, how they sit between composition and inheritance, and when they are appropriate
  • How to refactor an inheritance-based design to a composition-based one
  • How dependency injection works in Python and why it leads to testable code
  • How typing.Protocol enables structural (duck-typed) interfaces without ABCs
  • Why Django's class-based views use inheritance correctly, and what makes it correct

Prerequisites

  • Lessons 01–06 of this module (Classes, __init__, Dunder Methods, Properties, Inheritance, Descriptors)
  • Understanding of Python's MRO at a basic level
  • Familiarity with typing module basics

Part 1 - Is-A vs Has-A: The Root Distinction

Is-A: When Inheritance Is the Right Model

Inheritance models a type relationship. If you can truthfully complete the sentence "B is a kind of A", then B inheriting from A is semantically correct.

class Shape:
def area(self) -> float: ...
def perimeter(self) -> float: ...

class Circle(Shape): # A Circle IS-A Shape - correct
def __init__(self, radius: float):
self.radius = radius

def area(self) -> float:
import math
return math.pi * self.radius ** 2

def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius

class Rectangle(Shape): # A Rectangle IS-A Shape - correct
def __init__(self, width: float, height: float):
self.width = width
self.height = height

def area(self) -> float:
return self.width * self.height

def perimeter(self) -> float:
return 2 * (self.width + self.height)

Both Circle and Rectangle are genuinely Shape subtypes. Any code that operates on a Shape can operate on either, and neither will behave unexpectedly. This is correct use of inheritance.

Has-A: When Composition Is the Right Model

Composition models a containment or usage relationship. If you can truthfully say "B has a A" or "B uses a A", then B should contain an instance of A, not inherit from it.

# Wrong: inheritance used for a has-a relationship
class Engine:
def start(self): print("Engine started")
def stop(self): print("Engine stopped")

class Car(Engine): # A Car IS-A Engine? No. A Car HAS-A Engine.
def drive(self): ...

# Right: composition
class Car:
def __init__(self, engine: "Engine"):
self._engine = engine # Car HAS-A Engine

def start(self):
self._engine.start() # delegation

def stop(self):
self._engine.stop()

def drive(self): ...

The composition version is immediately more flexible: you can swap engines without touching Car. A Car with an ElectricEngine and a Car with a PetrolEngine are the same type - Car - just configured differently.

The Quick Test

Ask these two questions:

  1. Is-a test: Does every instance of B remain a valid, fully-substitutable instance of A? Can all of A's methods be called on B without error or surprise?
  2. Has-a test: Does B merely use the functionality of A, or contain A as a component?

If the answer to question 1 is "no", or question 2 is "yes", use composition.

tip

Favour composition over inheritance. Composition is easier to test - you can inject mock dependencies directly into __init__. It is more flexible - components can be swapped without touching the containing class. And it is more readable - every dependency is explicitly declared in __init__ rather than hidden in an inheritance chain.

Part 2 - Why "Favour Composition Over Inheritance" Exists

The principle comes from the Gang of Four book (1994). After 30 years it remains the most violated OOP rule in production codebases. Here is why the rule exists, stated precisely.

Problem 1 - Inheritance Couples Implementation

When B inherits from A, B is coupled to A's implementation details, not just its interface.

class Base:
def process(self, data):
cleaned = self._clean(data)
return self._transform(cleaned)

def _clean(self, data):
return data.strip()

def _transform(self, data):
return data.upper()

class Child(Base):
def _transform(self, data):
# Overrides a "private" method - fragile coupling to internals
return data.lower()

If Base._clean changes signature, adds validation, or is renamed, Child breaks. The child depends on the parent's internals. This is called the fragile base class problem.

With composition, you only depend on the public interface:

class Transformer:
def transform(self, data: str) -> str:
return data.upper()

class Processor:
def __init__(self, transformer: "Transformer"):
self._transformer = transformer

def process(self, data: str) -> str:
cleaned = data.strip()
return self._transformer.transform(cleaned) # uses public API only

Problem 2 - Inheritance Hierarchies Become Rigid

Vehicle
├── LandVehicle
│ ├── Car
│ │ ├── ElectricCar
│ │ └── HybridCar
│ └── Truck
└── WaterVehicle
├── Boat
└── Submarine

What is an amphibious vehicle? Where does a hovercraft go? The tree breaks down. Adding a new capability requires either restructuring the hierarchy or using multiple inheritance, which introduces MRO complexity.

Composition sidesteps this entirely: a vehicle has a propulsion system, a steering system, and terrain capability - all as separate, swappable components.

warning

Deep inheritance hierarchies - three or more levels - are almost always a design smell. Every level you add couples you more tightly to implementation details above, makes the effective interface harder to reason about, and makes testing more difficult. If you find yourself at level 3 or beyond, reach for composition.

Problem 3 - Deep Hierarchies Are Hard to Reason About

Reading ElectricCar requires understanding Car, LandVehicle, and Vehicle. The effective interface of ElectricCar is the union of all four levels. With composition, all dependencies are explicit in __init__.

Part 3 - The Delegation Pattern

Delegation is the implementation mechanism of composition. The containing object delegates calls to the component objects.

Basic Delegation

class Logger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")

class FileWriter:
def write(self, path: str, content: str) -> None:
with open(path, "w") as f:
f.write(content)

class ReportGenerator:
"""Uses Logger and FileWriter via delegation - no inheritance."""

def __init__(self, logger: "Logger", writer: "FileWriter"):
self._logger = logger
self._writer = writer

def generate(self, data: list, output_path: str) -> None:
self._logger.log(f"Starting report generation for {output_path}")
report = self._format(data)
self._writer.write(output_path, report)
self._logger.log(f"Report written to {output_path}")

def _format(self, data: list) -> str:
return "\n".join(str(item) for item in data)

ReportGenerator delegates logging to Logger and file I/O to FileWriter. Neither component knows about the other. Both can be replaced independently.

Transparent Delegation with __getattr__

Sometimes you want to expose the component's full interface without wrapping every method:

class LoggedList:
"""A list that logs every mutation - wraps list via delegation."""

def __init__(self):
self._list = []
self._log = []

def append(self, item):
self._log.append(f"append({item!r})")
self._list.append(item)

def remove(self, item):
self._log.append(f"remove({item!r})")
self._list.remove(item)

def __getattr__(self, name):
# For any attribute not found on LoggedList, delegate to _list.
# Gives access to .sort(), .reverse(), .count(), etc.
return getattr(self._list, name)

def __len__(self):
return len(self._list)

def __getitem__(self, index):
return self._list[index]

def __repr__(self):
return f"LoggedList({self._list!r})"

@property
def history(self):
return list(self._log)


ll = LoggedList()
ll.append(1)
ll.append(2)
ll.append(3)
ll.remove(2)
print(ll) # LoggedList([1, 3])
print(ll.history) # ['append(1)', 'append(2)', 'append(3)', 'remove(2)']
print(ll.count(1)) # 1 - delegated via __getattr__

Use __getattr__ delegation carefully - it makes the composed object's interface implicit, which can hide bugs and confuse type checkers.

Explicit vs Implicit Delegation Trade-off

# Explicit: every delegated method is visible in the class definition
class Connection:
def __init__(self, socket):
self._socket = socket

def send(self, data: bytes) -> int:
return self._socket.send(data)

def recv(self, size: int) -> bytes:
return self._socket.recv(size)

def close(self) -> None:
return self._socket.close()


# Implicit: __getattr__ handles all delegation
class ConnectionProxy:
def __init__(self, socket):
self._socket = socket

def __getattr__(self, name):
return getattr(self._socket, name)

For production code, prefer explicit delegation when the interface is bounded and well-known. Use __getattr__ delegation for proxy or wrapper patterns where the wrapped interface is large or dynamic.

Part 4 - Mixins: The Middle Ground

What Is a Mixin?

A mixin is a class designed to be inherited from, but not to stand alone. It provides a specific, focused capability that can be mixed into any class that needs it, without imposing a type hierarchy.

Mixins use inheritance syntactically, but they model has-a semantically - they add a capability, not a type.

note

Mixins are a middle ground between pure composition and full inheritance. They let you mix in reusable behaviour - serialisation, logging, validation - without claiming a true is-a relationship. The key rules: always call super().__init__(), name them with the Mixin suffix, and document any attributes they depend on. Used correctly, mixins give you the flexibility of composition with less boilerplate.

class JsonSerializableMixin:
"""Add JSON serialisation to any class that has a meaningful __dict__."""

def to_json(self) -> str:
import json
return json.dumps(self.__dict__, default=str)

@classmethod
def from_json(cls, json_str: str):
import json
data = json.loads(json_str)
obj = cls.__new__(cls)
obj.__dict__.update(data)
return obj


class TimestampMixin:
"""Add created_at and updated_at tracking to any model."""

def __init__(self, *args, **kwargs):
from datetime import datetime
super().__init__(*args, **kwargs) # critical: always call super() in mixins
self.created_at = datetime.utcnow()
self.updated_at = datetime.utcnow()

def touch(self):
from datetime import datetime
self.updated_at = datetime.utcnow()


class ValidatedMixin:
"""Trigger validate() after __init__ completes."""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.validate()

def validate(self):
pass # subclasses override this


class User(JsonSerializableMixin, TimestampMixin, ValidatedMixin):
def __init__(self, username: str, email: str):
self.username = username
self.email = email
super().__init__() # triggers mixin __init__ chain via MRO

def validate(self):
if "@" not in self.email:
raise ValueError(f"Invalid email: {self.email}")


user = User("alice", "[email protected]")
print(user.to_json()) # {"username": "alice", "email": "[email protected]", ...}
print(user.created_at) # datetime object

The Mixin Contract

Mixins must follow these rules to be safe:

  1. Always call super().__init__() - ensures the full MRO chain is initialised correctly
  2. Do not define __init__ unless necessary - the best mixins have no constructor state
  3. Depend only on a documented interface - if the mixin requires self.email to exist, document that requirement explicitly
  4. Name them with Mixin suffix - JsonSerializableMixin, not JsonSerializable, signals their role
class RepresentationMixin:
"""Provide a meaningful __repr__ based on __init__ signature."""

def __repr__(self) -> str:
import inspect
params = inspect.signature(self.__class__.__init__).parameters
attrs = {
name: getattr(self, name, "?")
for name in params
if name != "self"
}
attr_str = ", ".join(f"{k}={v!r}" for k, v in attrs.items())
return f"{type(self).__name__}({attr_str})"


class Point(RepresentationMixin):
def __init__(self, x: float, y: float):
self.x = x
self.y = y


class Rectangle(RepresentationMixin):
def __init__(self, width: float, height: float):
self.width = width
self.height = height


print(Point(3, 4)) # Point(x=3, y=4)
print(Rectangle(10, 5)) # Rectangle(width=10, height=5)

When Mixins Are Appropriate

Mixins are appropriate when:

  • The capability is orthogonal to the type hierarchy (serialisation, logging, caching, validation)
  • The capability is used across many unrelated classes
  • The alternative - composition - would require boilerplate delegation in every consuming class

Mixins become problematic when:

  • They carry significant state that interacts with each other
  • They have complex dependencies on other mixins
  • The same capability is expressed through multiple overlapping mixins

Part 5 - Refactoring Inheritance to Composition

Recognising When to Refactor

Signs that an inheritance hierarchy should be refactored:

  1. Methods in the parent overridden to raise NotImplementedError or do nothing
  2. Child classes that only use a subset of the parent's interface
  3. The same feature being needed by multiple, unrelated class hierarchies
  4. You are inheriting for code reuse, not because of a genuine type relationship

Step-by-Step Refactoring

Before: inheritance-based design with coupling problems

class DataProcessor:
def __init__(self, source: str):
self.source = source

def fetch(self) -> list:
# Simulate fetching from source
return [1, 2, 3, 4, 5]

def process(self, data: list) -> list:
raise NotImplementedError # subclasses must implement

def save(self, data: list) -> None:
print(f"Saving {len(data)} records")

def run(self) -> None:
raw = self.fetch()
processed = self.process(raw)
self.save(processed)


class FilterProcessor(DataProcessor):
def process(self, data: list) -> list:
return [x for x in data if x > 2]


class DoubleProcessor(DataProcessor):
def process(self, data: list) -> list:
return [x * 2 for x in data]

Problems: process raises NotImplementedError - should use ABCs. The fetch and save logic cannot be swapped without subclassing. Testing requires subclassing just to inject test behaviour.

After: composition-based design with explicit dependencies

from typing import Protocol, List


class DataFetcher(Protocol):
def fetch(self) -> List[int]: ...


class DataTransformer(Protocol):
def transform(self, data: List[int]) -> List[int]: ...


class DataSaver(Protocol):
def save(self, data: List[int]) -> None: ...


# Concrete implementations - no inheritance between them
class ListFetcher:
def __init__(self, data: list):
self._data = data

def fetch(self) -> list:
return list(self._data)


class FilterTransformer:
def __init__(self, threshold: int):
self._threshold = threshold

def transform(self, data: list) -> list:
return [x for x in data if x > self._threshold]


class DoubleTransformer:
def transform(self, data: list) -> list:
return [x * 2 for x in data]


class PrintSaver:
def save(self, data: list) -> None:
print(f"Saving {len(data)} records: {data}")


# Pipeline: composed of dependencies, no inheritance
class DataPipeline:
def __init__(
self,
fetcher: DataFetcher,
transformer: DataTransformer,
saver: DataSaver,
):
self._fetcher = fetcher
self._transformer = transformer
self._saver = saver

def run(self) -> None:
raw = self._fetcher.fetch()
processed = self._transformer.transform(raw)
self._saver.save(processed)


# Wire it up
pipeline = DataPipeline(
fetcher=ListFetcher([1, 2, 3, 4, 5]),
transformer=FilterTransformer(threshold=2),
saver=PrintSaver(),
)
pipeline.run() # Saving 3 records: [3, 4, 5]

# Swap the transformer - zero changes to DataPipeline
pipeline2 = DataPipeline(
fetcher=ListFetcher([1, 2, 3, 4, 5]),
transformer=DoubleTransformer(),
saver=PrintSaver(),
)
pipeline2.run() # Saving 5 records: [2, 4, 6, 8, 10]

Every component is testable in isolation. DataPipeline can be tested with mock implementations of each Protocol. Adding a new transformer requires zero changes to existing code - this is the Open/Closed Principle in action.

Part 6 - Dependency Injection in Python

Dependency injection (DI) is the practice of passing collaborating objects into a class rather than creating them internally. It is the mechanism that makes composition actually work in production code.

Constructor Injection (Preferred)

from typing import Protocol


class EmailSender(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...


class SMTPEmailSender:
def __init__(self, host: str, port: int):
self._host = host
self._port = port

def send(self, to: str, subject: str, body: str) -> None:
# Real implementation would use smtplib here
print(f"SMTP [{self._host}:{self._port}]: sending to {to}: {subject}")


class MockEmailSender:
"""For testing - no network required."""

def __init__(self):
self.sent: list = []

def send(self, to: str, subject: str, body: str) -> None:
self.sent.append({"to": to, "subject": subject, "body": body})


class UserRegistrationService:
def __init__(self, email_sender: EmailSender):
# Inject the dependency - never create it internally
self._email_sender = email_sender

def register(self, username: str, email: str) -> dict:
user = {"username": username, "email": email, "active": True}
self._email_sender.send(
to=email,
subject="Welcome!",
body=f"Hello {username}, your account is ready.",
)
return user


# Production: real SMTP sender
service = UserRegistrationService(
email_sender=SMTPEmailSender(host="smtp.example.com", port=587)
)

# Testing: mock sender - no SMTP server needed
mock_sender = MockEmailSender()
test_service = UserRegistrationService(email_sender=mock_sender)
user = test_service.register("alice", "[email protected]")
assert len(mock_sender.sent) == 1
assert mock_sender.sent[0]["to"] == "[email protected]"
print("Test passed")

Property Injection and Method Injection

class ReportService:
"""Property injection: dependency is optional or set post-construction."""

def __init__(self):
self._formatter = None # optional dependency

@property
def formatter(self):
if self._formatter is None:
raise RuntimeError("Formatter not configured - call set_formatter() first")
return self._formatter

@formatter.setter
def formatter(self, value):
self._formatter = value

def generate(self, data: list) -> str:
return self.formatter.format(data)


class AnalyticsService:
"""Method injection: per-call variation of the dependency."""

def compute(self, data: list, strategy: "ComputeStrategy") -> float:
# Strategy injected per call - different strategies for different calls
return strategy.compute(data)

Use property injection when a dependency is truly optional or when circular dependencies make constructor injection impossible. Use method injection when the dependency varies per call.

Part 7 - typing.Protocol for Structural Typing

typing.Protocol (Python 3.8+) formalises Python's duck typing. A class satisfies a Protocol if it has the right methods and attributes - no explicit declaration or inheritance required.

Defining and Using Protocols

from typing import Protocol, runtime_checkable


@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...


class Circle:
def draw(self) -> None:
print("Drawing circle")

def resize(self, factor: float) -> None:
print(f"Resizing circle by {factor}")


class Square:
def draw(self) -> None:
print("Drawing square")

def resize(self, factor: float) -> None:
print(f"Resizing square by {factor}")


class TextLabel:
def draw(self) -> None:
print("Drawing label")

def resize(self, factor: float) -> None:
print(f"Resizing label by {factor}")


def render_all(drawables: list[Drawable]) -> None:
for d in drawables:
d.draw()


# None of these inherit from Drawable - they just have the right methods
canvas = [Circle(), Square(), TextLabel()]
render_all(canvas)

# With @runtime_checkable, isinstance works at runtime
print(isinstance(Circle(), Drawable)) # True

Protocols vs ABCs: When to Use Each

from abc import ABC, abstractmethod
from typing import Protocol

# ABC: nominal typing - requires explicit inheritance declaration
class Serializable(ABC):
@abstractmethod
def serialize(self) -> bytes: ...

@abstractmethod
def deserialize(self, data: bytes) -> None: ...

# Must explicitly inherit to satisfy
class MyModel(Serializable):
def serialize(self) -> bytes: return b"data"
def deserialize(self, data: bytes) -> None: pass


# Protocol: structural typing - no explicit inheritance needed
class Serializable(Protocol):
def serialize(self) -> bytes: ...
def deserialize(self, data: bytes) -> None: ...

# Satisfies Protocol without declaring it
class ThirdPartyModel:
def serialize(self) -> bytes: return b"data"
def deserialize(self, data: bytes) -> None: pass
ABCProtocol
Typing styleNominal (must declare inheritance)Structural (duck typing)
Inheritance requiredYesNo
isinstance at runtimeAlways worksOnly with @runtime_checkable
Enforcement at class creationYes - missing @abstractmethod raises TypeErrorNo - checked only by type checkers (mypy, pyright)
Best forStable internal hierarchies where you control both sidesExternal types, third-party code, existing code you cannot modify

Protocol with Composition: The Full Pattern

from typing import Protocol


class Cache(Protocol):
def get(self, key: str) -> object | None: ...
def set(self, key: str, value: object, ttl: int = 300) -> None: ...
def delete(self, key: str) -> None: ...


class InMemoryCache:
def __init__(self):
self._store: dict = {}

def get(self, key: str) -> object | None:
return self._store.get(key)

def set(self, key: str, value: object, ttl: int = 300) -> None:
self._store[key] = value # TTL not implemented in this stub

def delete(self, key: str) -> None:
self._store.pop(key, None)


class UserService:
def __init__(self, cache: Cache):
self._cache = cache

def get_user(self, user_id: int) -> dict | None:
key = f"user:{user_id}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
# Simulate a database fetch
user = {"id": user_id, "name": "Alice"}
self._cache.set(key, user, ttl=3600)
return user


service = UserService(cache=InMemoryCache())
print(service.get_user(1)) # {'id': 1, 'name': 'Alice'} - from DB
print(service.get_user(1)) # {'id': 1, 'name': 'Alice'} - from cache

Part 8 - When Inheritance Is Genuinely Right

After all this, state it clearly: inheritance is not wrong. It is wrong when misapplied. Here are the cases where it is the correct tool.

Case 1 - True Liskov-Substitutable Hierarchies

When B is genuinely substitutable for A in all contexts, inheritance is correct.

class Exception(BaseException): ... # Exception IS-A BaseException
class ValueError(Exception): ... # ValueError IS-A Exception
class UserNotFoundError(ValueError): ... # IS-A ValueError - correct

# Any code catching ValueError also catches UserNotFoundError
try:
raise UserNotFoundError("user 42 not found")
except ValueError as e:
print(f"Handled: {e}") # works - correct substitution

Python's own exception hierarchy is the model example.

Case 2 - Framework Integration via Hooks

Many frameworks are designed around inheritance as an extension mechanism. This is valid because the framework designed the hierarchy intentionally and the subclass IS genuinely the parent type.

# Django Class-Based Views - inheritance done right
from django.views import View
from django.http import HttpRequest, HttpResponse


class ReportView(View):
"""
ReportView IS-A View - it handles HTTP requests.
Django's View defines the request dispatch lifecycle.
Subclasses override specific HTTP method handlers.
This is the Template Method pattern in production.
"""

def get(self, request: HttpRequest, pk: int) -> HttpResponse:
return HttpResponse(f"Report {pk}")

def post(self, request: HttpRequest, pk: int) -> HttpResponse:
return HttpResponse(f"Updated report {pk}")

Why Django's CBV inheritance is correct:

  • ReportView IS genuinely a View - it handles HTTP requests
  • The parent defines the dispatch protocol; subclasses override specific hooks
  • Django's mixin system (LoginRequiredMixin, PermissionRequiredMixin) adds orthogonal capabilities
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin


class SecureReportView(LoginRequiredMixin, PermissionRequiredMixin, View):
permission_required = "myapp.view_report"

def get(self, request: HttpRequest) -> HttpResponse:
return HttpResponse("Secure content")

The MRO ordering matters: LoginRequiredMixin checks authentication, then PermissionRequiredMixin checks permission, then View handles the request. The order is intentional and documented.

Case 3 - Template Method Pattern

The Template Method pattern is a legitimate use of inheritance. The parent defines the algorithm skeleton; subclasses fill in the steps.

class ReportExporter:
"""Template Method: defines export algorithm, delegates format-specific steps."""

def export(self, data: list, filename: str) -> None:
"""The template - do not override this method."""
prepared = self._prepare(data)
formatted = self._format(prepared)
self._write(filename, formatted)
self._notify(filename)

def _prepare(self, data: list) -> list:
return [row for row in data if row] # default: filter empty rows

def _format(self, data: list) -> str:
raise NotImplementedError # subclasses must implement this step

def _write(self, filename: str, content: str) -> None:
with open(filename, "w") as f:
f.write(content)

def _notify(self, filename: str) -> None:
print(f"Export complete: {filename}")


class TabExporter(ReportExporter):
def _format(self, data: list) -> str:
return "\n".join("\t".join(str(c) for c in row) for row in data)


class PipeExporter(ReportExporter):
def _format(self, data: list) -> str:
return "\n".join("|".join(str(c) for c in row) for row in data)

TabExporter IS-A ReportExporter. The subclass completes the algorithm - it does not redefine the structure.

Common Mistakes

Mistake 1 - Inheriting to Reuse Code

# Wrong: inheriting just to get utility methods
class StringUtils:
def to_uppercase(self, s: str) -> str: return s.upper()
def to_slug(self, s: str) -> str: return s.lower().replace(" ", "-")

class UserReport(StringUtils): # UserReport is NOT a StringUtils
def __init__(self, user: dict): self.user = user

# Right: use composition or a standalone function
class UserReport:
def __init__(self, user: dict, formatter: "StringUtils"):
self.user = user
self._fmt = formatter

Mistake 2 - Mixin That Omits super() Call

# Wrong: breaks the MRO initialisation chain
class AuditMixin:
def __init__(self):
self.audit_log = [] # super() not called - everything above stops here

# Right: always forward args to super() in mixins
class AuditMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.audit_log = []

Mistake 3 - Using Protocol Without @runtime_checkable and Calling isinstance

from typing import Protocol

class Drawable(Protocol): # missing @runtime_checkable
def draw(self) -> None: ...

class Circle:
def draw(self) -> None: pass

isinstance(Circle(), Drawable)
# TypeError: Protocols with non-method members don't support issubclass()

Add @runtime_checkable if you need isinstance checks at runtime. Without it, Protocol is a type-checker annotation only.

Mistake 4 - Creating Dependencies Inside __init__ (Violates DI)

# Wrong: hardcodes the dependency - impossible to test without patching
class OrderService:
def __init__(self):
import smtplib
self._mailer = smtplib.SMTP("smtp.example.com", 587) # untestable

# Right: inject the dependency
class OrderService:
def __init__(self, mailer: "EmailSender"):
self._mailer = mailer # injected - mockable in tests

Engineering Checklist

Before moving to the next lesson, verify you can answer these without looking:

  1. What is the is-a test, and what is the has-a test? How do you apply them to a design decision?
  2. What is the fragile base class problem, and how does composition avoid it?
  3. What three rules must every mixin follow to be safe?
  4. How do you implement delegation via __getattr__, and when is explicit delegation preferable?
  5. What is the difference between constructor injection, property injection, and method injection?
  6. What is the difference between typing.Protocol (structural typing) and ABC (nominal typing)?
  7. When do you need @runtime_checkable on a Protocol?
  8. Why is Django's class-based view system a correct use of inheritance?
  9. What is the Template Method pattern and why does it justify inheritance?

Key Takeaways

  • The is-a test is the gatekeeper for inheritance: if B is not fully substitutable for A in every context, use composition instead.
  • Favour composition over inheritance - it produces code that is easier to test (inject mocks via __init__), more flexible (swap components without changing the containing class), and more explicit (all dependencies visible in the constructor).
  • The fragile base class problem: inheriting from a class couples you to its implementation details, not just its interface. Any change to internal methods in the parent can silently break children.
  • Deep inheritance hierarchies (three or more levels) are almost always wrong. They make the effective interface hard to reason about and testing painful.
  • Mixins are a legitimate middle ground - they add orthogonal capabilities (serialisation, logging, validation) without imposing a type hierarchy - but they must always call super().__init__() to preserve the MRO chain.
  • Dependency injection is what makes composition practical: pass collaborating objects into __init__ rather than creating them internally. This makes every dependency explicit and mockable.
  • typing.Protocol enables structural typing - a class satisfies a Protocol by having the right methods, with no inheritance required. Use it for external code you cannot modify or to describe duck-typed interfaces for type checkers.
  • Inheritance is correct for true Liskov-substitutable hierarchies, framework hook patterns (Django CBVs), and the Template Method pattern. These are the exceptions, not the rule.

Graded Practice

Level 1 - Predict the Output

Question 1

class Engine:
def start(self):
print("Engine.start")

class Car(Engine):
def drive(self):
self.start()
print("Car.drive")

c = Car()
c.drive()
print(isinstance(c, Engine))
Show Answer
Engine.start
Car.drive
True

Car inherits from Engine, so self.start() resolves to Engine.start. isinstance(c, Engine) returns True because Car inherits from Engine. This is a has-a modelled as is-a - a design mistake. Car should contain an Engine instance, not inherit from it.

Question 2

class Logger:
def log(self, msg):
print(f"[LOG] {msg}")

class Service:
def __init__(self, logger):
self._logger = logger

def run(self):
self._logger.log("running")
print("Service.run")

svc = Service(Logger())
svc.run()
Show Answer
[LOG] running
Service.run

Service uses Logger via composition - Logger is injected into __init__ and stored as self._logger. When run() is called, it delegates the logging call to the Logger instance. This is the delegation pattern working correctly.

Question 3

class JsonMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)

class Model(JsonMixin):
def __init__(self, name, value):
self.name = name
self.value = value

m = Model("speed", 42)
print(m.to_json())
print(isinstance(m, JsonMixin))
Show Answer
{"name": "speed", "value": 42}
True

JsonMixin.to_json() serialises self.__dict__, which for Model instances contains name and value. isinstance(m, JsonMixin) is True because Model inherits from JsonMixin. This is a correct use of a mixin - adding serialisation capability without claiming a meaningful type relationship.

Question 4

class Base:
def process(self, data):
raise NotImplementedError

class Child(Base):
def process(self, data):
return [x * 2 for x in data]

c = Child()
print(c.process([1, 2, 3]))
print(isinstance(c, Base))
Show Answer
[2, 4, 6]
True

Child.process overrides Base.process (which would raise NotImplementedError). The NotImplementedError pattern signals that composition or an ABC would be cleaner here. isinstance(c, Base) is True due to inheritance.

Question 5

class MockLogger:
def __init__(self):
self.messages = []

def log(self, msg):
self.messages.append(msg)

class Worker:
def __init__(self, logger):
self._logger = logger

def do_work(self):
self._logger.log("started")
self._logger.log("done")

mock = MockLogger()
w = Worker(mock)
w.do_work()
print(len(mock.messages))
print(mock.messages[0])
Show Answer
2
started

Worker uses constructor injection - logger is injected at creation time. MockLogger captures calls in a list rather than printing them, which is the standard testing pattern. After do_work(), mock.messages contains two entries: "started" and "done".

Level 2 - Debug Challenge

The following code was written to create a UserReport that can format output. The developer used inheritance but the design has a structural problem that will cause pain as the codebase grows. Identify the issue and refactor to composition.

class StringUtils:
def to_uppercase(self, s: str) -> str:
return s.upper()

def to_slug(self, s: str) -> str:
return s.lower().replace(" ", "-")

def truncate(self, s: str, max_len: int) -> str:
return s[:max_len] + "..." if len(s) > max_len else s


class UserReport(StringUtils):
def __init__(self, user: dict):
self.user = user

def title(self) -> str:
return self.to_uppercase(self.user["name"])

def slug(self) -> str:
return self.to_slug(self.user["name"])

def summary(self) -> str:
return self.truncate(self.user.get("bio", ""), 100)


report = UserReport({"name": "Alice Smith", "bio": "A long bio here..."})
print(report.title())
print(report.slug())
Show Answer

The problem: UserReport inherits from StringUtils purely for code reuse. A UserReport is not a StringUtils - the is-a test fails immediately. This exposes all of StringUtils's methods on UserReport objects, makes isinstance(report, StringUtils) return True (misleading), and couples UserReport to StringUtils's implementation details. If StringUtils adds a method with the same name as one added to UserReport, there will be a silent collision.

Refactored to composition:

class StringUtils:
def to_uppercase(self, s: str) -> str:
return s.upper()

def to_slug(self, s: str) -> str:
return s.lower().replace(" ", "-")

def truncate(self, s: str, max_len: int) -> str:
return s[:max_len] + "..." if len(s) > max_len else s


class UserReport:
def __init__(self, user: dict, formatter: StringUtils):
self.user = user
self._fmt = formatter # composed, not inherited

def title(self) -> str:
return self._fmt.to_uppercase(self.user["name"])

def slug(self) -> str:
return self._fmt.to_slug(self.user["name"])

def summary(self) -> str:
return self._fmt.truncate(self.user.get("bio", ""), 100)


report = UserReport(
user={"name": "Alice Smith", "bio": "A long bio here..."},
formatter=StringUtils()
)
print(report.title()) # ALICE SMITH
print(report.slug()) # alice-smith

Now UserReport and StringUtils are decoupled. You can inject a different formatter (e.g., HtmlFormatter) without changing UserReport. isinstance(report, StringUtils) correctly returns False.

Level 3 - Design Challenge

You are building a notification system. Notifications can be sent via email, SMS, and Slack. A NotificationService should support sending through one or more channels and logging every send attempt.

Design this system using composition and dependency injection. Requirements:

  • Each channel is a separate component (not a subclass of another channel)
  • NotificationService accepts channels at construction time
  • A Logger is injected, not hardcoded
  • Adding a new channel (e.g., WebhookChannel) requires zero changes to NotificationService
  • You can test NotificationService without sending real notifications
Show Answer
from typing import Protocol, List


# Structural interface for any notification channel
class NotificationChannel(Protocol):
def send(self, recipient: str, message: str) -> bool: ...


# Structural interface for logging
class Logger(Protocol):
def log(self, message: str) -> None: ...


# Concrete channel implementations - no inheritance between them
class EmailChannel:
def __init__(self, smtp_host: str):
self._smtp_host = smtp_host

def send(self, recipient: str, message: str) -> bool:
print(f"[EMAIL via {self._smtp_host}] To: {recipient} | {message}")
return True


class SmsChannel:
def __init__(self, api_key: str):
self._api_key = api_key

def send(self, recipient: str, message: str) -> bool:
print(f"[SMS] To: {recipient} | {message}")
return True


class SlackChannel:
def __init__(self, webhook_url: str):
self._webhook_url = webhook_url

def send(self, recipient: str, message: str) -> bool:
print(f"[SLACK] To: {recipient} | {message}")
return True


# Future channel - zero changes needed to NotificationService
class WebhookChannel:
def __init__(self, url: str):
self._url = url

def send(self, recipient: str, message: str) -> bool:
print(f"[WEBHOOK -> {self._url}] To: {recipient} | {message}")
return True


# Concrete logger
class ConsoleLogger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")


# Mock implementations for testing
class MockChannel:
def __init__(self):
self.calls: list = []

def send(self, recipient: str, message: str) -> bool:
self.calls.append({"recipient": recipient, "message": message})
return True


class MockLogger:
def __init__(self):
self.entries: list = []

def log(self, message: str) -> None:
self.entries.append(message)


# NotificationService - composed, not inherited
class NotificationService:
def __init__(self, channels: List[NotificationChannel], logger: Logger):
self._channels = channels # all dependencies injected
self._logger = logger

def notify(self, recipient: str, message: str) -> dict:
results = {}
for channel in self._channels:
channel_name = type(channel).__name__
self._logger.log(f"Sending via {channel_name} to {recipient}")
success = channel.send(recipient, message)
results[channel_name] = success
self._logger.log(f"{channel_name}: {'ok' if success else 'failed'}")
return results


# Production wiring
service = NotificationService(
channels=[
EmailChannel(smtp_host="smtp.example.com"),
SlackChannel(webhook_url="https://hooks.slack.com/xxx"),
],
logger=ConsoleLogger(),
)
service.notify("[email protected]", "Your order has shipped!")

# Test without real channels or real logger
mock_email = MockChannel()
mock_slack = MockChannel()
mock_log = MockLogger()

test_service = NotificationService(
channels=[mock_email, mock_slack],
logger=mock_log,
)
results = test_service.notify("[email protected]", "Test message")

assert len(mock_email.calls) == 1
assert mock_email.calls[0]["recipient"] == "[email protected]"
assert len(mock_slack.calls) == 1
assert len(mock_log.entries) == 4 # 2 channels x 2 log entries each
print("All tests passed")

Key design decisions:

  • NotificationChannel and Logger are Protocols - channels do not inherit from each other
  • NotificationService.__init__ accepts a list of channels and a logger - pure constructor injection
  • Adding WebhookChannel requires zero changes to NotificationService (Open/Closed Principle)
  • Tests use mock objects injected directly - no patching, no network, no SMTP server

What's Next

Lesson 08 covers Method Resolution Order (MRO) and the C3 linearisation algorithm in depth - the mechanism that determines which method is called when multiple inheritance is in play. The super() call you saw in mixin examples does not simply "call the parent" - it traverses the MRO, and understanding that chain precisely is essential for writing correct mixin-based code.

© 2026 EngineersOfAI. All rights reserved.