Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
Single Responsibility Principle (SRP)
The Single Responsibility Principle as one reason to change per class: find who asks for changes, split a bill class by team, and stop before fragments.
What you will learn
- Define the Single Responsibility Principle in terms of reasons to change
- Identify who asks for each change before deciding where code belongs
- Split a class that serves several groups of people into cohesive parts
- Avoid over-splitting a design into fragments with no behaviour of their own
Before you start
On this page
The previous lesson measured a design by the changes it meets. The Single Responsibility Principle (SRP) turns that into a rule for one class: look at who will ask for its changes. If two different groups of people will, the class has two responsibilities, and a change made for one group can break what the other one relies on.
What the principle says
Robert C. Martin lists SRP first among five principles of class design, which are now known together by their initials as SOLID. He traces the set to an article he posted to the comp.object newsgroup in 1995. His one-line version is:
A class should have one, and only one, reason to change.
The hard part is the phrase “reason to change”. In a later essay Martin answers it: reasons to change are people. A class should answer to one group of people with one narrowly defined business function, so that a change requested by that group cannot break something another group depends on. He gives the principle a second wording, too:
Gather together the things that change for the same reasons. Separate those things that change for different reasons.
That is cohesion and coupling again, stated in terms of change requests: keep together what changes together, and keep apart what changes for different reasons. Martin describes SRP as his attempt, in the late 1990s, to pull Parnas’s criterion, the separation of concerns, coupling and cohesion into one principle.
Finding the reasons to change
Before you split anything, list the class’s public methods and ask of each one: who would ask for this to change, and whose work breaks if it goes wrong? Group the methods by the answer. One group means one responsibility.
Take an electricity bill. The tariff team decides the prices: a fixed charge, then slabs of units at rising prices. The customer-letters team decides what the printed bill says. The records team decides the format of the archive file that their reconciliation tools read. Three groups, three reasons to change, and here they all sit in one class.
A change for one team that breaks two others
ElectricityBill has one method per team, and all three call a shared helper, _units(). Then the tariff team asks
for the first 50 units of every month to be free. The quickest edit is in _units(), because amount() gets its
units from there, and that is what bill_v2.py does. Each team keeps one check of its own:
before/team_checks.py
"""Each team's own check, run against the bill before and after the tariff team's change."""
import bill_v1
import bill_v2
def checks(module, expected_amount):
bill = module.ElectricityBill(account=4417, previous=4120, current=4330, exported=30)
units_line, row = bill.letter().splitlines()[1], bill.archive_row()
return [
("tariff team", f"amount is {expected_amount}", bill.amount() == expected_amount, bill.amount()),
("letters team", "letter shows 180 units used", units_line == "Units used: 180", units_line),
("records team", "archive row has 180 units", row.split(",")[1] == "180", row),
]
for module, expected, title in [
(bill_v1, 750, "bill_v1.py, before the change"),
(bill_v2, 500, "bill_v2.py, after the tariff team's change"),
]:
print(title)
for team, check, ok, seen in checks(module, expected):
print(f" {team:<13} {check:<28} {'ok' if ok else f'FAILED, got {seen!r}'}") Output
bill_v1.py, before the change tariff team amount is 750 ok letters team letter shows 180 units used ok records team archive row has 180 units ok bill_v2.py, after the tariff team's change tariff team amount is 500 ok letters team letter shows 180 units used FAILED, got 'Units used: 130' records team archive row has 180 units FAILED, got '4417,130,500'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 team_checks.py
before/bill_v1.py
"""An electricity bill that three teams change, each for its own reasons. The version before any change."""
class ElectricityBill:
FIXED_CHARGE = 50
SLABS = [(100, 3), (100, 5), (None, 8)] # (units in the slab, price per unit); None: all the rest
def __init__(self, account, previous, current, exported):
self.account = account
self.previous = previous
self.current = current
self.exported = exported # units that rooftop solar sent back to the grid
def _units(self):
return self.current - self.previous - self.exported
def amount(self): # the tariff team asks for changes here
left, total = self._units(), self.FIXED_CHARGE
for size, price in self.SLABS:
used = left if size is None else min(left, size)
total += used * price
left -= used
return total
def letter(self): # the customer-letters team asks for changes here
return f"Account {self.account}\nUnits used: {self._units()}\nAmount due: {self.amount()}"
def archive_row(self): # the records team asks for changes here
return f"{self.account},{self._units()},{self.amount()}" before/bill_v2.py
"""The same bill after the tariff team's request: the first 50 units of every month are free."""
class ElectricityBill:
FIXED_CHARGE = 50
SLABS = [(100, 3), (100, 5), (None, 8)] # (units in the slab, price per unit); None: all the rest
def __init__(self, account, previous, current, exported):
self.account = account
self.previous = previous
self.current = current
self.exported = exported # units that rooftop solar sent back to the grid
def _units(self):
# The tariff change, made where amount() gets its units from.
return max(self.current - self.previous - self.exported - 50, 0)
def amount(self): # the tariff team asks for changes here
left, total = self._units(), self.FIXED_CHARGE
for size, price in self.SLABS:
used = left if size is None else min(left, size)
total += used * price
left -= used
return total
def letter(self): # the customer-letters team asks for changes here
return f"Account {self.account}\nUnits used: {self._units()}\nAmount due: {self.amount()}"
def archive_row(self): # the records team asks for changes here
return f"{self.account},{self._units()},{self.amount()}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The tariff team’s check passes: 180 units now cost 500 instead of 750. The other two teams never asked for anything, and both of their checks fail, because the letter and the archive row now say 130 units instead of the 180 that the meter counted. Nobody wrote a bug in the letters code or the records code. The class put three teams’ concerns behind one helper, so the change for one team reached the other two.
Common mistake
A shared private helper looks like good reuse, but _units() meant three things: the units to charge for, the
units to print and the units to archive. They happened to be equal until the tariff changed. When code serves
several groups of people, check whether it means the same thing to each of them before you share it.
Splitting by reason to change
Split the class so that each team’s changes land in one place:
tariff.pybelongs to the tariff team:Tariff.amount(units)knows the fixed charge, the slabs and, now, the free units.letter.pybelongs to the customer-letters team andarchive.pyto the records team.meter.pyholdsReading, a frozen data class that records what the meter counted. It is data that all three use, not a fourth responsibility tangled with theirs.
after/team_checks.py
"""The same three checks, with the tariff team's change made inside the tariff."""
from archive import archive_row
from letter import letter
from meter import Reading
from tariff import Tariff
reading = Reading(account=4417, previous=4120, current=4330, exported=30)
for tariff, expected, title in [
(Tariff(), 750, "Tariff(), before the change"),
(Tariff(free_units=50), 500, "Tariff(free_units=50), after the tariff team's change"),
]:
amount = tariff.amount(reading.units())
units_line, row = letter(reading, amount).splitlines()[1], archive_row(reading, amount)
print(title)
for team, check, ok, seen in [
("tariff team", f"amount is {expected}", amount == expected, amount),
("letters team", "letter shows 180 units used", units_line == "Units used: 180", units_line),
("records team", "archive row has 180 units", row.split(",")[1] == "180", row),
]:
print(f" {team:<13} {check:<28} {'ok' if ok else f'FAILED, got {seen!r}'}") Output
Tariff(), before the change tariff team amount is 750 ok letters team letter shows 180 units used ok records team archive row has 180 units ok Tariff(free_units=50), after the tariff team's change tariff team amount is 500 ok letters team letter shows 180 units used ok records team archive row has 180 units ok
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 team_checks.py
after/tariff.py
"""The tariff team's rules: what a number of units costs. Only tariff changes touch this file."""
class Tariff:
def __init__(self, fixed_charge=50, slabs=((100, 3), (100, 5), (None, 8)), free_units=0):
self.fixed_charge = fixed_charge
self.slabs = slabs # (units in the slab, price per unit); None: all the rest
self.free_units = free_units
def amount(self, units):
left, total = max(units - self.free_units, 0), self.fixed_charge
for size, price in self.slabs:
used = left if size is None else min(left, size)
total += used * price
left -= used
return total after/meter.py
"""A meter reading: what the meter counted. It changes only when the meter data changes."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Reading:
account: int
previous: int
current: int
exported: int # units that rooftop solar sent back to the grid
def units(self):
return self.current - self.previous - self.exported after/letter.py
"""The customer-letters team's layout."""
def letter(reading, amount):
return f"Account {reading.account}\nUnits used: {reading.units()}\nAmount due: {amount}" after/archive.py
"""The records team's archive format."""
def archive_row(reading, amount):
return f"{reading.account},{reading.units()},{amount}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The same request is now Tariff(free_units=50), a change inside the tariff team’s file, and all three checks pass.
The letter still shows the 180 metered units because it never asks the tariff for anything but an amount.
Who asks for changes, before and after the split
Text description of the diagram
The diagram has two parts, one above the other.
- Before: the tariff team, the customer-letters team and the records team all ask for changes to one class, ElectricityBill, whose methods amount(), letter() and archive_row() all call the same helper, _units().
- After: the tariff team asks for changes to tariff.py (Tariff.amount(units)), the customer-letters team to letter.py (letter(reading, amount)) and the records team to archive.py (archive_row(reading, amount)). letter.py and archive.py read the metered units from meter.py (Reading.units()), which none of the three teams' changes need to touch.
Two details of this split are worth noticing. letter.py and archive.py are plain functions: SRP is about the
reasons a module changes, and a function in a module of its own satisfies it as well as a class does. And the code
that puts the parts together, team_checks.py here, is the one place that knows about all of them; in a real
program it is the place where the program starts.
How far to split
If one responsibility per class is good, is one method per class better? Here are the tariff and the letter again,
cut into eight classes with a method or two each. They print the same letter. This program measures both versions
with Python’s ast module and follows one letter through each with sys.setprofile, which calls a function of
yours on every call and return:
oversplit/measure.py
"""Compares the three-part design (../after) with the eight-class one (parts.py) on the same job: one letter."""
import ast
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "after"))
sys.dont_write_bytecode = True
import parts
from letter import letter
from meter import Reading
from tariff import Tariff
def size(files):
"""(classes, functions and methods, non-blank lines) of some source files."""
classes = functions = lines = 0
for file in files:
source = file.read_text()
lines += sum(1 for line in source.splitlines() if line.strip())
for node in ast.walk(ast.parse(source)):
classes += isinstance(node, ast.ClassDef)
functions += isinstance(node, ast.FunctionDef)
return classes, functions, lines
def deepest_chain(job, files):
"""The most calls into `files` that are open at the same moment while `job` runs."""
names = {os.path.realpath(f) for f in files}
depth = deepest = 0
def watch(frame, event, arg):
nonlocal depth, deepest
if os.path.realpath(frame.f_code.co_filename) in names:
if event == "call":
depth += 1
deepest = max(deepest, depth)
elif event == "return":
depth -= 1
sys.setprofile(watch)
try:
result = job()
finally:
sys.setprofile(None)
return deepest, result
AFTER = HERE.parent / "after"
reading = Reading(account=4417, previous=4120, current=4330, exported=30)
designs = [
("three parts: tariff.py, letter.py", [AFTER / "tariff.py", AFTER / "letter.py"],
lambda: letter(reading, Tariff().amount(reading.units()))),
("eight classes: parts.py", [HERE / "parts.py"], lambda: parts.build().letter(reading)),
]
shared = AFTER / "meter.py"
letters = set()
print(f"{'design':<36}{'classes':>8}{'functions':>11}{'lines':>7}{'deepest chain':>15}")
for label, files, job in designs:
classes, functions, lines = size(files)
depth, text = deepest_chain(job, [*files, shared])
letters.add(text)
print(f"{label:<36}{classes:>8}{functions:>11}{lines:>7}{depth:>15}")
print(f"\nBoth print the same letter: {'yes' if len(letters) == 1 else 'NO'}")
print(letters.pop()) Output
design classes functions lines deepest chain three parts: tariff.py, letter.py 1 3 16 2 eight classes: parts.py 8 13 43 4 Both print the same letter: yes Account 4417 Units used: 180 Amount due: 750
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
oversplit/parts.py
"""The tariff and the letter again, split into a class per small step: eight classes, most with one method."""
class SlabTable:
def rows(self):
return ((100, 3), (100, 5), (None, 8))
class FixedCharge:
def value(self):
return 50
class FreeUnits:
def __init__(self, count=0):
self.count = count
def apply(self, units):
return max(units - self.count, 0)
class SlabWalker:
def __init__(self, table):
self.table = table
def charge(self, units):
left, total = units, 0
for size, price in self.table.rows():
used = left if size is None else min(left, size)
total += used * price
left -= used
return total
class AmountCalculator:
def __init__(self, fixed, free, walker):
self.fixed, self.free, self.walker = fixed, free, walker
def amount(self, units):
return self.fixed.value() + self.walker.charge(self.free.apply(units))
class UnitsLine:
def text(self, reading):
return f"Units used: {reading.units()}"
class AmountLine:
def text(self, amount):
return f"Amount due: {amount}"
class LetterAssembler:
def __init__(self, calculator, units_line, amount_line):
self.calculator, self.units_line, self.amount_line = calculator, units_line, amount_line
def letter(self, reading):
amount = self.calculator.amount(reading.units())
lines = [f"Account {reading.account}", self.units_line.text(reading), self.amount_line.text(amount)]
return "\n".join(lines)
def build():
calculator = AmountCalculator(FixedCharge(), FreeUnits(), SlabWalker(SlabTable()))
return LetterAssembler(calculator, UnitsLine(), AmountLine()) The eight-class version has more than twice the lines, and printing one letter goes four calls deep instead of two.
Worse, it has not separated anything that changes for different reasons. SlabTable, FixedCharge, FreeUnits,
SlabWalker and AmountCalculator all answer to the tariff team, so the tariff can no longer be read in one place:
each tariff request now starts with working out which of five classes it touches, and a new kind of rule may have to
edit several of them. That is the opposite failure: one responsibility spread over many classes, where SRP wanted one
class per responsibility.
One method per class is not SRP
SRP counts reasons to change, not methods. A class with six methods that the tariff team alone ever asks about has one responsibility. Split when two groups of people would ask for changes, or when a class’s tests need set-ups for unrelated features. Keep things together when they always change together, even if that makes a class longer.
SRP beyond classes
The same question works at every size:
- Functions: a function that parses, validates and saves does three jobs, and splitting it makes each easier to read and test. SRP’s own question, who asks for the change, matters most at the level of modules and classes.
- Packages: among Martin’s package principles is the Common Closure Principle, “Classes that change together are packaged together”, which is SRP one level up.
- Services: Martin points out that splitting a system into small services does not solve the problem by itself. A service can bundle code that changes for different reasons just as a class can.
In a design interview
When you propose classes, say whose rules each one holds: “the pricing rules belong to the business, the receipt layout to the product team, the archive format to whoever runs the reconciliation”. Naming the people makes your split easy to defend, and when the interviewer adds a requirement you can say at once which class it lands in.
Key takeaways
- SRP: a class (or module) should have one reason to change, and reasons to change are the groups of people who ask for changes.
- Find responsibilities by asking who would request a change to each method; group the methods by the answer.
- Shared helpers between groups are where one group’s change breaks another’s work; split them along the groups.
- Do not split further than the reasons to change: eight one-method classes for one team’s rules spread one responsibility over many files.
Exercise
Exercise · Medium · Python
Split a report card that serves two groups of people
ReportCard in report_card.py has two reasons to change. The exam board sets the grade boundaries, and the school office decides what the printed card looks like. Today both kinds of request edit the same method, so a new card layout can break the grading and new boundaries can break the card. Split the class so that each group's changes land in a class of its own:
GradeRules().grade(mark)returns the grade for a mark out of 100:"A"from 90,"B"from 75,"C"from 60,"D"from 40 and"F"below 40.CardLayout().render(name, rows, average)returns the printed card.rowsis a list of(subject, mark, grade)andaveragea number; the layout does no grading of its own.ReportCard(name, marks, rules=None, layout=None)uses aGradeRules()and aCardLayout()when none are given.rows()returns the(subject, mark, grade)triples in the order ofmarks,average()the mean mark, andtext()the card that the layout renders, exactly astext()prints it today.
For ReportCard("Ravi", {"Maths": 75, "History": 60}), text() returns:
Ravi
Maths 75 B
History 60 C
Average 67.5
The sample tests check the cards of six students, every grade boundary, and pass in rules and a layout of their own to show that each can change without the other.
Starter code · report_card.py
class ReportCard:
"""Grades a student's marks and lays out the printed card. The exam board and the school office both edit it."""
def __init__(self, name, marks):
self.name = name
self.marks = marks # {subject: mark out of 100}, in the order the card lists them
def text(self):
lines = [self.name]
for subject, mark in self.marks.items():
if mark >= 90:
grade = "A"
elif mark >= 75:
grade = "B"
elif mark >= 60:
grade = "C"
elif mark >= 40:
grade = "D"
else:
grade = "F"
lines.append(f" {subject:<10}{mark:>4} {grade}")
average = sum(self.marks.values()) / len(self.marks)
lines.append(f" Average {average:>5.1f}")
return "\n".join(lines) The sample tests · test_report_card.py
from report_card import CardLayout, GradeRules, ReportCard
STUDENTS = [
("Asha", {"Maths": 92, "Physics": 74}, "Asha\n Maths 92 A\n Physics 74 C\n Average 83.0"),
("Ravi", {"Maths": 75, "History": 60}, "Ravi\n Maths 75 B\n History 60 C\n Average 67.5"),
("Meena", {"Biology": 39, "Art": 40}, "Meena\n Biology 39 F\n Art 40 D\n Average 39.5"),
("Kabir", {"Music": 100}, "Kabir\n Music 100 A\n Average 100.0"),
("Zoya", {"Maths": 89, "Physics": 90, "Art": 59}, "Zoya\n Maths 89 B\n Physics 90 A\n Art 59 D\n Average 79.3"),
("Ira", {"History": 0, "Maths": 61}, "Ira\n History 0 F\n Maths 61 C\n Average 30.5"),
]
class PassFail:
def grade(self, mark):
return "P" if mark >= 40 else "F"
class CsvLayout:
def render(self, name, rows, average):
return "\n".join(f"{name},{subject},{mark},{grade}" for subject, mark, grade in rows)
def test_cards_unchanged():
"""prints the same cards as before for six students"""
for name, marks, expected in STUDENTS:
assert ReportCard(name, marks).text() == expected
def test_grade_boundaries():
"""GradeRules keeps the exam board's boundaries"""
rules = GradeRules()
marks = [100, 90, 89, 75, 74, 60, 59, 40, 39, 0]
assert [rules.grade(m) for m in marks] == ["A", "A", "B", "B", "C", "C", "D", "D", "F", "F"]
def test_rules_can_change_alone():
"""new grading rules change the grades, not the layout"""
card = ReportCard("Meena", {"Biology": 39, "Art": 40}, rules=PassFail())
assert card.text() == "Meena\n Biology 39 F\n Art 40 P\n Average 39.5"
def test_layout_can_change_alone():
"""a new layout uses the same grades"""
card = ReportCard("Ravi", {"Maths": 75, "History": 60}, layout=CsvLayout())
assert card.text() == "Ravi,Maths,75,B\nRavi,History,60,C"
def test_layout_needs_no_rules():
"""CardLayout lays out rows it is given, with no grading inside"""
assert CardLayout().render("Kabir", [("Music", 100, "X")], 100.0) == "Kabir\n Music 100 X\n Average 100.0"
def test_rows_and_average():
"""rows() pairs each mark with its grade; average() is the mean mark"""
card = ReportCard("Zoya", {"Maths": 89, "Art": 59})
assert card.rows() == [("Maths", 89, "B"), ("Art", 59, "D")]
assert card.average() == 74.0 A hint
Move the if/elif chain into GradeRules.grade() and the f-strings into CardLayout.render(). Then ReportCard.rows() becomes one list comprehension that calls self.rules.grade(mark), and text() hands self.rows() and self.average() to self.layout.render().
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- The Single Responsibility Principle (Robert C. Martin, The Clean Code Blog)
- The Principles of OOD (archived copy) (Robert C. Martin, via the Internet Archive)
- Solid Relevance (Robert C. Martin, The Clean Code Blog)
- On the Criteria To Be Used in Decomposing Systems into Modules (Communications of the ACM (copy at Eindhoven University of Technology))
- dataclasses, data classes (Python Software Foundation)
- sys.setprofile (The Python Standard Library) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress