Topic 8.11
Composition over Inheritance and Good Class Design
In one line
Composition builds an object out of other objects it holds (has-a), while inheritance says one class is a kind of another (is-a). Passing collaborators in through parameters, keeping each class to one job, and using a plain function or dataclass when a class is overkill produce code that is easy to test and change.
Think of it like this
A toy robot kit. You do not build a new robot by melting an old robot and reshaping it (inheritance). You snap together parts: a battery, a motor, a head. Want a quieter motor? Swap that one part. A robot has a motor; it is not a kind of motor. Composition means building objects from parts they hold.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Composition
- Building an object out of other objects it holds as attributes (a has-a relationship).
- Is-a / has-a
- Two relationships: Dog is an Animal (inheritance), Car has an Engine (composition).
- Dependency
- Another object or function that a class needs in order to do its job.
- Dependency injection
- Passing dependencies into a class from outside, usually through
__init__, instead of creating them inside. - Single responsibility
- A class should have one job and one reason to change.
- Coupling
- How strongly one piece of code depends on another. Less is better.
- Strategy
- A design pattern where behaviour is chosen by passing in a replaceable part. In Python often just a function.
- Design pattern
- A named, reusable solution to a common design problem.
Step by step
01Is-a or has-a?
Say the sentence out loud. "A Dog is an Animal" is true, so inheritance can fit. "A Car is an Engine" is false: a car has one. Wrong inheritance gives the car engine methods it should not have, and ties the two classes together forever.
02Hold the part, inject it
Car.__init__(self, engine) stores whatever engine it is given. Car(Engine()), Car(ElectricMotor()) and Car(FakeEngine()) all work, because Car only calls engine.start(). The three engine classes share no parent.
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
return "car: " + self.engine.start()03The hard-wired version
HardCar creates Engine() inside __init__. It looks simpler, but you cannot give it another engine, and a test cannot avoid the real one. Whenever you see a class creating its own helpers inside, ask if the helper should be a parameter.
04Not everything needs a class
total_price(prices, tax_rate) is just a function: it has inputs and an output and keeps no state. Wrapping it in class PriceCalculator adds ceremony and no benefit. A class pays off when several functions share data that must stay consistent.
Behaviour that varies can be a function argument: apply_discount(200, lambda p: p * 0.9). Here lambda p: p * 0.9 is a tiny function with no name (Topic 5.6). Passing it in is the Strategy pattern, in one parameter.
05One job per class
Checkout coordinates. PriceCalculator prices. Receipt formats. To change how totals are computed, you replace one small class. Checkout runs with FlatTen and the output changes, with no edit to Checkout.
06Open for new parts
Alerts([send_email, send_sms]) takes a list of senders. Adding a Slack sender means adding one function and one list item. The Alerts class is not edited. That is the open/closed principle, achieved by composition.
07When inheritance is right
Use it for a true is-a with shared behaviour, for framework base classes you are asked to extend, and for small mixins (Topic 8.6). Keep hierarchies shallow, keep parents stable, and prefer composition for everything else.
Try it yourself
- 1
Add a part without editing
Add
class HybridEnginewith astartmethod and buildCar(HybridEngine()). Check that you edited neitherCarnor the other engines. - 2
Turn a class into a function
Rewrite
PriceCalculatoras a plain functionprice(order)and giveCheckoutthe function instead. Call it asself.calculator(order). Which version reads better? - 3
Find the hidden dependency
Look at
HardCar. List what you would have to change to test it with a fake engine. Compare withCar, where the answer is nothing.
Code & diagrams
Add a new engine class and a lambda discount rule, and use them without changing Car or apply_discount.
Expected output
car: engine running
car: silent motor running
car: fake started
engine running
177.0
180.0 150
['email:disk full', 'sms:disk full']Swap in FlatTen to change pricing without touching Checkout. A dataclass carries the data.
Expected output
Items: 3, total: 35
Items: 3, total: 10Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Inherit when it is not an is-a
Make Stack inherit from list and expose unwanted methods.
class Stack(list):
def push(self, x):
self.append(x)
s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99)
print(s)Myth vs fact
Myth
Good object-oriented code means many classes and deep hierarchies.
Fact
Good design means small, clear parts. Python programmers use plain functions, dataclasses and modules wherever a class would add nothing.
Myth
Dependency injection needs a framework.
Fact
In Python it is passing an argument to __init__ or to a function. No framework is required.
Myth
Composition is slower than inheritance.
Fact
The cost of one extra attribute lookup is tiny. The real cost to worry about is code that is hard to change and test.
Myth
SOLID is a set of strict laws.
Fact
They are guidelines that name common problems. Python's dynamic features mean you often meet them with simpler tools than in Java.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer small protocols (Topic 8.10) for the parts you inject. A one-method
Protocolis trivial to fake, and type checkers verify that real and fake objects both fit. - ▸
Use a composition root: build the real objects in one place (usually
main()), wire them together, and pass them down. The rest of the code never constructs its own dependencies, so tests can build a different wiring. - ▸
Mutable module-level objects are Python's singletons, but they are global state. Passing the object explicitly is more testable, even when a global would be shorter.
- ▸
For AI applications keep model clients, retrievers, clocks and random generators as injected parts. That makes agent logic deterministic in tests, and lets you record and replay model calls without network access.
Remember this
- 1
Two questions decide the design. Is-a: is every
Dogtruly anAnimal, in every way the program uses? Then inheritance fits. Has-a: does aCaruse anEngine? Then the car holds an engine in an attribute. ACar(Engine)class would claim a car is an engine, and it would inherit methods likestartthat mean something different. When in doubt, choose has-a. - 2
Dependency injection is a grand name for a simple habit: pass the parts a class needs **in through
__init__** instead of creating them inside.Car(engine)lets you give it a petrol engine, an electric motor or a fake for tests.HardCarthat builds its ownEngine()inside cannot be changed or tested without editing it. - 3
This is exactly how AI code stays testable. A
CheckoutorAgentobject receives a model client, a clock or a price calculator as arguments. In production you pass real ones. In tests you pass fakes (Topic 8.10). The class never needs to know which it got, as long as the methods match. - 4
The SOLID principles, in plain Python terms. Single responsibility: a class has one reason to change (the example splits pricing from receipt printing). Open/closed: add behaviour by adding a new part, not by editing old code (
Alertsaccepts any list of senders). Liskov substitution: a subclass must work wherever its parent does. Interface segregation: prefer small protocols (one or two methods) to big ones. Dependency inversion: depend on a shape (a protocol), not on a concrete class. - 5
Often the best class is no class. If you need to group three values, use a dataclass (Topic 8.8). If a class has one method besides
__init__, it is usually a function that wants to be written as a function:total_price(prices, tax_rate). If you need to vary a step, pass a function (apply_discount(price, rule)). Python treats functions as values, so many patterns that need class hierarchies in Java are a single parameter here. - 6
Signs of bad class design: a class named
ManagerorHelperthat does everything; a method that needs ten attributes from another object (the logic belongs there); a hierarchy three levels deep that nobody can trace; subclasses that disable parent methods; classes holding only static methods (use a module instead). - 7
The famous Gang of Four design patterns (Strategy, Observer, Factory and the rest) are names for recurring shapes of composition. The System Design course explains them with diagrams in the Design Patterns topics at
/system-design. In Python many are one line: Strategy is passing a function, Singleton is a module-level object, Iterator is a generator.
Explain it without notes
What is the difference between inheritance and composition, and how do you choose?
What is dependency injection, and how do you do it in Python?
Explain the single-responsibility and open/closed principles with a Python example.
When is a function or dataclass better than a class?
Practice
Write a Logger class that takes a write function in __init__ and has info(text) that calls write("INFO: " + text). Create one that prints and one that collects into a list.
Write a Player that HAS a Weapon (given to __init__), with attack() returning the weapon's use(). Use two weapon classes.
Rewrite this class as a function: class Greeter: def __init__(self, g): self.g = g and def greet(self, name): return self.g + ", " + name. Keep the same behaviour using functools.partial (Topic 9.5: it fixes some arguments of a function in advance) or a plain function.
Trade-offs
- ↔
Composition and injection cost a little more wiring, and give flexibility and testability. You write the connecting code once, in one place.
- ↔
Splitting into many small classes avoids giant ones, but too many tiny classes scatter simple logic. Split when there is a real reason to change or to replace a part, not by habit.
Done when you can
Done when you can decide between is-a and has-a by saying the sentence aloud.
Done when you can write a class that receives its collaborators through
__init__.Done when you can explain single responsibility and open/closed with an example.
Done when you can turn a one-method class into a function or a dataclass.
Done when you can name where design patterns are explained in the System Design course (
/system-design).