Single Responsibility in OOP and Composition Versus Inheritance
- Recognize a Class that has more than one responsibility, and break it apart
- Explain what Composition is and build an object made of other objects
- Tell apart has-a and is-a and choose between Composition and Inheritance
- Understand why “Composition over Inheritance” is an accepted rule of thumb
What will we learn?
- The Single Responsibility Principle (SRP) at the level of a Class.
- Composition: an object made of other objects.
- Composition versus Inheritance: how to choose.
Why do we need this?
In the clean code topic we learned SRP for functions: every function does one thing. Exactly the same principle applies to Classes, and there it is even more important, because a big Class is a “pile” of data and behavior. When every change (in the receipt format, in the payment method, in the VAT calculation) touches the same Class, it becomes fragile and scary to change.
And in the lesson on inheritance we saw that a car should not inherit from an engine. Today we will see what you do do.
Explanation from the basics
SRP at the level of a Class
Every Class should have one reason to change (Responsibility = “a reason for change”).
How do you recognize a Class with too much responsibility?
- It is hard to describe it in one sentence without “and also”.
- It has groups of Attributes where each group is used only by part of the methods.
- It changes for different reasons: a change in the receipt design, and a change in the tax rules, and a change in sending email.
- Its name is too general:
Manager,Handler,Processor,Utils,System.
Composition — “has a” (has-a)
Composition = building a complex object out of other objects, each one responsible for its own part. The complex object holds them as Attributes, and delegates work to them.
class Car:
def __init__(self):
self.engine = Engine() # the car HAS an engine
self.gps = GPS() # the car HAS a GPS
def start(self):
return self.engine.start() # delegate the work to the part that knows howA computer is made of a processor, memory, a disk and a graphics card (remember the topic on the computer and the operating system!). The computer does not “inherit” from the processor. It has a processor. Every component is responsible for one job, and you can replace a component (a bigger disk) without changing the rest. That is Composition.
Composition versus Inheritance
| Inheritance | Composition | |
|---|---|---|
| Relationship | is-a (“is a kind of”) | has-a (“has a”) |
| Example | Manager(Employee) | Car holds an Engine |
| Coupling | Tight. The Child depends on all the details of the Parent | Loose. You talk only through the part’s interface |
| Flexibility | Decided when the Class is written | You can replace a part at runtime |
| When something changes in the Parent/the part | It may break all the children | It affects only whoever uses the interface |
An accepted rule of thumb in the industry: “Favor Composition over Inheritance” — prefer Composition. Use inheritance only when there is a real is-a relationship, and when you want Polymorphism.
The simple test:
- “X is a kind of Y”? → inheritance.
- “X has a Y”? / “X uses a Y”? → Composition.
Syntax
class Part:
def do_work(self):
return "part did its job"
class Whole:
def __init__(self, part):
self.part = part # composition: hold a part object
def work(self):
return self.part.do_work() # delegation: let the part do it
whole = Whole(Part()) # the part is passed in (can be swapped)When the Part is passed as a parameter (and not created inside), you can pass different parts, and that is the place where Composition and Polymorphism meet.
First code example
A God Class before breaking it apart:
class ShopSystem:
def __init__(self):
self.items = []
self.vat = 0.17
self.customer_email = ""
def add_item(self, name, price, quantity):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for name, price, quantity in self.items)
def total_with_vat(self):
return round(self.subtotal() * (1 + self.vat), 2)
def receipt_text(self):
lines = []
for name, price, quantity in self.items:
lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
lines.append("TOTAL: " + str(self.total_with_vat()))
return "\n".join(lines)
def send_receipt(self):
print("Sending email to", self.customer_email)
print(self.receipt_text())How many reasons for change are there here? (1) How the items in the cart are managed. (2) How tax is calculated. (3) What a receipt looks like. (4) How it is sent. Four responsibilities in one Class.
After breaking it apart with Composition. week-3/shop_composition.py:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
if price < 0 or quantity <= 0:
raise ValueError("invalid price or quantity")
self._items.append((name, price, quantity))
def items(self):
return list(self._items)
def subtotal(self):
return sum(price * quantity for name, price, quantity in self._items)
class TaxCalculator:
def __init__(self, rate):
self.rate = rate
def total(self, amount):
return round(amount * (1 + self.rate), 2)
class ReceiptFormatter:
def format(self, cart, total):
lines = []
for name, price, quantity in cart.items():
lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
lines.append("TOTAL: " + str(total))
return "\n".join(lines)
class Checkout:
def __init__(self, cart, tax, formatter):
self.cart = cart
self.tax = tax
self.formatter = formatter
def receipt(self):
total = self.tax.total(self.cart.subtotal())
return self.formatter.format(self.cart, total)
cart = Cart()
cart.add("Bread", 8, 2)
cart.add("Cheese", 32)
checkout = Checkout(cart, TaxCalculator(0.17), ReceiptFormatter())
print(checkout.receipt())Output:
Bread x2 = 16
Cheese x1 = 32
TOTAL: 56.16Breaking down the code line by line
| Class | One responsibility | One reason for change |
|---|---|---|
Cart | Managing the items | The cart’s rules (quantities, limits) |
TaxCalculator | Calculating tax | A change in the tax law |
ReceiptFormatter | Formatting a receipt as text | A change in the receipt design |
Checkout | Connecting the parts | A change in the checkout process |
Checkoutis made of a cart, a tax calculator and a formatter (Composition), and delegates the work to them:self.tax.total(...),self.formatter.format(...). It hardly calculates anything itself.- Every Class is small, clear, and can be tested on its own:
TaxCalculator(0.17).total(100)→117.0.
Another example
The power of Composition: replacing parts (Composition + Polymorphism). We add a different receipt formatter, and a tax calculator for an exempt business. Checkout does not change at all.
class HtmlReceiptFormatter:
def format(self, cart, total):
rows = ""
for name, price, quantity in cart.items():
rows = rows + "<li>" + name + " x" + str(quantity) + "</li>"
return "<ul>" + rows + "</ul><b>Total: " + str(total) + "</b>"
class TaxExempt:
def total(self, amount):
return amount
html_checkout = Checkout(cart, TaxCalculator(0.17), HtmlReceiptFormatter())
print(html_checkout.receipt())
exempt_checkout = Checkout(cart, TaxExempt(), ReceiptFormatter())
print(exempt_checkout.receipt())Output:
<ul><li>Bread x2</li><li>Cheese x1</li></ul><b>Total: 56.16</b>
Bread x2 = 16
Cheese x1 = 32
TOTAL: 48Compare with the God Class version: to support an HTML receipt and a tax-exempt business, we would have had to add ifs inside receipt_text and total_with_vat. Here every option is a part you swap.
Composition instead of inheritance — the classic example
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
self.running = False
def start(self):
self.running = True
return "Engine started (" + str(self.horsepower) + " hp)"
class Car:
def __init__(self, model, engine):
self.model = model
self.engine = engine
def start(self):
return self.model + ": " + self.engine.start()
def upgrade_engine(self, new_engine):
self.engine = new_engine
car = Car("Corolla", Engine(130))
print(car.start())
car.upgrade_engine(Engine(180))
print(car.start())Corolla: Engine started (130 hp)
Corolla: Engine started (180 hp)With inheritance (class Car(Engine)), you cannot “replace the engine”. The car is the engine. With Composition, you replace a part at runtime.
Common mistake
Mistake 1: inheritance in order to “get useful methods”.
class Order(list):
pass
order = Order()
order.append("pizza")
order.sort()
order.clear() # an order that can be "cleared", "reversed", "popped"...?Order got all 40+ methods of list, including ones that have no meaning for an order, and you cannot control the rules. The right way: Composition, self._items = [], and exposing only what an order needs (add, items).
Mistake 2: an “exposed” Composition — bypassing the complex object.
checkout.cart._items.append(("Free TV", -500, 1))Deep chaining that touches the inside of a part bypasses its rules. Talk to a part only through its public methods (checkout.cart.add(...)).
Mistake 3: over-splitting. 15 Classes of 3 lines for a problem that is solved with 2 clear Classes. SRP says “one reason for change”, not “one method per Class”. If two actions always change together, they are probably one responsibility.
Mistake 4: a “Composition” where the part creates itself inside, always the same. self.tax = TaxCalculator(0.17) inside __init__ works, but then you cannot replace it. Passing the part as a parameter (Checkout(cart, tax, formatter)) gives the flexibility.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
What you practice on this page: one responsibility per Class (SRP), Composition — a Class that holds other objects, replacing a part without changing the rest, and when not to use inheritance.
By the end of the page you will know how to:
- Recognize a Class that does too much and break it apart
- Build a Class made of other objects (has-a)
- Replace a component in an object without changing the code that uses it
- Tell apart “is a kind of” (inheritance) and “has a” (composition)
Exercise 1: A Class that does everything
What you do: design only — recognize a Class that does too much, and suggest a split.
The code:
class ShopSystem:
def add_item(self, name, price, quantity): ...
def subtotal(self): ...
def add_vat(self, amount): ...
def format_receipt(self): ...
def send_email(self, address): ...The task:
- How many different responsibilities are there here? Write each one in words.
- Suggest a split into several Classes: a name for each Class, and which methods move to it.
What to submit: the list of responsibilities, and a table of Classes and methods.
Show solution
- Four responsibilities: managing the cart (items and total), calculating tax, formatting a receipt, sending email. Each one is a different “reason to change” — this is a God Class.
- A split:
| Class | Responsibility | Methods |
|---|---|---|
Cart | The items | add_item, subtotal |
VatCalculator | The tax | add_vat |
ReceiptFormatter | The formatting | format_receipt |
EmailSender | The sending | send_email |
Exercise 2: Composition — the cart and the checkout
What you build: a shopping cart and a checkout — where the checkout is made of a cart and a tax calculator.
What to write:
Cart— withaddthat receives a name, a price and a quantity (default 1), andsubtotalthat returns the cart’s total.IsraeliVat— withapplythat receives an amount and returns it including 17% VAT.Checkout— receives a cart and a tax calculator in__init__and holds them (has-a).- In
Checkout:total— the cart’s total, after the tax calculator.
Test code:
cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())The desired output:
93.6Show solution
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def subtotal(self):
total = 0
for name, price, quantity in self._items:
total = total + price * quantity
return total
class IsraeliVat:
RATE = 0.17
def apply(self, amount):
return round(amount * (1 + IsraeliVat.RATE), 2)
class Checkout:
def __init__(self, cart, tax):
self.cart = cart
self.tax = tax
def total(self):
return self.tax.apply(self.cart.subtotal())
cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())93.6Checkout does not calculate by itself — it delegates (Delegation) to the cart and to the tax calculator, each of which is an expert in one thing.
Exercise 3: Replacing a part
What you build: continuing the checkout — replacing the tax calculator without changing Checkout.
What to write:
- A new Class named
TaxExempt, withapplythat returns the amount unchanged. - Create one checkout with
IsraeliVatand a second checkout withTaxExempt, on the same cart, and print the total of both. - Check: you did not change a single line in
Checkout.
The desired output:
93.6
80Show solution
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def subtotal(self):
total = 0
for name, price, quantity in self._items:
total = total + price * quantity
return total
class IsraeliVat:
RATE = 0.17
def apply(self, amount):
return round(amount * (1 + IsraeliVat.RATE), 2)
class TaxExempt:
def apply(self, amount):
return amount
class Checkout:
def __init__(self, cart, tax):
self.cart = cart
self.tax = tax
def total(self):
return self.tax.apply(self.cart.subtotal())
cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
print(Checkout(cart, IsraeliVat()).total())
print(Checkout(cart, TaxExempt()).total())93.6
80This is the power of Composition + Polymorphism: every tax calculator implements apply, and the checkout works with any of them.
Exercise 4: Inheriting to save code?
What you do: recognize wrong inheritance, and fix it with composition.
The code:
class Engine:
def start(self):
return "engine started"
class Car(Engine):
def __init__(self, model):
self.model = modelThe task:
- Why is the inheritance here wrong, even though the code works? Use the “is a kind of” test.
- Rewrite:
Enginereceives horsepower, andCarreceives a model and an engine and holds it (has-a).Carhas astartthat uses the engine.
Test code:
car = Car("Mazda", Engine(150))
print(car.start())The desired output:
Mazda: engine 150hp startedShow solution
- A car is not a kind of engine — a car has an engine. They inherited only to “get”
start, and nowCarincludes everything an engine has, and you cannot replace the engine. - has-a:
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return "engine " + str(self.horsepower) + "hp started"
class Car:
def __init__(self, model, engine):
self.model = model
self.engine = engine
def start(self):
return self.model + ": " + self.engine.start()
car = Car("Mazda", Engine(150))
print(car.start())Mazda: engine 150hp startedExercise 5: A computer from parts
What you build: a computer made of a processor, memory and a disk.
What to write:
CPU— with a number of cores and a speed in GHz.RAM— with a size in GB.Disk— with a size in GB.Computer— receives the three parts and holds them. It has a__str__that describes the computer as in the output, andcan_runthat receives a minimum of cores and a minimum of memory and returnsTrueorFalse.
Test code:
pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))The desired output:
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? FalseShow solution
class CPU:
def __init__(self, cores, ghz):
self.cores = cores
self.ghz = ghz
def __str__(self):
return str(self.cores) + "-core " + str(self.ghz) + "GHz CPU"
class RAM:
def __init__(self, gb):
self.gb = gb
def __str__(self):
return str(self.gb) + "GB RAM"
class Disk:
def __init__(self, gb):
self.gb = gb
def __str__(self):
return str(self.gb) + "GB SSD"
class Computer:
def __init__(self, cpu, ram, disk):
self.cpu = cpu
self.ram = ram
self.disk = disk
def can_run(self, min_cores, min_ram):
return self.cpu.cores >= min_cores and self.ram.gb >= min_ram
def __str__(self):
return str(self.cpu) + ", " + str(self.ram) + ", " + str(self.disk)
pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? FalseThere are enough cores (8 ≥ 6), but only 8GB of RAM out of the 16 required. Every part knows how to present itself, and Computer only connects them.
Summary
- SRP in a Class: one reason to change. A God Class (
...Manager,...System) → break it apart. - Composition = an object made of other objects (has-a), which delegates work to them.
- Inheritance only for a real is-a. For has-a or “uses-a” → Composition.
- Favor Composition over Inheritance: loose coupling, and parts that can be replaced.
- Pass parts as parameters to
__init__, so that they can be replaced (Composition + Polymorphism). - Talk to a part through its interface, not through its insides.