Single Responsibility in OOP and Composition Versus Inheritance

Lesson goals
  • 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?

  1. It is hard to describe it in one sentence without “and also”.
  2. It has groups of Attributes where each group is used only by part of the methods.
  3. It changes for different reasons: a change in the receipt design, and a change in the tax rules, and a change in sending email.
  4. 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 how
Analogy: a computer

A 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

InheritanceComposition
Relationshipis-a (“is a kind of”)has-a (“has a”)
ExampleManager(Employee)Car holds an Engine
CouplingTight. The Child depends on all the details of the ParentLoose. You talk only through the part’s interface
FlexibilityDecided when the Class is writtenYou can replace a part at runtime
When something changes in the Parent/the partIt may break all the childrenIt 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.16

Breaking down the code line by line

ClassOne responsibilityOne reason for change
CartManaging the itemsThe cart’s rules (quantities, limits)
TaxCalculatorCalculating taxA change in the tax law
ReceiptFormatterFormatting a receipt as textA change in the receipt design
CheckoutConnecting the partsA change in the checkout process
  • Checkout is 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: 48

Compare 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:

  1. How many different responsibilities are there here? Write each one in words.
  2. 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
  1. 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.
  2. A split:
ClassResponsibilityMethods
CartThe itemsadd_item, subtotal
VatCalculatorThe taxadd_vat
ReceiptFormatterThe formattingformat_receipt
EmailSenderThe sendingsend_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:

  1. Cart — with add that receives a name, a price and a quantity (default 1), and subtotal that returns the cart’s total.
  2. IsraeliVat — with apply that receives an amount and returns it including 17% VAT.
  3. Checkout — receives a cart and a tax calculator in __init__ and holds them (has-a).
  4. 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.6
Show 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.6

Checkout 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:

  1. A new Class named TaxExempt, with apply that returns the amount unchanged.
  2. Create one checkout with IsraeliVat and a second checkout with TaxExempt, on the same cart, and print the total of both.
  3. Check: you did not change a single line in Checkout.

The desired output:

93.6
80
Show 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
80

This 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 = model

The task:

  1. Why is the inheritance here wrong, even though the code works? Use the “is a kind of” test.
  2. Rewrite: Engine receives horsepower, and Car receives a model and an engine and holds it (has-a). Car has a start that uses the engine.

Test code:

car = Car("Mazda", Engine(150))
print(car.start())

The desired output:

Mazda: engine 150hp started
Show solution
  1. A car is not a kind of engine — a car has an engine. They inherited only to “get” start, and now Car includes everything an engine has, and you cannot replace the engine.
  2. 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 started

Exercise 5: A computer from parts

What you build: a computer made of a processor, memory and a disk.

What to write:

  1. CPU — with a number of cores and a speed in GHz.
  2. RAM — with a size in GB.
  3. Disk — with a size in GB.
  4. Computer — receives the three parts and holds them. It has a __str__ that describes the computer as in the output, and can_run that receives a minimum of cores and a minimum of memory and returns True or False.

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? False
Show 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? False

There 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.

Official documentation