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.”
On Twitter, Chris Eidhof pointed to an example of taking the Single Responsibility Principle too far. Specifically, Chris was unhappy with the argument that Singletons violate the SRP because, besides their main responsibility, they also manage their own life cycle:
This argument against singletons made me cringe (specifically, the SRP point): https://t.co/C9wVVnqHFs
— Chris (@chriseidhof) June 29, 2017
This led to a lively discussion. Many reacted against “over-architecture.” No doubt they experienced fragmented code that grew from over-zealous attempts at SRP.
I think that SRP isn’t just over-applied. It’s fundamentally misunderstood, even misquoted. The repeated misquotes perpetuate that misunderstanding.
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, especially since it’s basically 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 to the 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. After our Twitter dialog, David Tanzer made 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 clearly 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 really 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 really 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 type A calling another method in type B, which in turn calls something in type 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 actually 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.”
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.
The next time you’re working 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 changes in code that’s 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 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 types of changes you’ve had to make to your code? Please share in the comments below! Real-life examples will help us all improve our SRP.