Software Design: The SOLID Principles in 10 Minutes
Agile development is the ability to develop software quickly in the face of rapidly changing requirements. Agility is achieved by:
- following practices that provide the discipline and feedback needed,
- writing clean code, and
- using design principles that keep our software flexible and maintainable; further, when required, applying design patterns to solve specific design problems.
It is easy to identify and agree with agile practices and clean code approaches. However, sometimes, it is wrongfully assumed that agile is all about coding, and design is irrelevant or does not exist. It is not the case.
The most important thing to have in mind about software design in an agile development process is that the design evolves along with the code. The design is continuously improved and continuously mean every day. In agile, software design is a process, not an event.
Yes, the most valuable asset is the code. That is the product that we want to deliver. However, a poor design can jeopardize the quality of this product. The design, just as the code, should be clean. When the design is not clean, it smells, which in consequence makes the code:
- hard to change (rigid),
- easy to break (fragile),
- hard to reuse (immobile),
- disorganized (opaque),
Further, a design that is more than what we need is also a smell. So, abstract classes, interfaces, design patterns, and other infrastructure elements that do not solve a problem and are there to over-design the system are as bad as their absence when needed.
Eliminate design smells is the goal of design principles. Design principles are the product of decades of experience of a large number of developers and researchers. They are not just someone’s occurrence. We apply principles to eliminate smells when they appear. We do not use them when there are no smells. As Robert Martin stated:
design principles are not a perfume to be liberally scattered all over the system
Let’s talk about design principles, what they define and how they help us. There are five design principles to consider: Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Dependency Inversion Principle (DIP), and Interface Segregation Principle (ISP).
Each design principle identifies particular smells and has associated recommended heuristics for a solution. Remember “heuristics” are technique or approaches to problem-solving that employs a practical method that is not guaranteed to be optimal, perfect, or rational, but is nevertheless sufficient for reaching an immediate, short-term goal or approximation.
1. Single Responsibility Principle (SRP)
A class should have only one responsibility.
Why? Because each responsibility implies a possibility of a change. If a class has more than one responsibility, then the responsibilities become coupled. Therefore, changes to one responsibility may impair or inhibit the ability of the class to meet the other responsibilities. This kind of coupling leads to fragile designs that break in unexpected ways when changed. SRP is all about understanding when and how to apply decoupling.
Heuristics applied to achieve decoupling include the use of design patterns, specifically: facade, proxy, and delegate.
Imagine that you need a piece of software to read data from diverse sensor devices (a heart rate monitor, a brain-computer interface, a skin conductance sensor, etc.). And, you need to store that information for future use also. For some sensors, we need to gather data directly from a serial port. For others, we use a WebSockets (third-party APIs help us get data from the physical device). To store data, we want to be able to store data in a local file (text file) or in a database. Thus, collect data and store the data. Simple!.
A novice programmer will immediately jump to code a single class Sensor with some attributes, methods to gather data, and methods to store data. Maybe methods like: gatherDataSerialPort(), gatherDataWebSocket(), saveDataFile(), saveDataDatabase().
One class with one responsibility (be a sensor), right?. Wrong! Here is the point in which be a programmer is not the same that be a software engineer. Our example is expressing two responsibilities (not one): gather data and store data. And, we need to decouple them. How?
Well, our Sensor is a facade for something that needs to read and write things — a familiar face for two responsibilities. Moreover, we could use some proxies or delegates for gathering data and storing data. Then we can deal with the details and complexity of that in different classes. A draft of a possible solution would look as shown in figure 1.
We applied SRP heuristics because we sense the smell of fragility. Even a novice programmer will notice sooner or later the problem with the initial solution of one class with four methods. We do not create interfaces and use patterns for fun or to make our code look complex. We did it to make our code better, extensible, modifiable, etc.
2. Open-Closed principle (OCP)
Software entities (functions, classes, modules, etc.) should be open for extension but closed for modification.
A core goal in software design is to create entities that can be extended without modifying the existing source code. OCP is all about achieving changes adding new code, not changing the old code that already works. Because we want to avoid that a single change results in a cascade of changes in dependent entities — this is a smell of rigidity.
Let me be clear: closure cannot be complete. There will always be some change against which the entity is not closed. Thus, the closure must be strategic. As a developer, make educated guesses about the likely kinds of changes that the application could suffer over time.
simply and effective heuristic to satisfy OCP is inheritance — a behavior can be extended in a subclass without modifying the superclass. Additionally, some design patterns are helpful, including the strategy pattern and the template method pattern.
Imagine you are asked to create a program to draw geometric shapes on screen. We want to draw circles and draw squares; maybe later, we would ask for drawing triangles or something else.
Our novice programmer will probably jump to code a class and include some attributes and probably methods like drawCirlce()and drawSquare(). Our novice programmer will assume that later, a drawTriangle() could be added and more. A perfect solution, right? No, it isn’t good at all.
“Open for extension but closed for modification” means that we do not want to modify the class, i.e., write code into the class. It is not like you are working on a school programming assignment. Once you create a class and put that class in a production environment, you do not want to touch that class.
So, what can we do?
Use inheritance. Create a family of geometric shapes and put in each class its own method draw(). When you need new geometric shapes, you can add a new class to the family with its method draw(). You can extend, and you do not need to modify what already exists. A draft of a possible solution would look as shown in figure 2.
OCP is core for flexible, maintainable, and reusable. However, do not load the design with lots of unnecessary abstract classes and interfaces. Use them only to these parts of the software that exhibit frequent change. Remember that resisting to apply premature abstraction is as important as creating abstraction itself.
3. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for all their base types.
That principle is the answer proposed by Barbara Liskov (1988) to the questions:
- What are the characteristics of the best inheritance hierarchies?
- What are the traps that could create hierarchies that jeopardize the OCP?
Guaranty that a subclass will always work where its superclasses are used is a powerful way to manage complexity. Moreover, it is a fact that a violation of LSP is a latent violation of OCP.
What could be a case in which a subclass is not able to replace its superclass? When that subclass eliminates a behavior that it inherits from the superclass. The parent has it, but the child removes it. And, that is wrong, remember the object-oriented statement: a child should always be better than its parent. And, “better” means more behaviors, not less.
When dealing with inheritance and polymorphism, remember that,
- Common responsibilities should be inherited from a common superclass.
- A subclass does not do less than its superclass — never remove functionality.
- When defining a family of classes, always think: “these two classes should be connected as parent and child, or they could work better as siblings.”
- Also, along these lines, a method in a subclass does not throw exceptions whose superclass does not throw.
Imagine you already have a class Circle, and you are asked to create a class Cylinder. Or, maybe you have a class Rectangle, and you are asked to create a class Square (a square is a rectangle with the same width and height). Or, you have a class LinkedList, and you are asked to create a class PersistentLinkedList (one that writes out its elements to a stream and can read them back later).
If you are tempted to use inheritance from Circle to Cylinder, or from Rectangle to Square, or from LinkedList to PersistentLinkedList, i.e., create a parent-child relationship for any of these cases, you will have problems.
- The class Cylinder would eliminate the method calculateArea() in Circle since calculating an area does not make sense. It is impossible to use our Cylinder object to replace a Circle object.
- The class Square will make the methods setWidth() and setHeight() to modify both width and height attributes (they are equal in a square, right?). Therefore, it will be impossible to use a Square object to replace a Rectangle object.
- The class PersistentLinkedList needs persistent (serializable) objects while LinkedList does not. Moreover, probably, PersistentLinkedList would need to throw some exceptions.
In most of these cases, the solution is to relate the classes as siblings instead of parent and child.
Keep an eye on LSP because LSP enables OCP.
4. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Traditional procedural programming creates software structures in which high-level modules depend on low-level modules. Therefore, changes to the lower-level modules can directly affect the higher-level modules and force them to change. When higher-level modules depend on low-level modules, it became challenging to reuse those high-level modules in diverse contexts.
The dependency structure of a well-designed object-oriented program is “inverted” with respect to the dependency structure that generally results from traditional procedural methods. DIP is what makes software fulfill the object-oriented paradigm. It is not about the programming language you are using. It is about your mindset. If your software does not follow DIP, you are programming procedurally; it does not matter that you are using an object-oriented language.
Robert Martin points out a candidate solution in what he calls the Hollywood Principle: “do not call us, we will call you.” It implies the following:
- Review DIP whenever one class sends a message to another. DIP is about calling methods.
- When doing that, depend on abstractions (use abstract classes or interfaces).
Imagine that you are asked to create a program with a UI showing you some questions (a quiz). You will answer the questions. Then your answers need to be graded, and your grade stored in the system.
Our old friend, the novice programmer, will probably jump to create an initial design: we need a UI module, a module to provide the quiz (with the questions), a module to grade them, and a module responsible for storing the grades. Quite simple. And we can apply an architectural pattern here. It is an opportunity for a layered architecture with UI, business functionality, and data management. So, three layers will look good; however, if you are thinking of a model like the one shown in picture 3A. That is procedural programming, and it is not an object-oriented approach.
What? Yes, Take a look and notice “high-level modules are depending on low-level modules.” An object-oriented implementation of a layered architecture for our problem should look like the one shown in Figure 3B because in that one, “high-level modules do not depend on the low-level modules; and, all modules depend on abstractions.”
DIP is a core principle for object-oriented framework design. But remember, we applied DIP heuristics because we sense the smell of in-mobility. As mentioned before, we do not create interfaces and use patterns for fun or make our code look complex. We did it because we need it to make our code better.
5. Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods that they do not use.
ISP deals with the disadvantage of “fat” interfaces (or abstract classes). Fat interfaces (or abstract classes) cause bizarre and harmful couplings between their clients. ISP recommends to broke up interfaces with a lot of methods into several interfaces.
When you notice an interface or an abstract class is becoming fat, it could be time to separate its content (methods). How? Two options:
- To create a new interface (or abstract class). If that makes sense, or
- To through delegation by applying the delegate pattern.
Let’s reuse the example above. We create an interface DataHandlerService. It works for the example above to exemplify the need for a module that deals with data handling. The example above is just about Quizzes. What if that system evolves and we would like to handle grades for exams and assignments. Moreover, final grades per course. Furthermore, students enrollment or instructors and staff hiring. You got the idea, right? Time to create a family of interfaces for data handling.
To conclude, do not forget, in agile, design principles are not applied to a big-upfront design. Instead, they are used from iteration to iteration to keep the code and design clean. During your daily work:
- Write clean code.
- Detect smells.
- Diagnostic problems by using design principles.
- When needed, solve problems by using the appropriate techniques or design patterns.
For the interested reader, I highly recommend reading the following books:
- Agile Software Development by Robert Cecil Martin (~550 pages)
- Refactoring by Kent Beck and Martin Fowler (~460 pages)
- Design Patterns by E. Gamma et al. (~450 pages)