SOLID: 5 Principles of Software Design
Back in 2000, Robert C. Martin put out a paper Design Principles and Design Patterns to explain proper software architecture at the module level. This eventually led to the coining of the acronym SOLID which summarizes 5 key principles in object-oriented software development: the single responsibility principle, open-closed principle, Liskov substitution principle, interface segregation principle, and dependency inversion principle. In this article, we will explore these 5 principles, the context for their necessity, and what each part of the acronym means.
The Symptoms of Rotting Software
The software starts to rot. At first it isn’t so bad. An ugly wart here, a clumsy hack there, but the beauty of the design still shows through. Yet, over time as the rotting continues, the ugly festering sores and boils accumulate until they dominate the design of the application. The program becomes a festering mass of code that the developers find increasingly hard to maintain. Eventually the sheer effort required to make even the simplest of changes to the application becomes so high that the engineers and front line managers cry for a redesign project. - Robert C. Martin
Software design is not static. As it keeps growing, there are symptoms of software rot that can show up. Martin describes 4 symptoms of this rot. They aren't independent of each other, but they do have a distinctness to them:
- Rigidity in software development refers to the difficulty in modifying software, even simply, due to the dependencies and subsequent changes required in other modules. It can result in a time-consuming process where changes to one module cause a chain reaction of changes in other modules.
- Fragility refers to the tendency of software to break in multiple places every time it is changed, including areas that have no conceptual relationship to the change made. As the fragility worsens, the likelihood of breakage increases, eventually becoming so high that the software becomes impossible to maintain. Each fix introduces more issues than resolves, and the software's credibility is lost.
- Immobility refers to the inability to reuse software from other projects or parts of the same project. Modules or code that they were written before, cannot be reused because those modules are too closely tied to other software components or dependencies. This often leads to a situation where the work and risk required to separate desirable and undesirable parts of the software can be too great, and the software is simply rewritten instead of reused.
- Viscosity refers to the tendency of a system to resist change or make it difficult to do the right thing. It manifests in two forms: (1) viscosity of design and (2) viscosity of environment. Viscosity of design occurs when the design-preserving methods are harder to implement than the hacks. If the design-preserving methods are more difficult to implement, then the viscosity of the design is high. This can result in engineers taking shortcuts or making "hacks" to get the job done, which can ultimately degrade the quality of the system over time. Viscosity of environment occurs when the development environment is slow and inefficient. For example, if compile times are very long, it may be tempting to make changes that don’t force large recompiles, even if they are not optimal from a design perspective.
These are the symptoms that arise from poor architecture, which can cause the design to degrade over time. But what causes these symptoms to arise in the first place? Martin states that these problems can arise from changing requirements, where the initial design did not anticipate the new changes, or from dependency management, where improper dependencies between modules results in the dependency architecture degrading.
The SOLID Principles
The goal of SOLID principles in object-oriented design is to create software that is easier to maintain, understand, and extend over time. These principles aim to reduce the impact of change and make it easier to modify software without introducing new bugs or breaking existing functionality. By adhering to these principles, developers can create software that is more robust, reusable, and easier to test.
S - Single Responsibility Principle (SRP)
The Single Responsibility Principle states the following:
Software entities should have one responsibility and only one reason to change
In other words, each part of a program should have a single, well-defined responsibility or role.
When we design software using SRP, we aim to create classes or functions that are highly cohesive and have low coupling. What does this mean? Cohesion describes the degree to which the functions or elements within a single module or component are related to each other. A highly cohesive module is one where all the functions work together towards a common purpose. Coupling refers to the degree of interdependence between different modules or components of a software system. In other words, it describes how closely two or more modules are connected or reliant on each other. With SRP, we want modules to be less related to each other, but we want functions (and elements) within a single module to work well together.
By encapsulating a specific functionality within a module, class, or function, we make it easier to understand and maintain. A well-designed module, class, or function should have a narrow, well-defined purpose that is closely aligned with its responsibilities. This makes it easier to modify or update the code later on, as changes to one responsibility do not affect other unrelated parts of the code.
In practice, this means that a class or function should have only one reason to change. If a class or function has multiple responsibilities, it becomes more difficult to modify or maintain over time, as changes in one responsibility can unintentionally affect other parts of the code.
O - Open-Closed Principle (OCP)
The Open-Close Principle states the following:
Software entities should be written to be open for extension but closed for modification
This principle emphasizes the importance of designing software entities in a way that allows for easy extension without requiring modification of the existing code. Martin considers this to be the most important principle of object-oriented design, and it's closely tied to the concept of polymorphism. (If you want to learn more about polymorphism, visit this article)
So, how can we apply the OCP in our software development process? In Martin's paper, he explains it in the context of subtype and parametric polymorphism. In the context of subtype polymorphism, developers can create interfaces that indicate what needs to be extended. As the code evolves, new classes that extend this interface, while the other classes that already extend this interface don't change. By doing this, we can leave the original class implementation unchanged while allowing the behavior of the system to be extended through newly derived classes. Parametric polymorphism (generics) is another way to follow OCP, where a class can use delegates where the type of delegate is generic.
L: Liskov Substitution Principle (LSP)
The Liskove Substitution Principle states the following:
A subclass object can replace a superclass object without causing application errors
The concept was introduced by Barbara Liskov and Jeanette Wing, but it was popularized by Robert C. Martin. Essentially, the concept is saying that derived classes should be substitutable for their base classes. At face value, this principle may seem obvious, but there are nuances to consider.
For example, we know that a square is a type of rectangle, so in principle, the Square class can extend the Rectangle class. However, the Square class has a constraint that all its sides must be of equal length. The Rectangle class has no such constraint. This constraint makes it impossible to fulfill the LSP since a Square cannot always be substituted for a Rectangle without causing errors or unexpected behavior.
To ensure adherence to the Liskov Substitution Principle, we must consider the contracts of the methods. This includes the preconditions, which declare what must be true before the method is called, and the postconditions, which declare what the method guarantees will be true once it has been completed. A derived class is substitutable for its base class only if its preconditions are no stronger and its postconditions are no weaker than the base class method, meaning that derived methods should expect no more and provide no less.
I: Interface Segregation Principle (ISP)
The Interface Segregation Principle states the following:
Having many client-specific interfaces is better than having one general purpose interface
This design principle recommends creating specific interfaces for each client of a class, rather than one large interface to serve them all. This principle helps to accomplish 2 goals:
- No class should be forced to implement any interface methods that it does not use. This means that a class should only implement methods that are relevant to its functionality, and not be forced to implement methods it does not need.
- Instead of creating large interfaces that contain all the methods required by clients, multiple smaller interfaces should be created, each serving a submodule of the system. This allows clients to focus on the methods that are relevant to them, and not be burdened with unnecessary methods.
Implementing ISP in this way would result in a more maintainable and extensible codebase, as changes to one submodule of the system would not require changes to unrelated submodules.
D: Dependency Inversion Principle
The Dependency Inversion Principle states the following:
Depend upon abstractions. Do not depend upon concretions.
This principle states that high-level software entities should not depend on low-level software entities, but instead, they should depend on abstractions.
To implement the DIP, we must create abstract classes or interfaces that both high-level and low-level modules depend on, rather than modules depending on each other. This way, we can keep the coupling between them as loose as possible.
This principle also helps in creating code that is easier to test, maintain, and extend. By depending on abstractions instead of concrete classes, we can easily replace implementations without affecting the entire system.
Conclusion
Adhering to SOLID principles in object-oriented software development can prevent software from rotting or degrading over time, which can result in time-consuming and costly redesigns. By ensuring that software entities have one responsibility, are open for extension but closed for modification, follow the Liskov substitution principle, adhere to interface segregation, and follow the dependency inversion principle, developers can create software that is more maintainable, extensible, and reusable. By designing software with these principles in mind, developers can reduce the impact of changes, make it easier to modify software without introducing new bugs and avoid breaking existing functionality.
Hope you enjoyed this article! Until next time!