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 Eidhof (@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.
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 classes, 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:
“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.
Some confusion probably comes from the difference between extracting methods and extracting classes.
For methods, I try to practice Uncle Bob’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 classes come in?
A class 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 class, the more each method uses the same instance variables, the more cohesive the class is.
Pick one of your larger classes. Look at how many instance variables 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 class’s instance variables, and identify a subset of them that that hang together well, you’re looking at a class that ought to be split up.
To fix that, apply the Extract Class refactoring. It’s described in the Refactoring book.
But what happens when people take Extract Class too far? We can see this in classes that pass the same data deeper and deeper.
Let’s say we have a method in class A calling another method in class B, which in turn calls something in class 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:
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!
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…
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.
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 class 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 classes 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.
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,” Uncle Bob 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:
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:
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.
Please log in again. The login page will open in a new window. After logging in you can close it and return to this page.