The Single Responsibility Principle is the most quoted rule in SOLID, and in my experience the most misread. Almost everyone repeats the same one-liner: a class should do one thing. That sounds obvious and wise, and it is quietly wrong. Take it literally and you end up splitting classes by verb count, arguing about whether "validate and save" is one thing or two, and feeling clever for having made twelve tiny classes where you had one.
The actual definition, the one Robert Martin settled on, is sharper: a class should have one reason to change. And a reason to change is not a task. It is a person. A responsibility belongs to some actor - a team, a role, a stakeholder - who can walk in and ask for the behaviour to be different. SRP says: don't let two of those people share a class.
That reframing changes everything about how you apply it, so let me show you where the naive version bites.
It always starts clean
Here is a class from a home-energy dashboard. It takes a set of meter readings and knows how to turn them into the three things the product needs: a cost, a friendly summary for the app, and a row for the data export.
class EnergyUsage:
def __init__(self, readings, tariff):
self.readings = readings
self.tariff = tariff
def _billable_kwh(self):
return sum(r.kwh for r in self.readings)
def cost(self):
return self._billable_kwh() * self.tariff.rate
def summary(self):
return f"You used {self._billable_kwh():.1f} kWh this month."
def to_export_row(self):
return [self._billable_kwh(), self.cost()]Nothing here is bad code. It reads well, it has no duplication, and that shared _billable_kwh helper feels like exactly the kind of tidy factoring we are taught to do. If you asked "does this class do one thing?" you could happily answer yes - it handles energy usage. That answer is the trap.
The exact moment it breaks
Three different people care about this class, and they don't know each other.
The billing team owns cost. The design team owns summary. The data platform team owns to_export_row, which feeds a regulatory report. Three actors, one class.
Now billing gets a new rule: customers with solar panels feed power back to the grid, and that feed-in should not be billed. So a billing engineer does the obvious thing and fixes it at the source:
def _billable_kwh(self):
return sum(r.kwh for r in self.readings if r.kwh > 0)Correct for billing. The cost drops, the customer is happy, the ticket closes. But _billable_kwh was shared, and to_export_row leaned on it. The export that goes to the regulator now quietly excludes every negative reading too, and the monthly figure it reports is wrong. Nobody touched the export code. Nobody reviewed the export code. It broke anyway, because a change owned by billing reached through a shared method into something owned by the data team.
This is the SRP violation, and notice what it is not. It is not "the class does too many things" in some aesthetic sense. It is that the class had more than one reason to change, and when one of those reasons arrived, it damaged the other. The naive "does one thing?" test sailed right past it.
The fix: split by who asks
Group the code by the actor who can request a change, and give each actor its own home.
def billable_kwh(readings):
return sum(r.kwh for r in readings if r.kwh > 0)
class BillingCalculator: # owned by billing
def __init__(self, tariff):
self.tariff = tariff
def cost(self, readings):
return billable_kwh(readings) * self.tariff.rate
class UsageReport: # owned by data platform
def to_export_row(self, readings):
total = sum(r.kwh for r in readings) # every reading, on purpose
return [total, self._raw_cost(readings)]The billing rule and the export rule now live apart. When billing decides feed-in isn't billable, it changes BillingCalculator and the export is untouched. When the data team is told the regulator wants gross usage, it changes UsageReport and billing is untouched. Each class answers to one actor, so each change lands in exactly one place and can't leak sideways.
The shared helper hasn't vanished - billable_kwh is still there for whoever genuinely wants billable kilowatt-hours. What changed is that the export stopped borrowing billing's definition of the number. They looked like the same calculation. They were never the same responsibility.
Why "one reason to change" is the better test
Changes stay contained. When each class has a single owner, a request from that owner touches one file. You stop getting the bug above, where a fix for one team silently corrupts another team's output through code they never read.
Reviews get the right eyes. A pull request to BillingCalculator is a billing change, full stop. The right people know to look, and nobody has to reason about whether your billing tweak also moved the export or the UI copy.
Tests get smaller. A class with one reason to change has one axis of behaviour to cover. You are not writing a test matrix that crosses billing rules with export formats with display strings, because those no longer live together. This is the same instinct behind pure functions: the fewer reasons a unit has to behave differently, the less you have to pin down to trust it.
Collaborators become swappable. Once billing logic sits in its own class, you can hand it to the code that needs it rather than hard-wiring it in. That is exactly the seam dependency injection relies on, and the two principles tend to show up together - SRP tells you where to cut, DI tells you how to reconnect the pieces.
When you don't need it
SRP is a response to a real force - two actors pulling one class in different directions - and if that force isn't present, splitting is just ceremony.
A script with one reader has one actor: you. A fifty-line data-munging script that you run and throw away doesn't have competing stakeholders. Carving it into Loader, Transformer and Writer classes buys you nothing but indirection.
Don't split by verb. A class with open, read and close is not violating SRP just because that is three methods. Those all serve the same actor for the same reason. SRP is about reasons to change, not method counts, and over-splitting on verbs is how you end up with the class-per-function sprawl that gives the principle a bad name.
Wait for the second actor to actually appear. If two behaviours happen to sit together today and only one person has ever asked to change either, leaving them together is fine. Split when a second owner shows up, not in anticipation of one who might. Merging back is harder than it sounds, so don't pre-fragment on a guess. Sometimes the honest move is a single guard clause and a comment, not a new class.
The takeaway
Forget "a class should do one thing." The word thing is doing no work and causing every argument. Ask instead: who can walk in and ask me to change this? If the answer names two different people - billing and the data team, design and finance, the API team and the reporting team - you have two responsibilities, and today's tidy shared helper is tomorrow's cross-team bug. One class, one reason to change, one person to answer to. That is the whole principle, and it is far more useful than counting things.

