Maintainable SRP
Most developers have heard of the Single Responsibility Principle (SRP). It is one of the principle pillars of SOLID and frequently touted as a core principle that all developers should strive for when designing their code. It is also one of the least understood principles I’ve ever seen. Most developers will agree that it’s really important, but these same developer will have a hard time defining SRP means in concrete terms. They have an even harder time translating the amorphous concept into their code.
This became painfully clear to me when we had a particularly bright 1 developer create a Rest API Client for our app 2. He was an enthusiastic proponent of SRP and sought to employ the principle in all of his code. Unfortunately, he left soon thereafter for a better job, because to this day, I do not understand what he wrote. His client spanned across 10 different classes and so fully conformed to the SRP principle that no one in our team ever managed to understand how all the pieces came together. Whenever it came to modifying the client to fix a bug or add functionality, that task became one of the few tasks no developer wanted to take up 3. In the end, I recreated the API client as a single class that contained one fifth the original code 4. The experience was eye opening to me. I thought I was a proponent of SRP until he showed exactly how far I hadn’t taken the concept.
Often, complicating a class is simpler than abstracting it out. Except, of course, when it’s not.
I realized that the problem with SRP is in the definition. The concept of “responsibility” is extremely ambiguous5. That developer I mentioned defined the concept of “responsibility” very differently than I did. He had a much more particular definition that required small variances in behavior to be split out into separate classes in order to maintain the SRP ideal. As an example, some of the APIs required authentication while others did not. This was, in his definition, separate responsibilities, and so he had different classes handle each responsibility exactly how SRP dictated it should be. However, there was much common functionality between these classes, so a third class produced functions 6 that could be used by the other two classes. That’s three classes working in concert just to allow a distinction of behavior for authenticated requests. On some level, it’s quite rational the way he worked through the problem, but it resulted in a complex arrangement of objects that was difficult for others to understand, much less debug. In contrast, the refactored API Client has but a single boolean flag to indicate when a call is authenticated. The class stored a separate variable with some additional headers for authorized requests and a separate function that validates and authenticates if necessary. It is extra state and logic and yet, at the end of the day, it constituted a drastically simpler solution. Often, complicating a class is simpler than abstracting out. Except, of course, when it’s not.
And theres the rub. The idea of “single responsibility” is a moving target. There’s no hard definition that determines when a class has become too large or too complex 7. SRP doesn’t help as much as one might think for the simple reason that what constitutes a “responsibility” is entirely up to the person writing the code. What makes the target “move” is that projects evolve and often there becomes a need for this definition of responsibility to shift and change. What was once simple becomes complex and requires refactoring. There’s a temptation to think that this kind of refactoring is a failure to plan or perhaps even a failure of SRP in general, gut in reality it’s more like a reality of real world projects that all developers must come to grips with.
Balance Simplicity
This is going to sound mundanely obvious, but large projects have a lot of objects while smaller projects have fewer objects. And, of course, large projects tend to be more complex than smaller projects. The point I’m making is that all projects start as a small project, no matter the end goal. Small projects evolve into large projects. As they evolve, the very definition of what constitutes a “single responsibility” will change. And as small simple classes necessarily expand their functionality to react to changing expectations in the project, they eventually require refactoring.
Everything should be made a simple as possible, but no simpler.
Albert Einstein
I’ve seen many developers attempt to forestall this by over engineering the project when it’s small. They try to create infrastructure by breaking small pieces of code into even smaller objects working in concert as if the project were already large. This rarely works out well. For one, it’s difficult, if not outright impossible, to actually predict where the project is headed. You end up with a lot of orphaned complexity, where some of the infrastructure is filled out, but much of it sits unused as small objects that only serve obfuscate the project. That might be ok, except it defeats the point of using SRP in the first place, which is to reduce technical debt and increase the project’s flexibility and maintainability. Having a large number of objects working in concert together can be more difficult to maintain than a single large object.
That is not to say there isn’t some minimal infrastructure all projects should have. And if you anticipate a project is going to be large, you would be well advised to use more infrastructure than less. But the point of SRP isn’t SRP. Sometimes I think proponents tend to forget that, thinking that it’s ok to split a single 400 line object into ten 50 line objects working in a complex dependency graph. Yes, you’ve adhered to SRP, but in the process you’ve succeeded in making your code more difficult to maintain, harder to debug and harder to understand.
In my opinion, utilizing SRP well involves balancing the needs of the project right now, with the changes you think might happen in the future. However, above all, strive for simplicity. I can’t overstate how important that is. Software development is already a complex, difficult task. There is profound truth to the maxim that you write once but read many times. Write your code such that you (and others) can easily understand it and modify it.
Maximize Understandability
As projects grow large, this very clear arch nemesis come to the fore: complexity. Because complexity grows in an exponential fashion, the ability of any single developer to understand the project diminishes and eventually becomes impossible. You may understand the whole project generally, or parts of the project specifically, but you cannot understand the whole project specifically. There’s simply too much for us to grasp, and no devotion to SRP will fix this fundamental problem.
This is where many large projects break down. A codebase without a proper structure requires you to understand the entire project in it’s all it’s glorious detail in order to understand any part specifically. Because you can’t know that a local change won’t have a global effect, you’re stuck either risking regressions or spending an inordinate amount of time chasing down dependencies. When large projects grind to a crawl, this is largely the reason why. It becomes harder and harder for developers to make simple changes, much less perform more extensive (and probably needed) refactors. Some developers attempt to mitigate the problem through extensive Unit Testing, but that’s only a bandaid solution. Unit Tests can test individual functionality, but are horrible at predicting how the entire system will function. Just because each individual piece is functionally rationally doesn’t mean the system as a whole is.
It’s at this point that the software architect’s role becomes paramount. It is their job to ensure the project is structured in such a way that it can be understood generally, and that specific parts can be understood specifically. This can only be done by creating a very clear, understandable architecture that separates out the areas of concern in the app into independent modules. This can be tricky to do, but there are some good guidelines:
- Create a simple dependency graph of the modules and resist the temptation to complicate that dependency graph when the lure to reuse code comes calling. Every exception you add makes the project harder to understand
- Treat modules as frameworks. If you can, separate them out into different repositories. This forces the modules to be independent.
- Separate UI and business logic. Hmmm… and then separate UI and business logic. And when you’re done, separate UI and business logic. Seriously, separate them. Dumb UI, smart business. Put them in different modules so that it’s impossible to mix them. Of all things, this is one area I see developers screw themselves over with again and again. I’m not sure why, but I think it’s because we’re given UI specs and work backwards from that. But whatever the reason, it causes a lot of problems.
- Try to create independently stacked dependencies. This isn’t always possible but really helps if you can do it. If
a
depends only onb
which depends only onc
andd
, then it’s easy to understand. And again whenx
depends ony
which depends onc
andd
as well, then we understand the graph easily and can anticipate how changes to one module affects the others. But ifa
also has a cross dependency onx
andy
, everything becomes muddled. When changes in one module can affect any other module, you loose much of the benefits of the architecture.
Aside from architecture, there’s a lot in simple development practices that can help:
- Document as much as possible, but only add context. Developers have a weird relationship with documentation. It gets in the way when you’re writing code but it’s water to a desert man when you’re trying to understand code you didn’t write. Yet, I can’t tell you often I see this:
var network //This is the networ variable
. That’s not documentation. It’s useless commenting. Explaining why something is there and how it’s being used is good documentation. Explaining what it is frequently is little more than the declaration itself. - A bunch of interdependent objects working as one are, in fact, one object. Either find a way to simplify the object or hide its complexity. But don’t delude yourself into thinking that because you’ve split everything into a hundred simple objects working together, you’ve done any real good.
- Use simple, clear interfaces to hide complex implementations where possible. The more complexity you can “hide”, the better.
- Use only explicit interfaces. Avoid implicit interfaces and behavior as much a possible. An obvious example is using
NotificationCenter
, where you observe some notification in another object you don’t own (I see this done with View Controllers all the time). But there are less obvious ones that seem innocuous, yet can create the same level of havoc. For example, changing the value of a shared persistent object knowing it will change the behavior of an observer in another part of the app. All of this is implicit and must be known in advance in order to properly work in the app. Don’t do this. Easy now often means hell later.
The point to all this is to make the project understandable, which directly translates into maintainable. If, on average, 80% of development time is spent maintaining a project, it makes a lot of sense to spend as much time making the project as maintainable as possible.
- And I mean it. This guy was intelligent. I loved having discussions with him. He and I would have long arguments about all kinds of programming topics ranging from the mundane to the technical. I miss having him on my team, though I admit I don’t miss his code. ↩︎
- The original API Client was “legacy”, and way overstuffed with functionality it was not supposed to be doing. Seriously, the api client was literally setting up Core Data… talk about doing someone else’s job... ↩︎
- Normally our developers are very proactive about finding tasks and doing them. ↩︎
- This isn’t completely fair… I also refactored it from objective-c into Swift, which is a much less verbose language. Still, the difference was dramatic. ↩︎
- I’m sorry, but I don’t find the definition of responsibility laid out by Robert Martin as helpful. He defines a responsibility as a “reason to change”. What is a “reason to change” is almost as ambiguous as the idea of responsibility. Moreover, all the examples I’ve seen always assume a static set of expectations and don’t address the evolution of a project over time. ↩︎
- Functions in objective-c are not first class. So really it produced
blocks
. Whatever, in the end it amounts to the same thing using different syntax. Thank god Swift allows functions to be first class citizens… makes so many things easier. ↩︎ - Ok, there’s some hard lines. At least I tend to draw a few hard lines in the sand that say “No”. I don’t care how small the class is, some things should not be mixed together. I always separate out UI code and business logic. Core services are always separated out (ex: persistence, api, logging, etc). ↩︎