Single Responsibility Principle. Not as simple as it seems

Single Responsibility Principle. Not as simple as it seems Single Responsibility Principle, also known as the principle of single responsibility,
is a concept that can be quite slippery to grasp and is a rather nerve-wracking question in programmer interviews.

My first serious encounter with this principle occurred at the beginning of my first year, when a group of us young and inexperienced students was taken to the woods to turn us from caterpillars into real students.

In the woods, we were divided into groups of 8-9 people each and set up a competition — which group could finish a bottle of vodka the fastest, provided that the first person in the group pours the vodka into a glass, the second drinks it, and the third has a bite to eat. The unit performing its task then moves to the back of the queue.

A case where the size of the queue was a multiple of three was a good implementation of SRP.

Definition 1. Single Responsibility.

The official definition of the Single Responsibility Principle (SRP) states that each object has its own responsibility and reason for existence, and that responsibility is singular.

Let’s consider the object 'Tippler' (Tippler).
To implement the SRP, we can divide the responsibilities among three individuals:

  • One pours (PourOperation)
  • One drinks (DrinkUpOperation)
  • One has a bite (TakeBiteOperation)

Each participant in the process is responsible for one component of the process, meaning they have one atomic responsibility — to drink, pour, or have a bite.

The Tippler, in turn, serves as a facade for these operations:

class Tippler {
    //...
    void Act(){
        _pourOperation.Do() // pour
        _drinkUpOperation.Do() // drink
        _takeBiteOperation.Do() // have a bite
    }
}

Single Responsibility Principle. Not as simple as it seems

Why?

The programmer writes code for the monkey, and the monkey is inattentive, foolish, and always in a hurry. It can retain and understand about 3 to 7 terms at one time.
In the case of the Tippler, these terms are three. However, if we write code in one long block, it will contain hands, glasses, fights, and endless arguments about politics. And all this will be in the body of a single method. I'm sure you've seen such code in your practice. Not the most humane test for the psyche.

On the other hand, the man-ape is focused on modeling objects from the real world in his mind. In his imagination, he can collide them, assemble new objects from them, and just as easily disassemble them. Imagine an old car model. You can imagine opening the door, unscrewing the door panel, and seeing the mechanisms of the window lifters, where there will be gears inside. But you cannot see all the components of the car at once, in one "listing." At least the 'man-ape' cannot.

Therefore, programmer-people decompose complex mechanisms into a set of less complex and functional elements. However, decomposition can be done in different ways: in many old cars, the air duct goes into the door, while in modern ones, a failure of the lock electronics prevents the engine from starting, which complicates repairs.

So, SRP is a principle that explains HOW to decompose, meaning where to draw the line of separation..

It states that decomposition should be done based on the principle of 'responsibility' separation, that is, according to the tasks of various objects.

Single Responsibility Principle. Not as simple as it seems

Let's return to the drinker and the benefits that the man-ape gets from decomposition:

  • The code has become extremely clear at every level.
  • Code can be written by several programmers simultaneously (each writes a separate element).
  • Automated testing is simplified—the simpler the element, the easier it is to test.
  • There is composability of code—you can replace DrinkUpOperation with an operation where the drinker pours liquid under the table. Or replace the pouring operation with an operation where you mix wine and water or vodka and beer. Depending on the business requirements, you can do everything, while not touching the method's code. Tippler.Act.
  • From these operations, you can build a glutton (using only TakeBitOperation), an Alcoholic (using only DrinkUpOperation directly from the bottle), and satisfy many other business requirements.

(Oh, it seems this is already the OCP principle, and I broke the responsibility of this post.)

And, of course, the downsides:

  • You will have to create more types.
  • The drinker will first drink a couple of hours later than he could have.

Definition 2. Single variability.

Allow me, gentlemen! The class of drinkers also fulfills a single responsibility — it drinks! Moreover, the term 'responsibility' is extremely vague. Some are responsible for the fate of humanity, while others are responsible for lifting fallen penguins at the pole.

Let's consider two implementations of the drinker. The first one mentioned above includes three classes — pour, drink, and snack.

The second one is written using the 'Forward and only forward' methodology and contains all the logic in the method Act:

//Не тратьте время  на изучение этого класса. Лучше съешьте печеньку
сlass BrutTippler {
   //...
   void Act(){
        // наливаем
    if(!_hand.TryDischarge(from:_bottle, to:_glass, size:_glass.Capacity))
        throw new OverdrunkException();

    // выпиваем
    if(!_hand.TryDrink(from: _glass,  size: _glass.Capacity))
        throw new OverdrunkException();

    //Закусываем
    for(int i = 0; i< 3; i++){
        var food = _foodStore.TakeOrDefault();
        if(food==null)
            throw new FoodIsOverException();

        _hand.TryEat(food);
    }
   }
}

Both of these classes, from an outsider's perspective, look absolutely identical and fulfill the single responsibility of 'drinking'.

Awkward!

Then we go online and learn another definition of SRP — Single Responsibility Principle.

SRP states that 'A module has one and only one reason to change'. In other words, 'Responsibility is the reason for change.'

(It seems that the guys who came up with the original definition were confident in the telepathic abilities of a human-ape)

Now everything falls into place. Procedures for pouring, drinking, and snacking can be changed separately, while in the drinker, we can only change the sequence and composition of operations, for example, by moving the snack before drinking or adding reading a toast.

In the 'Forward and only forward' approach, everything that can be changed is modified only in the method Act. This can be readable and effective when there is little logic and it rarely changes, but often it ends with terrible methods of 500 lines each, with the number of if-statements exceeding what is required for Russia's entry into NATO.

Definition 3. Localization of changes.

Drinkers often don't understand why they woke up in someone else's apartment, or where their mobile phone is. It's time to add detailed logging.

Let's start logging the pouring process:

class PourOperation: IOperation{
    PourOperation(ILogger log /*....*/){/*...*/}
    //...
    void Do(){
        _log.Log($"Before pour with {_hand} and {_bottle}");
        //Pour business logic ...
        _log.Log($"After pour with {_hand} and {_bottle}");
    }
}

Encapsulating it in PourOperation, we acted wisely in terms of responsibility and encapsulation, but now we face a dilemma with the principle of changeability. Besides the operation itself, which may change, the logging process is also becoming variable. We'll need to separate it and create a special logger for the pouring operation:

interface IPourLogger{
    void LogBefore(IHand, IBottle){}
    void LogAfter(IHand, IBottle){}
    void OnError(IHand, IBottle, Exception){}
}

class PourOperation: IOperation{
    PourOperation(IPourLogger log /*....*/){/*...*/}
    //...
    void Do(){
        _log.LogBefore(_hand, _bottle);
        try{
             //... business logic
             _log.LogAfter(_hand, _bottle);
        }
        catch(exception e){
            _log.OnError(_hand, _bottle, e);
        }
    }
}

A meticulous reader will notice that LogAfter, LogBefore and OnError can also change separately, and similarly to the previous actions, it will create three classes: PourLoggerBefore, PourLoggerAfter and PourErrorLogger.

And recalling that there are three operations for the pouring action, we end up with nine logging classes. Ultimately, the entire pouring operation consists of 14 (!!!) classes.

Hyperbole? Hardly! A person with a decompositional grenade will break the 'pourer' down into a decanter, a glass, pouring operators, a water service, a physical model of molecular collision, and in the next quarter he will be trying to untangle dependencies without global variables. And believe me — he won't stop.

It is precisely at this moment that many conclude that SRP is just a fairytale from pink kingdoms, and they go off to spin noodles...

… without ever learning of the existence of the third definition of SRP:

The Single Responsibility Principle states that similar things that need to change should be kept in one placeorWhat changes together should be stored in one place.

In other words, if we are changing the logging of the operation, we should change it in one place.

This is a very important point — as all previous explanations of SRP suggested breaking types down until they can be, thus imposing an 'upper limit' on the size of the object, now we are also talking about a 'lower limit'. In other words, SRP not only requires 'to break it down while it can be broken', but also not to overdo it — 'not to break apart things that are tied together'.It's a great battle between Occam's razor and the person with the monkey!

Single Responsibility Principle. Not as simple as it seems

Now it should be easier for the pourer. Besides the fact that there’s no need to break the IPourLogger into three classes, we can also consolidate all loggers into one type:

class OperationLogger{
    public OperationLogger(string operationName){
/*..*/}
    public void LogBefore(object[] args){
/*...*/}
    public void LogAfter(object[] args){
/*..*/}
    public void LogError(object[] args, exception e){
/*..*/}
}

And if we add a fourth type of operation, logging for it is already prepared. The code for the operations themselves is clean and free of infrastructural noise.

As a result, we have 5 classes for the task of drinking:

  • Pouring Operation
  • Drinking Operation
  • Chasing Operation
  • Logger
  • Drinker Facade

Each of them is strictly responsible for one functionality, having a single reason for change. All similar rules for modification lie close by.

Real-Life Example

Once we wrote a service for automatic B2B client registration. Then a GOD method appeared, containing 200 lines of similar content:

  • Go to 1C and create an account
  • With this account, go to the payment module and set it up there
  • Check that an account with this number hasn’t been created in the main system server
  • Create a new account
  • Add the registration result from the payment module and the 1C number to the registration results service
  • Add the account information to this table
  • Create a point number for this client in the points service. Pass the 1C account number to this service.

And there were about 10 more business operations in this list with dreadful interdependencies. The account object was needed by almost everyone. The point identifier and client name were needed in half the calls.

After an hour of refactoring, we managed to separate the infrastructural code and some subtleties of account operations into separate methods/classes. The God method became lighter, but 100 lines of code remained that refused to be unraveled.

It only took a few days to realize that the essence of this 'lightened' method is actually the business algorithm. The initial specification was quite complex. The attempt to break this method into pieces would violate the SRP, not the other way around.

Formalism.

It’s time to leave our drinker behind. Wipe your tears— we will definitely return to him someday. But now let’s formalize the knowledge from this article.

Formalism 1. Definition of SRP

  1. Separate elements so that each is responsible for something singular.
  2. Responsibility is decoded as 'a reason for change.' This means each element has only one reason for change, in terms of business logic.
  3. Potential changes in business logic must be localized. Modifiable synchronous elements should be adjacent.

Formalism 2. Necessary self-check criteria.

I have not encountered sufficient criteria for SRP compliance. However, there are essential conditions:

1) Ask yourself the question — what does this class/method/module/service do? You should answer it with a simple definition. (thank you) Brightori )

explanations

However, sometimes finding a simple definition is very difficult.

2) Fixing a certain bug or adding a new feature affects the minimum number of files/classes. Ideally — one.

explanations

Since the responsibility (for the feature or bug) is encapsulated in one file/class, you know exactly where to look and what to correct. For example: a feature to change the logging output of operations would only require changing the logger. There's no need to search through the rest of the code.

Another example — adding a new UI control similar to previous ones. If this forces you to add 10 different entities and 15 different converters — it seems you've 'over-engineered'.

3) If multiple developers are working on different features of your project, the likelihood of a merge conflict, meaning the probability that the same file/class will be changed by multiple developers simultaneously, is minimal.

explanations

If adding a new operation 'Pour vodka under the table' requires you to touch the logger, drinking, and pouring operations — it seems that responsibilities are poorly divided. Of course, this is not always possible, but you should strive to reduce this indicator.

4) When clarifying questions about business logic (from a developer or manager), you delve strictly into one class/file and get information only from there.

explanations

Features, rules, or algorithms are compactly written, each in one place, rather than scattered as flags throughout the code space.

5) Naming is clear.

explanations

Our class or method is responsible for only one thing, and that responsibility is reflected in its name.

AllManagersManagerService — likely a God class.
LocalPayment — probably not.

Formalism 3. Development methodology 'Occam's first'.

At the beginning of design, a person-monkey does not know or feel all the subtleties of the task at hand and can make mistakes. Mistakes can happen in various ways:

  • Creating overly large objects by combining different responsibilities
  • Break it down by dividing a single responsibility into many different types
  • Incorrectly defining the boundaries of responsibility

It's important to remember the rule: "it's better to err on the side of caution," or "if in doubt, don’t split." For example, if your class has two responsibilities, it is still comprehensible and can be split into two with minimal changes to the client code. However, assembling a glass from shards is usually harder due to the context being scattered across several files and the lack of necessary dependencies in the client code.

It's time to wrap up

The application scope of SRP is not limited to OOP and SOLID. It applies to methods, functions, classes, modules, microservices, and services. It is applicable to both "fine-tuning" and "rocket science" development, making the world a little better everywhere. When you think about it, it’s arguably a fundamental principle of all engineering. Mechanical engineering, control systems, and indeed all complex systems are built from components, and "under-splitting" deprives designers of flexibility, while "over-splitting" deprives them of efficiency, and incorrect boundaries rob them of clarity and peace of mind.

Single Responsibility Principle. Not as simple as it seems

SRP is not a natural invention and is not part of the exact sciences. It arises from our biological and psychological limitations. It is merely a way to control and develop complex systems using the brain of a human-ape. It tells us how to decompose a system. The original formulation required considerable telepathic skill, but I hope this article somewhat clears the smoke.

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster