The “Single Responsibility Principle” (SRP) sounds so noble. But I’m afraid it’s misunderstood and misapplied. Ask your teammates: “What is the Single Responsibility Principle?” Go ahead, ask them. Then ask if the SRP is a good thing or a bad thing. I’d bet many of them will say something like this: “In principle, it’s a good idea. But in practice, it’s overkill.”
I think that SRP isn’t just over-applied. It’s fundamentally misunderstood, even misquoted. The repeated misquotes perpetuate that misunderstanding.
Improve your test writing “Flow.”
Sign up to get my test-oriented code snippets.
Let’s see if we can clear things up, and point to a better way.
Single Responsibility Principle as Commonly Quoted
What did your teammates answer when asked, “What is the Single Responsibility Principle?” Most of them probably said, “An object should have a single responsibility. That is, it should do one thing.”
This is understandable since it seems to match the name of the SRP. But what is “one thing”?
Some people, eager to apply “a principle,” keep drilling down further and further. We extract more and more types, pursuing some ideal of “one thing.” It’s almost like you don’t reach SRP until you can’t subdivide it any further.
That’s a path to pain. How do you recognize over-architecture? I notice it when:
- New members of a team feel lost in the codebase.
- I have no idea how to implement a feature while sticking to the team’s prescribed architecture.
- Changing an existing feature requires stepping through with a debugger just to figure out what does what.
What Is “One Thing” Anyway?
“One thing” is hard to put your finger on. David Tanzer makes it clearer in his SRP blog post:
You can describe everything a design element (method, class, module, …) does—at a reasonable level of abstraction—as a single, coherent thing.
The key is to focus on a specific level of abstraction and operate solely within that level.
The Extract Function Refactoring
Some confusion probably comes from the difference between extracting functions and extracting types or classes.
For functions, I try to practice Robert Martin’s advice. In Clean Code Episode 3, he says,
How big should a function be? Four lines is okay, maybe five. Six… okay. Ten is way too big.
How is that even possible? We can do this by constraining our functions to just one level of abstraction. Does anyone remember when we called helper functions “subroutines”? Helper routines live sub—below—the current level of abstraction.
When I extract methods, I’m starting with a bottom-up design. The original method has lots of details. These details may have multiple scopes—that is, it’s one detail followed by a separate detail. Or the details express multiple levels of abstraction. What I want is to end up with code that looks as if I had done top-down design. I want well-named things at a higher level, calling well-named things at a lower level.
But so far, we haven’t talked about objects. Where do types come in?
Types and Cohesion
A type is a collection of methods, together with data on which those methods operate. This brings us to an important concept: Cohesion.
Cohesion expresses “how closely related are these pieces of code?” In a type, the more each method uses the same properties, the more cohesive the type is.
Pick one of your larger types. Look at how many properties it has. Are they all related? I suspect that there are clusters. Are you looking at disjoint sets?
If you can look at a type’s properties, and identify a subset of them that that hang together well, you’re looking at a type that ought to be split up.
To fix that, apply the Extract Class refactoring. It’s described in the Refactoring book.
When Data Is Spread Too Thin
But what happens when people take Extract Class too far? We can see this in types that pass the same data deeper and deeper.
Let’s say we have a method in A calling another method in B, which in turn calls something in C:
We now want to add conditional code to C: perhaps an if statement, perhaps a switch statement. This condition is based on a new argument. But this new argument comes from A. The lazy way is to have B (and any other intermediaries) pass this argument along.
Depending on the new argument, I want different behavior. The naive approach, “Just add a conditional,” creates complexity. It’s easy to fall into this trap when we forget that there are ways to eliminate conditionals.
This example falls into a Code Smell called Middle Man. The Refactoring book includes a catalog of code smells. Each smell describes possible refactorings to consider. For Middle Man, these are:
- Remove Middle Man
- Inline Function
- Replace Superclass with Delegate
- Replace Subclass with Delegate
The important thing is that if code is decomposed to the point that the breakdown is interfering with work, don’t just complain. Change it!
Balancing Against Over-Architecture
Over-architecture is the result of taking a reasonable practice too far. But you usually don’t discover where “too far” is until you get there. That’s natural and okay. Once you notice, refactor in the other direction.
When we over-architect, we apply one principle, at the expense of other principles. We forget that there are other balancing forces.
This is where the Four Rules of Simple Design come in. They contain multiple forces, laid out in a clear path. Well-designed code has these characteristics. It…
- …Passes tests,
- …Expresses intent,
- …Has no duplication (DRY),
- …With the fewest elements.
These are in priority order. Notice how the last one pushes back against over-extraction. But you can’t plan these up-front. Design is a process of discovery.
Then How Should We Extract Types?
Code that is incorrectly decomposed creates a chain of reliance:
This brings us to another important concept that’s often discussed together with Cohesion: Coupling.
A depends on B, which in turn depends on C. Things are particularly bad when changes have to ripple from one type to the next.
Such design goes against agility because the code is resisting change.
How do we make code that is more accepting of change? By decomposing our types along lines of likely change. In his paper On the Criteria To Be Used in Decomposing Systems into Modules, David Parnas concludes:
It is almost always incorrect to begin the decomposition of a system into modules on the basis of a flowchart. We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others.
Single Responsibility Principle: One Reason to Change
This brings us back to the definition of the Single Responsibility Principle. Rather than pursuing a Platonic ideal that “a class should do one thing,” Robert Martin defines the SRP like this:
A module should have one and only one reason to change.
What are some possible axes of change? I can think of two:
- Business change. For example, “We need to change the layout of this screen.”
- Technological change. For example, “Facebook is shutting down Parse, so we will lose it as our backend service.”
Of course, any valid technological change has an underlying business need. But considering them separately will help us better imagine the possible changes.
Whatever the source of change, change is a given. The question is, how well does our code accommodate those changes? We can only be as agile as our code lets us be.
We can only be as agile as our code lets us be.
The next time you’re working on a new feature, ask yourself:
- What are ways this might change due to a business requirement?
- What are ways this might change due to a technological requirement?
Then ask, would our current modules hide these changes? How easy is it to isolate those changes?
If you’re making code changes that are spread too thin, ask yourself: “Can we collapse this unnecessarily spread-out decomposition? What’s the name of this code smell?” Then look up refactoring moves that will lead to a simpler design.
What we all need to learn is how to write code that “responds to change.” To me, that’s the heart of the SRP.
What are some examples of changes you’ve had to make to your code, whether business, technological or something else? Please share in the comments below! Real-life examples will help us all improve our SRP.
As for me all my programming experience leads me to the rule that “there always should be enough of this great technique” and “there are always some tradeoffs when following this rule”. No matter what technique or rule you are following.
I agree, Anton. All the guidelines help balance each other. I think the important thing is to remember that the techniques are means to an end, and to keep that end in mind. (…For me, it’s so that I and my teammates can maintain & modify code more easily.)