Software development is difficult. One of the ways to overcome difficulties of building software is to use so-called architectural patterns, for example well known SOLID principles.
But trying to apply these principles in Elixir we often hesitate:
- Could these principles be even applicable? Don't they refer to classical OOP languages, while Elixir is a functional language?
- Are these principles useful at all? Does this stuff even refer to programming practice and not only to "clever" books with fictional class hierarchies of rectangles and squares? This is even more disturbing when our everyday work consists mostly of putting code into predefined places provided by "frameworks": views, controllers, models, etc.
This article is a small attempt to illustrate positive answers to these questions with real issues of a real OSS Elixir library (SMPPEX).
The structure of this protocol is not very important, it's enough to say that it is an asynchronous binary protocol over TCP.
SMPPEX is based on ranch and handles TCP connections in the following way:
We need to say a few words about behaviors. Behaviors is an Erlang way for specifying interfaces. Each Erlang (and Elixir) module can define so-called
defmodule Foo do @callback bar(a :: pos_integer(), b :: String.t()) :: String.t() end
Then, any module can implement this behavior:
defmodule FooImpl do @behavior Foo @impl true def bar(a, b) do String.duplicate(b, a) end end
After that this module can be safely used whenever
Foo beahaviour is required.
def do_smth(foo_impl_module) do ... s = foo_impl_module.bar(5, "abc") ... end ... do_smth(FooImpl)
There are two main modules for handling sessions (see picture).
SMPPEX.TransportSession. It works directly with TCP socket, transforms binaries from socket to protocol structs (PDUs) and vice versa, sends PDUs and handles TCP errors. It also defines
SMPPEX.TransportSessionbehavior: callbacks for more intellectual PDU handling.
SMPPEX.Session. It implements
SMPPEX.TransportSessionbehavior. Responsibility of
SMPPEX.Sessionis to make pings, handle timeouts, correlate request PDUs with response PDUs and so on.
SMPPEX.Sessionalso defines its own behavior: callbacks for placing user defined logic.
So, when implementing an SMPP "server" or "client" (called MC and ESME respectively), one should implement
We see that Elixir allows to define interfaces and to provide polymorphic access to programming entities. That's a major part of OOP (if not the whole OOP according to some definitions). We also have a real example of using this mechanisms in practice.
This allows us to conclude that Elixir code can be a subject of analysis through SOLID principles.
But is this practically useful?
SMPPEX is a library for quite a specific domain, that's why it is not super popular, but still has a worldwide user base. From time to time users submit their reports and improvement suggestions.
Telemetry is an Erlang library for tracing arbitrary system events and for handling them independently.
This functionality seemed to be quite useful, but the proposed solution made me think a bit. After some consideration I realized that I wanted to see another solution to problem.
What made me think so? I think that was subconscious expectation of possible difficulties with supporting this code, but it were the SOLID principles which helped me to come to rational explanation of the feelings.
S in SOLID stands for The Single-responsibility principle:
There should never be more than one reason for a class to change.
Obviously we have no class, but have a module. It is often somewhat difficult to understand what does "reason" mean. That's why I often use another formula of this principle:
There should never be more than one party interested in modifying a unit of code.
Obviously, we were going to violate this principle: there were two apparent parties which wanted to share a single module (and even function): me as a supporter of the whole library and the team which needed this functionality and made the PR.
O in SOLID stands for The Open-closed principle:
Software entities ... should be open for extension, but closed for modification.
In other words, if we want to add new functionality, our architecture should allow us not to put it into existing classes and functions, but to compose new entities and utilize them.
Obviously, we were going to violate this principle too: completely new and unrelated functionality was going to be added to an existing function.
Probably we shouldn't be strongly concerned if some theoretical constructions are wrecked. That's why we need to demonstrate real-life issues which arise when these principles are violated.
Violating S-principle and mixing concerns in a single function or module lead to inconveniences for all parties interested in the code.
Obviously, as a maintainer of the library, I want the core to be well documented, well tested, and so on. After receiving the new code it would have been harder for me to test code, to modify it and even understand.
On the other hand, the team which needs this code would like to modify it as fast as is needed for their business and not to wait for my spare time.
Even if this functionality is worth being included (and it actually is), we often want to have different "tiers" of support for our code. Our "core" code should be well tested, well documented and have wider compatibility (and in case of SMPPEX it really is: see documentation and compatibility matrix).
At the same time supplementary code may not be so well documented or may be compatible only with newer versions of tools.
As can be seen, the proposed PR brings a new dependency:
telemetry. Although it is a useful library, additional dependencies often bring problems and conflicts. It an issue even in such a small library as SMPPEX (see, for example, this issue), that's why it tries to depend only on Ranch.
We want to allow users of the library to resolve conflicts in minor functionality (even, for example, forking or reimplementing it) and still use the stable core.
telemetry is still at
0.x versions and one may just not want to depend on
So my proposed solution was:
Thanks for the proposal.
But as far as I can see, the proposed solution violates the Open-closed principle. We shouldn't add orthogonal functionality to existing modules and functions.
One may do the following not to violate the Open-closed principle. Since
SMPPEX.TransportSessionbehavior, it is possible to create a "proxy" session which
- Contains only telemetry-sending logic.
- Passes all the handling to the encapsulated
Moreover, this can be probably implemented without changing original modules at all.
SMPPEX.TelemetrySession is the mentioned "proxy"
This complies with the mentioned SOLID principles.
- We do not violate S adding new functionality into a new module.
- We do not violate O because we extend functionality composing unchanged old entities and new ones.
A bit later I was glad to find out that the team tried and successfully implemented this approach!
Following SOLID principles allowed them to avoid many inconveniences: not to wait for the maintainer (me), not to fork the project just to add some lines of code. I didn't even know that the work was in progress!
Later I implemented the solution as "batteries": savonarola/smppex_telemetry.
You can see that this is a standalone library, which has a smaller compatibility matrix and it is enough for it to have just a README. It also has a dependency which the core library doesn't have.
I think this is a small but good example of how SOLID helps to build software and to make communications simpler. So we can frankly answer to the second question: yes, SOLID principle can be applied in practice and can help in better software building.