Partager
January 31, 2023
The 5 SOLID principles explained to developers
Code reviews are one of the tools used by developers to find problems as early as possible. However, it is complicated to identify those related to SOLID principles if you don't know what to look for. So, instead of offering you yet another article that just defines the principles, I propose to show you how to recognize them and fix them if needed, with code examples based on a video game design.
Why apply SOLID principles
SOLID principles tell us how to organize our functions and data structures into classes and how these should be interconnected.
Please note that although we use the word class, this does not mean that these principles are only applicable to object-oriented development. SOLID principles apply to all groupings of functions and structures, even if they are not really in a class (in the object-oriented programming sense).
The goal of these principles is the creation of modules that:
- tolerate change;
- are easy to understand;
- are the basis of components that can be used in any type of system.
The theory of SOLID principles was introduced in their current form, but in a different order, in 2002 by Robert C. Martin (also known as Uncle Bob). However, it was not until a couple of years later that Michael Feathers suggested rearranging them to create the acronym SOLID.
The SOLID concepts are as follows:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Single Responsibility Principle (SRP)
The single responsibility principle states that a class should have only one reason to change. Separating responsibilities in different classes is important because if a class has several responsibilities and one of them changes, it could impact the other and could cause unintended effects.
Note: All the examples below are inspired by Leek Wars, a programming game in which you have to create the most powerful leek and destroy your enemies.
Consider the following class diagram:
In this example, the LeekAI
class has several responsibilities:
- move around the map;
- attack a leek;
- heal a leek;
- play its turn by orchestrating the three previous actions.
One reason to modify the LeekAI
class would be to change the way you move. Another would be to change how the leek moves on the map. Since we have two reasons to modify this class, we have violated the single responsibility principle and are introducing excessive coupling.
To fix this, we would have to extract these methods into their class:
With this new breakdown, we have removed our excessive coupling. The LeekAI
class has only one reason to change: if we change the order of our game phases during our turn.
Even though the single responsibility principle speaks of a class, it can also be applied at the level of a function.
Take this possible implementation of MoveAI
:
class MoveAI {
move(leek: Leek, reason: Reason, cell: number) {
if (reason === Reason.Attack) {
// Calculate the best position to attack the cell
// Move toward the best position to attack
} else if (reason === Reason.Retreat) {
// Move forward the cell
}
}
}
The move
function performs several tasks, so it violates the principle of single responsibility. It performs a different move depending on the reason and in case of an attack it also calculates the best position.
Fixing this problem is trivial, just create dedicated functions.
class MoveAI {
move(leek: Leek, reason: Reason, cell: number) {
if(reason === 'ATTACK') {
this.moveToAttack(leek, cell);
} else if (reason === 'RETREAT') {
this.moveAway(leek, cell);
}
}
private moveToAttack(leek: Leek, cell: number) {
const bestCellToAttack = this.getBestCellToAttack(leek, cell);
this.moveToward(leek, bestCellToAttack);
}
private getBestCellToAttack(leek: Leek, cell: number) {
// Calculate the best position to attack the cell
}
private moveToward(leek: Leek, cell: number) {
// Move toward the best position to attack
}
private moveAway(leek: Leek, cell: number) {
// Move forward the cell
}
}
Open-Closed Principle (OCP)
The open-closed principle states that a software entity must be open to extension but closed to modification.
A software entity that is open to an extension means that the behavior of a module can change, for example, when the specification is changed. However, it must be closed to modification to prevent a module from changing when its behavior is changed. This may seem counter-intuitive but the key to applying this principle is abstraction! Indeed, if a module references only abstractions it is possible to add an implementation without modifying it.
For example, the following code does not respect the open-closed principle:
class LeekAI {
private readonly moveModule = new SimpleMoveAI();
private readonly attackModule = new SimpleAttackAI();
public play() {
// ...
moveModule.move(me, Reason.Attack, enemy);
}
}
In the code above, LeekAI
depends on the concrete classes SimpleMoveAI
and SimpleAttackAI
. If we want to change the way our AI moves but these two classes cannot be modified (they come from a third-party library for example), we would have to modify LeekAI
directly, which would imply that our class is open to modification, which is exactly what we should not do!
To solve this problem, we should create abstractions of SimpleMoveAI
and SimpleAttackAI
and use these abstractions in LeekAI
so that we can change the way we move or attack without changing our module.
It is then possible to modify our class in this way:
class LeekAI {
constructor(
private readonly moveModule: MoveAI,
private readonly attackModule: AttackAI
) {}
public play() {
// ...
moveModule.move(me, Reason.Attack, enemy);
}
}
The LeekAI
class no longer knows the concrete implementations for moving and attacking. If we want to change their behavior, we no longer need to modify LeekAI
and are therefore closed to modification. If, for example, we want to set up a new way of attacking, we need to create a new implementation of AttackAI
: we are open to extension.
We will see how to instantiate the right MoveAI
and AttackAI
implementations in the section on the dependency inversion principle, as the two principles are necessarily linked.
Liskov Substitution Principle (LSP)
Liskov's substitution principle can be paraphrased as follows: subtypes must be substitutable for their base types. It is named after Barbara Liskov and describes how subtypes should be able to be used instead of their supertypes without breaking the functionality of the program.
We are talking about a subtype, not a subclass. A subtype has a stricter definition and is used to indicate that the type was designed to replace its supertype. A subclass relationship does NOT imply a subtype relationship.
There are a few heuristics that can give you clues about Liskov's substitution violation. They all have to do with derived classes that somehow remove functionality from their base classes. A derivative that does less than its base is generally not substitutable for that base and thus violates the Liskov substitution principle.
For example, if SmartLeek
derives from Leek
:
class Leek {
useWeapon(weapon: Weapon): boolean {
// ...
}
}
class SmartLeek extends Leek {
useWeapon(weapon: Weapon): boolean {
// ...
}
}
As a derived class, SmartLeek
must adhere to the following rules:
- Its preconditions (the assertions made about its state before executing the function) of useWeapon of SmartLeek must not be stronger than those of
Leek
. - Its postconditions (the assertions made about its state after executing the function) must not be weaker than those of
Leek
. - It must not throw an exception where
Leek
does not. - Its behavior must not be different from that
Leek
.
Interface Segregation Principle (ISP)
This principle states that several specific interfaces are preferable to one general interface.
Look at the following code snippet:
interface CanUseChipAndWeapon {
setWeapon(weapon: number): void;
useWeapon(entity: number): number;
useChip(chip: number, entity: number): number;
}
class Leek implements CanUseChipAndWeapon {
setWeapon(weapon: number) {
// [...]
}
useWeapon(entity: number): number {
// [...]
}
useChip(chip: number, entity: number): number {
// [...]
}
}
class Bulb implements CanUseChipAndWeapon {
setWeapon(weapon: number) {
// Do nothing
}
useWeapon(entity: number): number {
// Do nothing
}
useChip(chip: number, entity: number): number {
// [...]
}
}
In the example above, we have two classes Leek
and Bulb
that implement CanUseChipAndWeapon
. However, Bulb cannot use a weapon but is forced to implement both setWeapon
and useWeapon
methods.
To fix this problem, we need to split this interface into two smaller ones.
interface CanUseChip {
useChip(chip: number, entity: number): number;
}
interface CanUseWeapon {
setWeapon(weapon: number): void;
useWeapon(entity: number): number;
}
class Leek implements CanUseChip, CanUseWeapon {
setWeapon(weapon: number) {
// [...]
}
useWeapon(entity: number): number {
// [...]
}
useChip(chip: number, entity: number): number {
// [...]
}
}
class Bulb implements CanUseChip {
useChip(chip: number, entity: number): number {
// [...]
}
}
This division allows the modules to be independent of each other and facilitates reading and refactoring.
Dependency Inversion Principle (DIP)
As said before, the dependency inversion principle is strongly linked to the open-closed principle. Indeed, it is this principle that makes the open-closed principle possible.
The dependency inversion principle states:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
To clarify, let's go back to the open-closed principle code:
class LeekAI {
private readonly moveModule = new SimpleMoveAI();
private readonly attackModule = new SimpleAttackAI();
public play() {
// ...
moveModule.move(me, Reason.Attack, enemy);
}
}
Here, LeekAI depends on two low-level modules, so we don't respect the dependency inversion principle. To correct this, we will use the inversion of control (IoC). Let's review how we fixed this problem for the open-closed principle.
class LeekAI {
constructor(
private readonly moveModule: MoveAI,
private readonly attackModule: AttackAI
) {}
public play() {
// ...
moveModule.move(me, Reason.Attack, enemy);
}
}
This time, our module depends on abstractions and our implementations depend on an interface as we can see on the class diagram.
Finally, to link concrete implementations and abstractions, we use dependency injection (DI).
class LeekAI {
constructor(
private readonly moveModule: MoveAI,
private readonly attackModule: AttackAI
) {}
public play() {
// ...
moveModule.move(me, Reason.Attack, enemy);
}
}
// Somewhere else in your code
const ai = new LeekAI(new SimpleMoveAI(), new SimpleAttackAI());
ai.play();
Conclusion
In this article, we've looked at the SOLID principles, including identifying where they're not being followed and how to fix them. By following these guiding principles for your modules, you will have a solid foundation for assembling them and creating the components that will form your application.
Bibliography
- Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices. Pearson, 2002.
- Robert C. Martin. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall, 2017.