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.Protocolenables 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
typingmodule 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:
- Is-a test: Does every instance of
Bremain a valid, fully-substitutable instance ofA? Can all ofA's methods be called onBwithout error or surprise? - Has-a test: Does
Bmerely use the functionality ofA, or containAas a component?
If the answer to question 1 is "no", or question 2 is "yes", use composition.
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.
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.
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}")
print(user.created_at) # datetime object
The Mixin Contract
Mixins must follow these rules to be safe:
- Always call
super().__init__()- ensures the full MRO chain is initialised correctly - Do not define
__init__unless necessary - the best mixins have no constructor state - Depend only on a documented interface - if the mixin requires
self.emailto exist, document that requirement explicitly - Name them with
Mixinsuffix -JsonSerializableMixin, notJsonSerializable, 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:
- Methods in the parent overridden to raise
NotImplementedErroror do nothing - Child classes that only use a subset of the parent's interface
- The same feature being needed by multiple, unrelated class hierarchies
- 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)
assert len(mock_sender.sent) == 1
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
ABC | Protocol | |
|---|---|---|
| Typing style | Nominal (must declare inheritance) | Structural (duck typing) |
| Inheritance required | Yes | No |
isinstance at runtime | Always works | Only with @runtime_checkable |
| Enforcement at class creation | Yes - missing @abstractmethod raises TypeError | No - checked only by type checkers (mypy, pyright) |
| Best for | Stable internal hierarchies where you control both sides | External 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:
ReportViewIS genuinely aView- 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:
- What is the is-a test, and what is the has-a test? How do you apply them to a design decision?
- What is the fragile base class problem, and how does composition avoid it?
- What three rules must every mixin follow to be safe?
- How do you implement delegation via
__getattr__, and when is explicit delegation preferable? - What is the difference between constructor injection, property injection, and method injection?
- What is the difference between
typing.Protocol(structural typing) andABC(nominal typing)? - When do you need
@runtime_checkableon a Protocol? - Why is Django's class-based view system a correct use of inheritance?
- What is the Template Method pattern and why does it justify inheritance?
Key Takeaways
- The is-a test is the gatekeeper for inheritance: if
Bis not fully substitutable forAin 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.Protocolenables 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)
NotificationServiceaccepts channels at construction time- A
Loggeris injected, not hardcoded - Adding a new channel (e.g., WebhookChannel) requires zero changes to
NotificationService - You can test
NotificationServicewithout 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(),
)
# 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,
)
assert len(mock_email.calls) == 1
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:
NotificationChannelandLoggerareProtocols - channels do not inherit from each otherNotificationService.__init__accepts a list of channels and a logger - pure constructor injection- Adding
WebhookChannelrequires zero changes toNotificationService(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.
