What is it? #
The Single Responsibility Principle says a class should have one reason to change.
Note the wording. It is not "a class should do one thing" — that is too vague, since everything can be described as one thing at some level. It is about reasons to change: who would ask for a modification, and why.
If the finance team can request a change, the email team can request a change, and the database team can request a change, all to the same class, then that class has three responsibilities.
The cost of ignoring it is coupling. A change to the invoice format risks breaking the email sending, because both live in the same file and share state.
Think of it like this #
A restaurant where one person takes orders, cooks, serves and handles the accounts. It works while there are three customers.
At thirty customers, changing the menu disrupts the accounting, and the person cannot be replaced or trained in one area without touching everything. Splitting the roles is not bureaucracy — it is what makes change survivable.
Simple example #
An Invoice class that calculates totals, formats a PDF, emails it, and saves it to the database. Four different teams can request changes to it, and each change risks the others.
Code #
# ---------- BAD: four responsibilities in one class ----------
class Invoice:
def __init__(self, customer, lines):
self.customer = customer
self.lines = lines
def total(self): # finance rules
subtotal = sum(q * p for _, q, p in self.lines)
return round(subtotal * 1.18, 2)
def to_pdf(self): # presentation
return f"PDF<{self.customer} owes {self.total()}>"
def email_to_customer(self, smtp): # messaging
smtp.send(self.customer.email, "Your invoice", self.to_pdf())
def save(self, connection): # persistence
connection.execute(
"INSERT INTO invoices (customer, total) VALUES (?, ?)",
(self.customer.email, self.total()),
)
What is wrong
- A tax rule change edits the same file as the PDF layout.
- Testing the total requires importing an SMTP library and a database driver.
- Switching from PDF to HTML touches the class that owns money calculations.
- Two teams editing it at once means merge conflicts every sprint.
- The class cannot be reused anywhere that lacks a database connection.
# ---------- BETTER: one reason to change per class ----------
class Invoice:
"""Only knows what an invoice is and what it costs."""
TAX_RATE = 0.18
def __init__(self, customer, lines):
self.customer = customer
self.lines = lines
def subtotal(self) -> float:
return round(sum(q * p for _, q, p in self.lines), 2)
def total(self) -> float:
return round(self.subtotal() * (1 + self.TAX_RATE), 2)
class InvoicePdfRenderer:
"""Changes when the layout changes."""
def render(self, invoice: Invoice) -> str:
return f"PDF<{invoice.customer.name} owes {invoice.total()}>"
class InvoiceRepository:
"""Changes when the storage changes."""
def __init__(self, connection):
self.connection = connection
def save(self, invoice: Invoice) -> None:
with self.connection:
self.connection.execute(
"INSERT INTO invoices (customer, total) VALUES (?, ?)",
(invoice.customer.email, invoice.total()),
)
class InvoiceMailer:
"""Changes when the messaging provider changes."""
def __init__(self, smtp, renderer: InvoicePdfRenderer):
self.smtp = smtp
self.renderer = renderer
def send(self, invoice: Invoice) -> None:
self.smtp.send(invoice.customer.email, "Your invoice", self.renderer.render(invoice))
# A thin coordinator ties them together
class IssueInvoice:
def __init__(self, repository, mailer):
self.repository = repository
self.mailer = mailer
def run(self, invoice: Invoice) -> None:
self.repository.save(invoice)
self.mailer.send(invoice)
How it works #
In the improved version, Invoice holds only the data and the money rules. Testing the tax calculation needs nothing but the class itself — no database, no SMTP server, no temporary files.
InvoicePdfRenderer owns presentation. Changing the layout, adding a logo or switching to HTML touches this class and nothing else.
InvoiceRepository owns storage. Moving from SQLite to PostgreSQL, or adding a second table, happens here.
InvoiceMailer owns messaging. Swapping SMTP for a transactional email API is one class.
IssueInvoice is a coordinator. It knows the sequence but none of the details, which makes the workflow readable in four lines.
Why this is better in concrete terms: each class can be tested alone, two teams can work in parallel without conflicts, and each piece can be reused — the renderer works in a preview endpoint, the repository works in a batch import.
The honest caveat is that this is more files. For a script that prints one invoice, the first version is fine. The split earns its keep when the code lives long enough for different people to change different parts.
Real-world use #
This principle is why layered architectures exist. Domain models hold rules, repositories hold storage, serializers hold formatting, services hold workflows. Each layer changes for its own reason.
The clearest real signal is your commit history. If one file appears in commits about pricing, about email templates and about database migrations, it has too many responsibilities.
Another signal is test setup. When testing one behaviour requires mocking four unrelated systems, the class under test is doing four things.
The counterweight matters: splitting too eagerly produces a maze of one-method classes. A good trigger is the second reason to change actually arriving, not being imagined.
Common mistakes #
- Reading it as "one method per class" and creating dozens of trivial classes.
- Splitting by technical type only, so one feature change still touches five folders.
- Leaving a coordinator that grows into a god class as the real logic moves out.
- Keeping storage code in domain models "just for now".
- Splitting before the second reason to change exists, adding indirection for nothing.
Practice #
Take a UserAccount class that validates a password, hashes it, saves the user, sends a welcome email and writes an audit log line. List the reasons to change for each behaviour, then split it into focused classes plus one coordinator. Finally, write a test for the password rules that needs no database and no email.