Another look at Single Responsibility

Background

In the last couple of years I participated in a number of job interviews. I always ask about Single Responsibility Principle(SRP from now on). And most of people literally do not know anything about it. Even those who can tell the definition of it, can’t describe how they operate this principle in their day to day programming life. Can’t tell how does it affect the code that they write or their reviews of other people’s code. Some of them had delusions that SRP, along with the rest of SOLID principles, is limited to object oriented programming. Also, people mostly can not see obvious cases of violation, just because the code was written by the guidelines of some famous framework. Yes, some frameworks have guidelines that violate basic principles of writing a good code. Flux and Redux are shining examples of such violations.

It matters

First of all I want to highlight the importance of SRP. And I want to emphasize that SRP is not limited to OOP. It applies to procedural, functional and even declarative programming. Yes, you can decompose HTML, especially now that it is often managed by UI frameworks like React or Angular. And not only programming, it applies to other engineering areas as well. Moreover, not only engineering, old military saying goes: “divide and conquer”, and that is the incarnation of the same principle. Complexity kills, decompose it and you win.

Regarding other engineering areas, there was an example, I read it somewhere on the internet, about prototype aircraft failure. Engines did not switch to reverse because they interpreted state of the chassis incorrectly. Instead of relying on functionality of chassis controller, they made it so controller of the engine directly monitored limit switches, tachometer and other sensors in the chassis. Engines have to go through long cycle of testing and certification before they can be put onto even unfinished aircraft. And in this case, due to violation of the principle, every time construction of the chassis changes, engines software have to be modified and go through the same certification again. And that is not the worst part, obviously, the violation could have caused loss of aircraft and test pilot’s life. Luckily, most of the programming that we do, has no such consequences. But even then, writing a better code matters.

  1. Decomposition of the code reduces its the complexity. Two problems put together have higher complexity then two separated problems. For example if solving one particular problem you write a code with cyclomatic complexity of 4, then solving two such problems in one method will cause complexity of 16, while separating it into two methods will make total complexity of 8. It might not be as easy as adding 4 and 4 versus multiplying, of course, but you get the idea.
  2. Unit-testing of decomposed code is easier and more efficient.
  3. Decomposed code causes less resistance to modifications. I.e., changing such a code is less susceptible to introducing defects.
  4. Code becomes better structured. Hierarchical folders/files structure is much easier to navigate through than one long file.
  5. And last, but not least. Decomposition allows for code generation. Particularly decomposition of boilerplate code from business logic.

And all those are the properties of the same code, you do not get to choose better structured code over testable code for instance. It always comes together, the code that is decomposed and better structured is also testable, modification resistant, less error prone and etc.

Existing definitions do not work

One of the definitions goes like this: “your code (class or function) should only have one reason to be modified”. But if you look at the second of SOLID principles, it says: “your code should be open for extension and closed to modification”. One reason for modification against no reason for modification is a conflict of definitions. If you try expanding and explaining further into them, you will come to the point where those principles do not contradict each other, but their vague definitions obviously do.

Second, more direct definition says that your code should only have one responsibility. But there is a problem with that definition as well. People tend to generalize the responsibility. For example, if you have a chicken farm, growing chickens would be the only responsibility of that farm. But if, at some point, you want to diversify your business and start growing ducks there, you won’t say it has two responsibilities now, you would rather call it poultry farm. Then you add sheep and call it domestic animal farm. After that you want to grow tomatoes there and make up next more generalized name for it. The same goes for “one reason to modify”. It can be as general as you could imagine.

Another example that I always like to mention during job interviews is a class controlling the space station. It does not do anything else, just controls the space station, how about that for a class having single responsibility.

And, since I mentioned Redux, the reducer has to have a switch in it, which sometimes grows to have hundreds of cases. It’s only responsibility is to handle state transitions of the application. This is literally what few of interviewed developers said. And nothing could tip them off.

So, if you think that your code has one responsibility, but it still “smells”, you know why. Definition of having one responsibility just does not work.

Better definition for SRP

After some trial and error, I came up with better definition. And that definition is not binary, it does not go “hot or not”.

“Your code’s responsibility should not be too big”.

Yes, you have to “measure” the size of the responsibility of your function or class now. And if it is too big, you’d better break it down into smaller responsibilities. Back to farm example, even “chicken farm” example could happen to be too big of the responsibility, and you might want to separate broiler chicken from egg laying hens.

But how to determine if the code’s responsibility is too big? Unfortunately, I can not offer any mathematically precise method, only empirical. It all comes with experience, the more experience you have, the more you tend to decompose your code. Here is the list of symptoms that could help.

  1. Cyclomatic complexity. Sometimes big complexity can be masked by using methods like array.forEach instead of a regular “for” cycle. But if you can collect this metric, it can point at the most obvious parts of the code that are in dire need of decomposition.
  2. Size of your functions and classes. Function containing 800 lines of code does not even need to be read before you can tell something is wrong with it.
  3. Lots of imports. Once I opened a file in the other team’s project and there was a whole screen of imports. I pressed page-down button, and second screen was all imports as well. You could say that all modern IDEs can hide imports under expandable plus sign. But I think that good code does not really need that amenity. It does not need to mask this smell. Also, in that file I managed to extract one simple piece of code to another file and about one fourth(or even one third) of those imports gone with it. Can’t be better demonstrated that this code had no place there.
  4. Unit tests. If you can’t just see that responsibility is too big, you can try and force yourself to write tests for your code. If you need 20 tests to cover main functionality of one function, you’d better go and decompose it.
  5. The same goes for a number of arrangements and assertions in your tests. Large number of those is also a clear sign. There is a utopian statement out there, saying that you can only have one assertion in the test. I say that good ideas raised to absolution become absurdly impractical and therefore get avoided by developers.
  6. Business logic better not be directly dependent on the “outside world”. Express routes, Oracle driver, and so on, better hide all that behind interfaces.

There are few side notes:

There is, obviously, the other end of the stick. 800 methods containing one line each is no better than one method containing 800 lines. The truth is somewhere in the middle.

This story is not meant to shed any light upon “where this responsibility should be placed” topic. Sometimes, for example, developers bring too much logic into DAL layer.

Again, I do not offer any guidelines how to negotiate those metrics between developers in teams. I would not recommend setting some hard constraints, like “no more than 50 lines per function”. But this approach at least gives something. It gives a direction in which any developer or team of developers can progress. It works for me, I bet it can work for anyone else.

And last note — going through TDD alone can force you start decomposing your code long before you would actually write 20 tests for you function, containing 20 assertions each. This development methodology can heavily boost you along the “following SRP” street.

Separating business logic from a boilerplate

Can’t go with the “how to write code” rant without actual “good” examples of how to do it. First example is about extracting boilerplate code from business logic.

Example of business logic coupled with boilerplate

This is the example how back-end code is usually written. People tend to write business logic right inside the Express binding code. I highlighted business logic with green rectangle. And red rectangle inside it is showing how query parameter extraction is intertwined with that business logic.

I always separate these 2 responsibilities like this:

Decomposed example

In this example, all the code related to Express: url binding, query parameters interaction, responding to request with 200 code, it all moved to separate file.

At first sight it does not look that much better: there are 2 files now, there is an overhead of having separate class and method signature. I.e. more code to write. So what does it give you? First of all, now your “application entry point” is not Express anymore. Now it is just plain old typescript function. Or javascript function, or C# as well as any other language you can write web api in. That enables you to do stuff with your application not available before decomposition. For example now you can write behavior test without starting and testing the Express. And without jury-rigging that Router object to act on behalf of Express for the tests. You just call the business logic directly.

And another fancy feature that such decomposition enables: you can now write code generator that would parse userApiService and generate Express binding code for you automatically. As part of future publications about code generation, I will also state this: code generation does not save time writing the code, but it does save time and comfort for developers maintaining the code.

Divide and conquer

This methodology of writing good code already existed, I did not came up with it. I just found it to be well suited for writing business logic. And I wanted to make another fictional example of how to write decomposed and self-documented code, since wikipedia only had diagrams and not the code examples.

Let’s say your business analyst hands you the task to send employee report to insurance service provider​. To do it, following steps have to be implemented

  1. Get the data from DB​

2. Convert to accepted format​

3. Send the data over network​

This sequence of actions might not be explicitly stated in the requirements, though, it could be implied. Or might want to discover that these steps are needed in this order after talking to BA. Regardless, when you create a method to implement it, do not go opening the database or network connection, instead, try to replicate what is in the requirements, figuratively speaking - translate them to the code. Something like this:

async function sendEmployeeReportToProvider(reportId){
const data = await dal.getEmployeeReportData(reportId);​
const formatted = reportDataService.prepareEmployeeReport(data);​
await networkService.sendReport(formatted);​
}

This gives you simple, readable and testable code, though I think such method is trivial and does not actually need unit-testing. And responsibility of this method is not to send employee report to insurance company, it is to break that big task down into three small subtasks.

After you come up with such a method, you go back to requirements and read further. Let’s say, the report has to have salary section and work hours section.

function prepareEmployeeReport(reportData){ 
const salarySection = prepareSalarySection(reportData);​
const workHoursSection = prepareWorkHoursSection(reportData);​
return { salarySection, workHoursSection };​
}

And so on, keep breaking your tasks down until all you have to do is to implement small, near trivial methods.

Relation to Open-Close Principle

Earlier, I pointed at the contradiction between SRP and Open-Close Principle. First says that there should be only one reason to modify the code, second says the code should be closed for modification altogether. Unlike their definition the principles not only have no contradiction, they work in synergy. In fact, all 5 SOLID principles serve the same goal, to help developer point at the “bad” code and help write better code. Irony — noticed how I just replaced five small responsibilities with big one?

So, after the implementation of the task from previous chapter, BA gives us the second task: to print the same report.

Let’s say there is a developer, who does not follow SRP. And when he was implementing the first task, he implemented it all in one function. After such developer receives the second task, second responsibility coming into the project, he combines it with the previous one, since there is much code to reuse, and comes up with more generalized name. It is “Serve the employee report” now. And implementation looks like this:

async function serveEmployeeReportToProvider(reportId, serveMethod){
/*
lots of code to read and convert the report
*/
switch(serveMethod) {
case sendToProvider:
/* implementation of sending */
case print:
/* implementation of printing */
default:
throw;
}
}

Reminds of some code in your project? As I said, both existing definitions of SRP just do not work. They don’t give developer any information about how to not write their code. So, he just renamed his previous “reason to change”, instead of not taking the second reason inside the same code. And here comes the Open-Close principle, which says that developer should not have changed the existing code while adding new functionality. His code should be organized in a way so adding new functionality would result in adding new code, new function or class, preferably a new file. So, this code is “bad” from the point of view of both principles. And if first did not help see it is “bad”, second one should.

Let’s see how to solve the second task using “divide and conquer” approach.

async function printEmployeeReport(reportId){
const data = await dal.getEmployeeReportData(reportId);​
const formatted = reportDataService.prepareEmployeeReport(data);​
await printService.printReport(formatted);​
}

New function was added. Sometimes I call such functions a scenario-function. Because they do not carry any implementation logic, they just define the sequence of calling the decomposed responsibilities. Obviously, first two steps are the same as first two steps of previously implemented function. The same way as first 2 steps of BA requirements for both tasks.

As a result, during implementation of this new task we added new scenario-function into our project, and implemented new printService. No existing files were modified. So this “divide and conquer” approach helps you satisfy both principles, SRP and Open-Close.

Alternative

I also wanted to mention that there is a competing approach “code then refactor”, not sure how it is called. It says you need to write all your code first, and only then break it down using refactoring tricks. Those refactoring methods are like mathematical approach to chess, where you do not actually know what you are doing strategy-wise , you calculate “the weight” of your position and try to maximize it by making the move. I never liked that refactoring approach because of one simple matter. Naming your methods and variables is hard as it is, naming them when they have no business meaning is nearly impossible. Refactoring technique says: look, these 6 lines are identical over here and over there, you need to extract it. All right, I did, but how do I name the function? someSixIdenticalLines()? Disclaimer: I am not saying that this method is necessarily bad.

Summary

There are benefits of following the principle.

“Should have one responsibility” definition does not work.

There is a better definition and symptoms to signal the need of decomposition.

“Divide and conquer” approach lets you write structured self-documented code.

--

--