Making the Single Responsibility Principle Practical

Written by dmitriislabko | Published 2024/03/27
Tech Story Tags: software-development | software-engineering | software-architecture | software-design | solid-principles | single-responsibility | srp-explained | what-is-the-srp

TLDRThe SRP should be applied through defining the reasons to change for any particular unit of code and with cost balancing in mind.via the TL;DR App

The Single Responsibility Principle (SRP) is one of the five SOLID principles of code design. It states that a unit of code should have only one reason to change. However, this principle is often misunderstood and misapplied, as we can see in too numerous 'big (or distributed) balls of mud' and 'spaghetti' code bases.

In this article, we will explore what the SRP is and how to make following it practical and actually beneficial.

First things first, why the SRP? Why should we care about it? The SRP helps write code that is easier to understand, maintain, and extend. Essentially, being able to follow the SRP is one of the key factors in writing clean code. The next thing is that the SRP can be applied not only to classes but also to methods and contracts in general.

This means that the SRP is not just about classes but about any unit of code that can change. And by a 'contact' I mean a set of inputs, outputs, and behaviors that a unit of code is expected to provide. The main challenge in the SRP is exactly in defining the 'reason' to change, to outline the responsibilities - this is what the article will revolve around.

The irony is that there is probably not a single developer who would not know about the SRP or would say that it is not important.

However, in practice, we see high coupling, overcomplicated dependency graphs, the costs of changes and maintenance keep growing way too fast, and the code becomes a nightmare to work with - and all the while everyone knows about the SRP and even tries to follow it.

How the SRP Is Often Introduced and Why It Does Not Really Help

Normally, the description of the SRP starts with an example where a few different responsibilities are combined together, and then it goes by separating those responsibilities into different classes, and stating that this is it, this is the SRP.

Yes, separating concerns may lead to a good and cleaner design, and it would be an example of the SRP, but it is just a limited application of it. Essentially, such examples show how to separate concerns, but not how to really apply the SRP.

Once the concerns are separated, we still need to implement the functionality that uses them together in a single flow. So, the SRP is not about separating different concerns, it is about how to properly outline the responsibilities in the first place. And the definition of 'proper' comes from the definition of the SRP itself - 'a unit of code should have only one reason to change.'

Let's take the Wikipedia example of a class that compiles and prints a report, even though it is rather limited, but we can start with it. Clearly, building or compiling a report and printing or saving it into a representation are two different functionalities that are quite independent of each other. However, in the end, they may form a single responsibility.

Let's ask ourselves a question: is this software ever going to need a different way to compile or build a report? Or a different way to print it or save it into another representation form? Does it actually have such reasons to change?

The answer to these questions comes from the business requirements - it is not about the code itself or not only about the code itself.

So, the typical view on the Single Responsibility Principle is fine if we balance it with proper bounds that will not lead us into a trap of over-engineering the code. It is very much like premature optimizations - just not in terms of performance or memory consumption but in terms of design and architecture. "If it is not broken, do not fix it. If it is not needed, do not add it." Do not write code or create designs that are not needed now and may never be needed.

It would be great to augment the canonical definition of the SRP - 'a unit of code should have only one reason to change' - with an understanding that the 'reasons to change' should be limited by the existing and potential, though still justified, requirements. What the piece of software that this unit of code is a part of is supposed to do, and what it is not supposed to do.

Ok, all this sounds good but there may be a question: why not just separate concerns and be done with it? Even if this separation will never be required by our software, even if we will have only a single implementation of each of those concerns and probably just a single use for each of them - why not separate them? We can have a cleaner implementation, more in line with the SRP, 'clean code', etc.

Again, if our software is small and simple, this separation will not actually hurt.

The answer to this question is that such separation of concerns creates new abstractions, new interfaces, new classes, and new dependencies. More abstractions potentially mean higher coupling. And since we are normally dealing with large and complex software, this separation may lead to a combinatorial explosion of abstractions, and thus - higher coupling.

So, it will not help with making our code cleaner and easier to understand and maintain. What is often omitted when describing the SOLID principles, and the SRP in particular, is that everything comes at a cost, and we need to balance these costs in order to keep our code maintainable and understandable.

To make this statement clearer, just count how many new abstractions we have after we carelessly separated concerns, and how many new dependencies we would need to inject into our classes to make them work - the higher the numbers, the higher the coupling.

Well, the next arising and rather justified question can be about the testability of such code, if we do not separate concerns. And yes, it can be very justified to separate concerns in order to make the code more testable, as this is often a part of our NFRs (non-functional requirements). But again, should we really introduce new abstractions and dependencies just to make our code more testable? Can it be tested just fine without introducing new abstractions?

And the answer, surprisingly, can often be 'yes.' Following the Wikipedia example, we can still apply the SRP on the level of methods, without introducing additional abstractions, and such methods can be tested just fine.

The SRP From the Point of View of Contracts

The Single Responsibility Principle is a design principle, and the design often starts with contracts. We can describe the contracts in a rather wide form - as a set of inputs, outputs, and behaviors that a unit of code is expected to use and provide. This description should be good enough for the purpose of this article.

Let's see how the SRP can be applied to contracts, and let's repeat its definition - 'a unit of code should have only one reason to change.' The contracts often have the most direct influence on when and why the code should change; if a contract changes, the code that implements or uses it should change as well.

And while we strive to maintain the contracts stable, they connect the pieces of code together, and thus, are quite likely to change when the co-related requirements change. In other words, we do not control some of the 'reasons to change' even if the requirements for our own unit of code are stable.

A contract can be described in terms of a 'dependency-consumer' relationship: another piece of code provides some behaviors used by our unit of code and those behaviors may create some data required by our unit of code. In turn, our unit of code gets consumed by other units of code.

A method with its list of parameters and return values is an example of such a contract. In the same way, a class or interface provides a contract with its methods, and we can keep climbing the abstraction ladder, speaking about the contracts on the level of modules, services, APIs, and so on.

When designing contracts for our code, we normally take into account this co-relation between the units of code, as it is quite clear that a contract we are designing should serve specific 'consumers' and will depend on other contracts. However, when looking at a contract from the point of view of the SRP, we should also consider that the 'reasons to change' for our contract are also influenced by those 'consumers' and 'dependencies', or if we look 'from inside out' - the behavior provided by our contract, and thus its output, influence the 'reasons to change' for the 'consumers' of our contract.

If we deal with quite a stable code base where the requirements are not changing often, and our contract most likely is going to be stable, we just need to design it well with sufficient thoughtfulness regarding its consumers and dependencies.

However, if we deal with a code base that is more fluid, where the requirements for our consumers and dependencies may change, we may want or will have to, either accept that our contract will keep changing as well, or design additional abstractions that will protect our contract from those changes.

One such abstraction is a mapping layer that will convert the data from the representation produced by the dependencies into the representation that our contract expects, and the same way around for our consumers. In fact, this is a common practice in the design of APIs, bounded contexts, and other similar units of code.

The main question is the cost - what is going to be less expensive, changing our contract and implementation when the dependencies change, thus invoking in turn potential change in our consumers, or introducing additional abstractions that will isolate our contract from those changes?

In real-world scenarios, we often begin with what is faster to implement, and then, we see how it evolves. Ideally, our working process has enough space to address and solve the arising technical debt, and we can refactor our code when needed with the goal of keeping it maintainable at the lowest cost.

And by the 'cost' I mean not only the time and effort needed to implement a particular change but also how costly it will be to maintain and extend the code in the future. Many of us know all too well that what costs very little to do right away may and will cost a lot in the future.

So, a bit unexpectedly, the SRP got connected not only with immediate design decisions and long-term maintainability but also with the costs of maintaining the code and addressing the technical debt.

Of course, the developers are not seers, but by applying the SRP properly for code design at the contract level, we can achieve better maintainability and lower future costs - both for the business and for the developers.

The SRP and the Leaky Abstractions

A leaky abstraction is an abstraction that leaks details of what it is supposed to abstract away. Wikipedia

On the practical level, a leaky abstraction can be anything that exposes, directly or indirectly, how a unit of code works - something that the consuming code is not expected to know about or use. For example, the response object contains a public property that logically should not be there, as it exposes internal state or implementation detail that the consuming code should not be aware of.

Another example is when a class method output should be additionally processed by another method of the same class in certain scenarios, which the consuming code does not control but is forced to check for.

One more example is submitting a parameter that controls the input data processing while the parameter value could be deducted from the input itself or should come from another source that the consuming code does not own or use for any other purpose.

Leaky abstractions are related to the Single Responsibility Principle because they introduce additional 'reasons to change' for a unit of code, even if only potentially. Leaky abstractions add to contract instability, both in terms of consumption and exposure.

Let's look at leaky abstractions from the point of view of contract design, which we discussed in the previous part. A few good checkup questions to ask when designing a contract are:

  • Does the contract really need a particular input parameter?

  • Is the exposed behavior atomic and self-sufficient, or will the consumers have to invoke additional behaviors of our contract on a condition not determined by the consumer?

  • Does the contract expose any internal, or even public, state that is not required by the consumers?

Let's go over these checkpoints in more detail to see why they may be related to leaky abstractions and the SRP.

Does the contract really need a particular input parameter?

Why should we be concerned about an input parameter of a contract? Besides the obvious reason of not being used by the code, we should also consider if we can deduce this parameter from the input data itself without incurring additional costs or if the parameter is a part of the global application state, and thus, could be injected into the contract implementation at the dependency resolution stage.

Well, normally we do need our parameters at the calling site, but we should be sure that it is within the responsibility of our consumers to provide them, and it is not the responsibility of our contract to obtain them. If these responsibilities are mixed up, we may have a leaky abstraction and we create an additional 'reason to change' either for our unit code or for its consumers.

Is the exposed behavior atomic and self-sufficient?

This may not affect our own unit of code 'reasons to change' directly, but it may affect the consumers of our contract, as this influences the contract stability. If the exposed contract behavior is not atomic, changes in its internal logic are more likely to affect the contract itself which will be a breaking change for the consumers.

This would also be a violation of the SRP on the contract level as the contract should not change when only the internal behavior changes. While the contract still provides the same final result, it has changed because of internal reasons that should not be exposed to the consumers, and this is another example of leaky abstractions.

Does the contract expose any internal or even public state that is not required by the consumers?

As well as introducing the risk of potentially breaking changes for the consumers, exposing internal or not needed state may also lead to unexpected coupling or 'reasons to change'. Let's say the consumers of our contract came to rely on an unintentionally exposed state, and for some reason, it is impossible or too expensive now to update the consumers; while this state cannot or should not be maintained by our code because the requirements have changed, and the code should be updated.

So, our code will be forced to maintain this state and implement an additional workaround to keep the consumers working - we now have an extra 'reason to change' for our code.

Consuming Leaky Abstractions

Of course, we should also be watchful when consuming leaky abstractions. Let's again take the Wikipedia example for the SRP regarding a class implementing a report persistence functionality. A report may be persisted into a local file, a database, or any other type of storage. Each of these methods of persistence has its own internal specifics that may leak out, and our code may mistakenly rely on them.

For example, the local or remote file persistence may expose the file location that our code may want to use for some reason. When the application is expanded with a new persistence method, say, a database, our code will have to change - simply because it was built around a leaky abstraction.

In a sense, leaky abstractions provide 'false contracts' that are not stable and may change at any time or simply be absent or different in other contract implementations, thus introducing additional 'reasons to change' for a unit of code and violating the SRP.

Let's Summarize...

Of course, all code is written in a context, and the context is the king, as it is defined by business requirements, NFRs, the current state of the code base, the current level of expertise of developers, the way the costs are balanced, and by many other factors.

Nonetheless, it is possible to outline some points for additional considerations when creating designs or implementing code that would help to stay more in line with the Single Responsibility Principle within the context present at the moment.

  1. Outline the responsibilities within the units of code based on valid 'reasons to change' that are defined by the business and non-functional requirements, and not by the code itself. "If it is not broken, do not fix it. If it is not needed, do not add it."

  2. Before extracting or creating a new abstraction, consider if it is really needed - check the 'reasons to change' for already existing code and for the new abstraction. If they are not different, it may most likely be better to keep the code together.

  3. When driven by the code testability requirements, consider applying the SRP on the level of methods, not only on the level of abstractions. If the only reason for a new abstraction is to make the code more testable, try adjusting your testing approach first, and introduce new abstractions only when there is no other way.

  4. When designing contracts in a complex system, consider if the potentially required dependencies are stable enough to be used directly, or if it is better to introduce additional abstractions that will protect your code from the changes in those dependencies. The cost of adding and maintaining extra abstractions should be balanced with the cost of potentially breaking changes when the dependencies change.

  5. Check for leaky abstraction in your code and in the dependencies it needs. If you see that the abstractions are leaking, consider replacing them with more stable abstractions. The cost of relying on leaky abstractions, or better to say the risk, should be balanced with the cost of replacing them.

As you see, sometimes, we should not add a new abstraction and sometimes we should - the context is the king, we just need to make sure it can rule happily for as long as possible and define the 'reasons to change' and the cost balancing are the keys in applying the SRP.


Written by dmitriislabko | Accomplished software developer with 25+ years of experience. Focus on .NET technologies.
Published by HackerNoon on 2024/03/27