DEV Community

Cover image for Separation of Concerns and SOLID
Anton Ukhanev
Anton Ukhanev

Posted on • Edited on

Separation of Concerns and SOLID

I would like to start with a quote of E.W. Dijkstra, from his 1974 paper "On the role of scientific thought":

Let me try to explain to you, what to my taste is characteristic for all intelligent thinking. It is, that one is willing to study in depth an aspect of one's subject matter in isolation for the sake of its own consistency, all the time knowing that one is occupying oneself only with one of the aspects. We know that a program must be correct and we can study it from that viewpoint only; we also know that it should be efficient and we can study its efficiency on another day, so to speak. In another mood we may ask ourselves whether, and if so: why, the program is desirable. But nothing is gained β€”on the contrary!β€” by tackling these various aspects simultaneously. It is what I sometimes have called "the separation of concerns", which, even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts, that I know of. This is what I mean by "focusing one's attention upon some aspect": it does not mean ignoring the other aspects, it is just doing justice to the fact that from this aspect's point of view, the other is irrelevant. It is being one- and multiple-track minded simultaneously

If we want to make our code resilient to the negative effects of change i.e. make it more adaptable to change without breaking, as well as to make it simple to understand and modify, we need to separate concerns very well. But what does it mean - to have a concern? And how do we determine what the concern of a module is?

Note: if you understand why and how concerns are separated, but not sure what to do in practice, learn how to make cross-platform modules in PHP. You are also encouraged to read Alain Schlesser's excellent article Using A Config To Write Reusable Code.

Here is an example. Let's say, our project is a car, and we need an engine to run it. The engine needs something to ignite the fuel in a cylinder - a spark plug. Would you make a spark plug built in, baked into the engine? Of course not! When it comes to physical things, nobody in their right mind would do something like that. We would just "know" that spark plugs need to be separate. How do we know that?

  • We know that because the lifetime of an engine is much greater than the lifetime of a spark plug. We know that spark plugs have a tendency to get burned, or malfunction in other ways - due to their softer materials and complex construction (as opposed to a cylinder), and therefore they will have to be changed frequently; whereas an cylinder has a very long lifetime because it is made of metal, and therefore it will need to be changed less frequently.
  • We also know that because the problems that can cause an engine or cylinder to be changed are a different kind of problems rather than what could cause a spark plug to be changed: cylinder heads can get scratched or otherwise damaged from the friction with the cylinder, and this happens at a relatively slow rate (they're made of metal, and are lubricated with oil), whereas spark plugs are prone to all kinds of damage related to the wear of isolation components, due to electric currents passing through the conducting materials, or perhaps dampness, etc.

Thus, we can determine two basic rules that determine a concern of a component:

  1. The frequency, with which the components need to be changed.
  2. The reasons, for which the components need to be changed.

Effectively, an engine with separate spark plugs does not decide which spark plugs to use; instead, it is decided by the engineer who is assembling the car out of its components.

If some code needs to change at a different rate than other code, or for different reasons than other code, then we know that those two units of code have different concern.

As a direct result of the above, this gives us the corresponding benefits:

  1. One unit can be changed more frequently than another, without causing the need for that other code to change.
  2. One unit can be changed for different reasons than another, without causing the need for that other code to change.

Like this, we have made our code more adaptable to change. This gives us great flexibility, because we can choose any component of the system, and make it do stuff completely differently, according to our changing needs, without breaking or changing any other part of the software. With each iteration we write more and more code which does stuff; but this means that with each iteration we have more and more code that we need to keep in mind when doing these changes. And our mental capacity is limited, our minds can only hold so many things at once. When code is adaptable to change, the amount of things that we need to hold in our minds reduces by an order of magnitude, because things that are logically unrelated become actually unrelated, allowing us to focus on only one concern, without thinking of how it affects our ways of addressing other concern. This gives us tremendous agility, and development speeds do not fall but grow, because with every component we write there is one less component to write when we want to address the same concern again. This kind of agility - the ability to affect the greatest amount of change with the least amount of effort - is the ultimate type of agility that is more beneficial than any other kind of agility. This is very well explained by Rich Hickey in his talk "Simplicity Matters", which I take great inspiration from.

And so, for example, using a PSR-18 HTTP Client is good, better than directly using WP's HTTP client, because it allows us to de-couple ourselves from WP, and because it is re-usable, etc, making things easy. But we do not use a PSR-18 HTTP client because it's easy; we use it because it is the correct way to do it: a module, whose purpose is to access a REST API, does not decide which HTTP client to use; instead it is decided by the engineer who is assembling the application out of these components. And for the purposes of a WordPress plugin, they decide to use an HTTP client that makes requests through WordPress, because this is the appropriate transport that would ensure compatibility with WordPress and its extensions. All the benefits that this abstraction brings are therefore simply useful and desirable side effects of following the Separation of Concerns principle, which corresponds to Single Responsibility Principle - the S in SOLID.

An engineer should not be at the mercy of the components that they use; an engineer should be in control of components, assembling their system as it is necessary for the purpose of their task. It is an engineer's decision which components to use; it is not the decision of some other code that they are forced to use.

SOLID Principles

In practice, Separation of Concerns would mean making units of code smaller. If a class has too much responsibility, which we can understand by applying the rationale described above, we split it into multiple classes, and the same is applied to functions. We do that by establishing the concerns addressed by our unit, and creating multiple smaller units, each of which has a narrow and well-defined concern of its own. However, the original logic made all these concerns play together, and thus we need a way for one unit to invoke another, naturally causing our units to depend on other units. Our classes start depending on other classes, and these dependencies are most often fulfilled by using Dependency Injection: a class receives dependencies, including instances of other classes, via its constructor parameters. This is the process of configuration, as opposed to consumption, because it sets some values that are internal to the class, and that consumers of the class don't necessarily see.

Open-Closed Principle

With our units now addressing separate concerns, they move closer to being compliant with the Open-Closed Principle (OCP). If our unit is a class, for example, outside code cannot modify its logic, but it can change the logic of its dependencies, decreasing the need for modification, and thus making our system more adaptable to change.

There is an opinion that it is best to make classes final, i.e. non-extendable. This encourages Composition Over Inheritance, which is a good thing, but also brings about a slew of problems, such as the necessity to write forwarding method implementations. The most important drawback here is that a decorating class has no way of overwriting a method of the base class, even if the added or changed behaviour is simple. Ideally, all extension or modification of behaviour would be achieved by decoration; however, not giving developers the opportunity to change specific methods of the base class through inheritance assumes that the base is written perfectly, e.g. that it addresses only a single concern, injects all dependencies, etc. I conclude that composition should always be preferred over inheritance; but because it is not always possible or feasible, class authors should not put a hard boundary for inheritance, allowing it to be usable when necessary. For example, wen using a third-party library, it may be necessary to make changes to the behaviour of some service, and if (this happens often) the service has too much responsibility, developers are forced to either copy-paste code, or abandon the attempt to adapt the service altogether. Ultimately, these are two different concerns: service authors should not limit consumers of the service; at the same time, consumers should use services in a way that leads to better architecture.

Thus, separation of concerns leads to the Open-Closed principle, reducing (or localizing) the need for changes, and keeping code units open for extension.

Liskov Substitution Principle

A common way of adding responsibility is by sub-typing, which usually means either extension of a class, or by using composition in conjunction with extension of an interface. Either way, both methods lead to the creation of a sub-type - a descendant of a super-type, and this new type may add responsibilities by declaring additional methods. So if type A has method one(), its sub-type B may add responsibilities by declaring method two(), resulting this subtype having both methods. As a real-life example, a smartphone has all the characteristics of a phone in the sense that it allows one to make phone calls, but it also has additional features, such as the address book.

Following the smartphone example above, we can see that a smartphone is also a phone. This dictates that it must be usable as a phone, without the knowledge of any additional features if these features are not required. The Liskov Substitution Principle (LSP) codifies this, and is represented in the PHP engine itself in the form of certain inheritance rules, such as variance. Developers do not need to remember many of the aspects of the LSP, as they are forced to comply by PHP, which is moving closer to complete implementation of the principle every year through improvements in its syntax and the rules applied during compilation.

Thus, we can see that the Liskov Substitution Principle is derived from the Separation of Concerns principle, as it allows us to invoke the concerns of a sub-type inherited from its super-type without knowing of the specifics of the sub-type, allowing those specifics to change.

Interface Segregation Principle

When depending on an injected type, the consuming class promises that it will not use any API other than of that type. While providing a large degree of predictability, this is still not perfectly safe: the injected type may change in the future, possibly breaking some of the previously declared API. For this reason, consumers should use only the API they really need - see "Dependency Inversion Principle".

On the other hand, consumers can only depend on a type that is declared, and therefore, if a more narrow type is not available, they are forced to declare dependency on an API that they do not consume. The Interface Segregation Principle (ISP) codifies the rule that allows consumers to depend only on what they need, giving everything else the freedom to change.

Dependency Inversion Principle

Injection of services requires the declaration of dependencies, and the other SOLID principles tell us among other things how we should do that. But what of the type itself? If a class that contains the necessary functionality exists, is it fine to simply depend on it?

Not always. It makes no sense to declare dependency on an API that is not consumed, thus increasing useless coupling, and introducing discrepancy between actual and declared dependency. According to ISP, a type should declare an API that relates to only the specific concerns of that type. DIP is the opposite side of that: consumers should depend on only the API they need, in its most generic form. If type A declares method one(), and its sub-type B declares method two(), given that we are only going to use method one(), we must depend on type A, and not type B. Thus, DIP allows implementations to change freely as long as they do not break their dependents by making sure that declared dependencies correspond to actual ones.

Conclusion

Following SOLID principles results in software that is adaptable to change and resilient to negative effects of change, because it, among other things:

  • Makes sure that a unit of code changes only in response to changes of its concern.
  • Makes units of code de-coupled from logic that is not their concern.
  • Makes relationships between units of code very predictable, yet flexible.
  • Makes APIs more stable and reliable.
  • Lowers the complexity of individual components, making them easier to understand, use, and change.
  • Increases re-usability of individual components, making it possible to re-combine them for a different result.
  • Allows for better application of Package Management Principles.
  • Decreases maintenance costs as a result of the above.

However, the most important benefit, which could be made into a principle of its own is the following:

The responsibility of a software engineer is to understand the problem domain, and translate its processes and entities into a language that a computer can understand, thus creating a model of the problem domain.

The more accurate this model is, the more predictable, reliable, and flexible the behaviour of the software becomes. By following SOLID principles, software engineers can ensure that their models of problem domains are very accurate. These principles are natural, because they reflect the nature of logical concepts, and following them is a natural conseqence of normalization and optimization. This has many positive side effects, such as high maintainability, or a high level of re-use. These side effects appear automatically, as a consequence of the high level of model accuracy, and while desirable, they are not the main goal of SOLID principles; yet they will be naturally acquired simply by following these principles. Abstraction therefore is something that should be done immediately, as soon as the need for it arises; yet it is not for the sake of being there, but in order to correctly represent the problem domain by applying SoC.

[...] whatsoever is contrary to nature is also contrary to reason, and whatsoever is contrary to reason is absurd, and, ipso facto, to be rejected.
β€” Benedict de Spinoza, "A Theologico-Political Treatise", 1883

Top comments (0)