At Codavel, we use SOLID principles to create a development proposition where merging becomes an almost trivial task. They allow us to take full benefit of lean strategies, with integration and build processes that are not nerve wrecking. This is the first of a series of posts that will explain to you how we can apply SOLID principles in writing modular network applications.
- Single responsibility principle (SRP)
- Open / closed principle (OCP)
- Liskov substitution principle (LSP)
- Interface segregation principle (ISP)
- Dependency inversion principle (DIP)
Starting, obviously, by the first one!
Fundamentalism? No, thank you
But before getting there, we should recall that SOLID principles are just that: principles. It doesn’t mean that they should be applied blindly. In fact, they could do more harm than good to your productivity if not used wisely. I really like the approach described by Steve Smith (@weeklydevtips): make it work, make it right, make it fast.
For me, this is the proper flow for applying SOLID in the software dev process. Create your MVP and, once it’s working, refactor it to the point where it’s extensible enough, maintainable enough, coherent enough and clean enough. A huge benefit of this refactoring process is that it will force you to think about which things will be likely to change along the product evolution. This exercise will show you exactly where and how you can apply SOLID principles.
SOLID: The Single Responsibility Principle
Now that we’re done with introductions, let's dive into the first SOLID principle: Single Responsibility Principle.
“A class should have one, and only one, reason to change.” - Robert C. Martin
Why is this principle important, you may ask? Well, software components are an ever-evolving entity.
You should already know that:
- Requirements may change over time: there is no clear way to fully predict the features your product will need. Your application may need to support a different type of encryption or you may need to support another type of I/O.
- Implementation of classes may change for performance reasons: you may find a more efficient algorithm for encoding/decoding data packets or you may realize later on that some sorting algorithm is better suited for the bulk of the data your application manages.
- Implementation of classes may change for architectural reasons:you may need to extend an API to account for a new variable or you may face a performance bottleneck that requires you to re-design the architecture of a component, e.g. switch from sequential packet processing to parallel packet processing.
If your classes do not follow the Single Responsibility Principle, chances are that you’ll have "God-like" classes with thousands of lines and multiple responsibilities. This means you'll probably end up changing a bunch of code lines that don’t need changes and introducing some bugs along the way.
What should I do, then? Well, make sure you follow this principle – especially if it’s a class that is expected to change a lot. If you start describing the responsibility of your class as a set of different properties or functions, it is highly likely that you are not properly defining a responsibility.
Responsibilities should be really low-level features, rather than conceptual responsibilities. They are something very specific almost related to a single operation that you want to do.
A SOLID client-server application
Let's assume we are writing code for a product that is a client-server type of application. Following an MVP logic, we would first write down a simple server and client code and make sure that they are able to communicate.
On the server side we would have a simple Server class that would implement the following flow:
- Listen for incoming connections
- Accept incoming connections
- Receive / process requests and send responses
- Closes when nothing else is required
On the client side we would have a simple Client class that would implement the following flow:
- Connect to server
- Send request
- Receive and process response
- Close when nothing else is required
With a working prototype of a server and client that are able to communicate, we have the "make it work" part figured out. Now we have to make it right. If we would ask what's the responsibility of the server and client, the answer would probably be the list of steps mentioned above. The server and the client have more than one responsibility, violating the SRP.
So let's refactor the server. What classes can we extract from the server? Clearly, we can extract a class for handling connections (say ConnectionHandler) and a class for handling requests (say RequestHandler). Is it done, now?
Refactor, refactor, refactor
If we question ourselves about what is the responsibility of the ConnectionHandler, we understand that it probably still has more than one responsibility: it may need to manage multiple connections from different clients and their respective configurations.
While this seems a single responsibility from a conceptual point of view, there are actually multiple responsibilities associated. The most obvious being handling a multiplicity of connections and the individual connections themselves (in particular, their associated events).
We can actually refactor the ConnectionHandler into ConnectionManager and Connection classes: one with the responsibility of managing a list of connections and the other with the responsibility of handling a single connection.
The great strength of using this methodology is that you are preparing for future changes ahead, while at the same time writing highly maintainable and testable code.
Depending on the application logic, we may further refactor these classes. For instance, suppose we want to support TCP and UDP connections, we could further refactor the Connection class into two classes UDPConnection and TCPConnection; and so on, and so on...
Similarly, we can try to refactor the RequestHandler class to account for its multiple responsibilities: receive a request, process request and reply to the request. Three more classes would be introduced. Again, these classes can be further refactored: for instance, we may need to support communication via HTTP, FTP, pure JSON, etc. For each of these types, we would have a different class for each of the types and responsibility.
Make it scalable
The great strength of using this methodology is that you are preparing for future changes ahead - while writing highly maintainable and testable code. Instead of having a God server class, we end up having a modular architecture. It allows us to plug-and-play different protocols according to your need, without writing any more code other than the new class.
It then becomes much easier to understand how your application can scale (you can easily isolate the connection part and create stress tests for it). You can see more clearly where it could break (for instance if you know your FTP client is not working, you just need to look into one class).
That is the beauty of SPR: follow it and get decoupling for free.