The Single Responsibility Principle (SRP) is one of the more important of the SOLID principles. It is responsible for decomposition of modules and encapsulates the idea that each unit of code should be responsible for just a single, clearly defined role. This ensures that software remains extensible long term and makes it easier to maintain.
To illustrate the Single Responsibility Principle concept, I’m going to use the example from my previous post (IEC 61131-3: SOLID – The Dependency Inversion Principle). That post showed how to use the Dependency Inversion Principle (DIP) to eliminate fixed dependencies.
There are three different lamp types, with a corresponding function block for each (FB_LampOnOff, FB_LampSetDirect and FB_LampUpDown). Each lamp type works in a different way and provides appropriate methods for modifying the output value.
A higher-level controller (FB_Controller) provides access to a single application programming interface (API) for addressing the three lamp types. The Dependency Inversion Principle (DIP) is applied to avoid having a fixed dependency between the controller and lamp types. The unitary API is defined by I_Lamp. The I_Lamp interface is implemented by the abstract function block FB_Lamp. FB_Lamp contains identical program code for all three lamp types. Having all lamp types derived from FB_Lamp means that the controller and lamps are decoupled. Instead of creating instances of specific lamp types, the controller manages just a single reference to FB_Lamp.
We’re going to use the function block FB_LampUpDown to evaluate the implementation in more detail. At the beginning of this series of articles, this function block contained only three methods for changing the output value: OneStepDown(), OneStepUp() and OnOff().
1st issue: multiple roles
In applying the Dependency Inversion Principle (DIP), we added the methods DimDown(), DimUp(), Off() and On() via the FB_Lamp abstract function block and the I_Lamp interface. These four methods represent an ‚adapter‘ between FB_Controller and the concrete FB_LampUpDown implementation.
The UML diagram below shows the two roles the FB_LampUpDown component currently performs. The methods inherited from FB_Lamp are marked in blue (role as adapter for FB_Controller). The area marked in green indicates the actual role performed by this function block (role as FB_LampUpDown).
At this point, we might consider designating the OneStepDown(), OneStepUp() and OnOff() methods as PRIVATE. We can only do this, however, if FB_LampUpDown has not previously been used in any other context. Otherwise, every extension would need to ensure that the function block retained backwards compatibility.
As was the case in our demonstration of the Dependency Inversion Principle (DIP), the program as it stands is very maintainable. But what happens if we add additional roles? A future development cycle might, for example, need to implement additional adapters. The actual FB_LampUpDown logic would be lost in the adapter implementation.
Creating the adapter
We therefore need a tool to separate the individual roles. Ideally a tool that ensures that the original implementation of FB_LampUpDown remains unchanged. This will also be necessary if, for example, FB_LampUpDown resides in a PLC library and is therefore outside the developer’s control.
Approach 1: inheritance
One possible solution would be to use inheritance. The new adapter function block (FB_LampUpDownAdapter) inherits from FB_LampUpDown. But it would also have to inherit from FB_Lamp. Since multiple inheritance is, however, not permitted, one option would be to have FB_LampUpDownAdapter implement the I_Lamp interface. In this case, the abstract function block FB_Lamp is rendered redundant.
By inheriting from FB_LampUpDown, the adapter also provides external access to methods that are not required for interaction with the controller. With this approach, therefore, FB_LampUpDownAdapter exposes details of the FB_LampUpDown implementation.
Approach 2: adapter pattern
In this case, the adapter contains an internal instance of FB_LampUpDown. The methods involved in adapter function are simply passed internally to FB_LampUpDown. This approach avoids exposing details of FB_LampUpDown externally.
This approach meets our objective of clearly separating the role of the adapter from the lamp logic. The lamp implementation does not need to be modified.
Let’s take a closer look at the program after implementing the Single Responsibility Principle (SRP).
Responsibilities are now clearly separated. If we need to extend the program code, it’s easy to work out which function block we need to modify.
If we need to add additional adapters, there is no need to extend the implementation of the existing function blocks for the lamps. We don’t need to worry about these function blocks becoming more and more bloated over the course of multiple development cycles.
Separating independent roles into individual, independent units of code (function blocks) makes the program easier to maintain. But it also means more function blocks, making it harder to grasp the overall picture. Consequently, we should not be looking to increase the number of function blocks unnecessarily. Creating individual function blocks for individual roles is not always desirable.
Since program functionality is always expanding, function blocks should be split up when they start to grow too big. SOLID principles can help you in implementing this. This raises the question, however, of how we judge when a unit of code has grown too big.
Class Responsibility Collaboration (CRC)
Counting the lines of code is not a good approach to evaluating the complexity of a unit of code. Code metrics like this can be useful tools (worthy of an article in itself), but I’d like to present a method for determining complexity based on the requirements of a unit of code.
The use of the term ‚unit of code‘, rather than ‚function block‘, is deliberate. This approach can also be used to evaluate a system architecture. In this case, the units of code might be, for example, the individual services. This method is not confined to evaluating pure source code alone.
The method I’m going to look at is called Class Responsibility Collaboration (CRC). The name gives a pretty good insight into the principle behind this method.
- We start by listing all of the function blocks (class).
- We then write down the role or responsibility of each function block.
- We then note down which other function blocks each function block collaborates with (collaboration).
The CRC method flags up very clearly any imbalances in a software system. Responsibilities and dependencies should be evenly distributed across all function blocks.
To keep things simple, our analysis will ignore the function block FB_AnalogValue. In all variants of our sample program, this operates in the same way to transfer the output value between the relevant lamp type and the controller.
Step 1: Initial state
We will start by analysing the program in its original form, i.e. before we undertook any optimisation (see IEC 61131-3: SOLID – The Dependency Inversion Principle).
We can clearly see that the controller performs a very large number of roles, but the functionality of each lamp type is easily understood. It’s a similar story with dependencies. The controller addresses each lamp type directly.
Step 2: Applying the Dependency Inversion Principle (DIP)
By applying the Dependency Inversion Principle, we eliminate fixed dependencies between the controller and lamp types. Now, the controller only addresses the abstract function block FB_Lamp, and no longer addresses each lamp type.
The disadvantage with this setup is that each lamp type performs more than one role – the logic for the specific lamp type and mapping to the abstract lamp.
Step 3: Applying the Single Responsibility Principle (SRP)
To bring this setup in line with the Single Responsibility Principle, we use the adapter pattern. Each lamp type now has an adapter function block responsible for mapping between the abstract lamp and the specific lamp type.
After optimisation, each function block performs just a single role. We now have a large number of small, rather than a small number of large function blocks.
The definition of the Single Responsibility Principle
Now let’s take a look at the definition of the Single Responsibility Principle. The principle was defined in the book (Amazon ad link*) Clean Architecture: A Craftsman’s Guide to Software Structure and Design by Robert C. Martin back in the early 2000s as:
A class should have only one reason to change.
Robert C. Martin has also expressed this as:
A module should be responsible to one, and only one, actor.
But what does module mean in this context and who or what is an actor?
A module in this context is a unit of code. What a module is depends on the angle from which you’re looking at the software system. From the point of view of a software architect, a module might be a REST service, a communication channel or a database system. For a software developer, a module might be a function block or an interrelated set of function blocks and functions. In the above example, the modules were function blocks.
Similarly, the term actor does not necessarily represent a person; it can also refer to a specific set of users or stakeholders.
In the previous post, I applied the Dependency Inversion Principle (DIP) to decouple the controller (FB_Controller) from the individual lamps. This also required modifications to the individual lamp function blocks. The Single Responsibility Principle (SRP) was then used to further optimise this decoupling.
Is it good practice if a single function block is responsible for compressing and encrypting data? No! Compression and encryption are completely different responsibilities. You can compress data without worrying about encryption. Similarly, encryption is independent of compression. They are completely independent roles. If compression and encryption were dealt with within the same function block, there would be two reasons to change – encryption and compression.
A further example of the Single Responsibility Principle in action (from a software architecture perspective) is the ISO/OSI model for network protocols. The model defines seven sequential layers, each performing clearly defined roles. This makes it possible to replace individual layers without affecting higher or lower layers. Each layer has a single clearly defined role, e.g. transmission of raw bits.
My next post will look at the Liskov Substitution Principle (LSP).