Let's talk: SOLID Principles 🇬🇧
FAMCHON Baptiste
Posted on September 19, 2024
Why do we need principles ?
Software would be a pain without clean code rules. But getting well written bricks of code is not enough: you can make a mess if those bricks are not well assembled.
That's were architecture principles comes in.
S.O.L.I.D. rules stands for:
- SRP: Single Responsibility Principle
- OCP: Open Closed Principle
- LSP: Liskov Substitution Principle
- ISP: Interface Segregation Principle
- DIP: Dependency Inversion Principle
Single Responsibility Principle
| A module should be responsible to one, and only one, actor.
Let's understand this principle with an example.
class Employee {
calculatePay()
reportHours()
}
A product got two methods to calculate a pay and report working hours of an employee.
Calculate pay is used by an Actor responsible of financial stuff, and hours reporting by another Actor, a manager for example.
We broke the Single Responsibility Principle.
Furthermore, imagine that these methods have some lines of code in common because we care about code reusability.
A change in the common lines of code would satisfy the result of a method but can make the second fail.
Open Closed Principle
| Code entities should be open to extensions and closed to modifications
class Player {
printRole() {
switch(this.role) {
case "elf":
console.log("Player is elf !");
break;
case "warrior":
console.log("Player is warrior !");
break;
case "..."
}
}
}
What happens if you need to add another Player role ?
You will have to modify the existing function and then violate the principle.
To be compliant with the principle, you will need to use abstractions.
class Player {
constructor(private readonly role: PlayerRole) {}
printRole() {
console.log("Player is ", this.role.getRole());
}
}
interface PlayerRole {
getRole(): string
}
class RoleElf implements PlayerRole {
getRole() {
return 'elf';
}
}
class RoleWarrior implements PlayerRole {
getRole() {
return 'warrior';
}
}
const player = new Player(new RoleWarrior());
player.printRole();
This way, if you want to add a new Role, you will not have to modify existing code, but only implement PlayerRole.
Liskov Substitution Principle
| When extending a class, remember that you should be able to pass objects of the subclass in place of objects of the parent class without breaking the client code.
Imagine this Player class, we define a property and a method to make player flying...
class Player {
items: string[];
flying: boolean;
fly(): {
this.flying = true;
}
}
class Warrior extends Player {
fly(): void {
throw new Error("Warriors can't fly, only Elves can !!!!");
}
}
But we break the Liskov principle as defining a subclass of Player, Warrior, may break the code.
Let's see how we can be compliant easily.
class Player {
items: string[];
}
class Elf extends Player {
items: string[];
flying: boolean;
fly(): void {
this.flying = true;
}
}
Interface Segregation Principle
| No client should be forced to depend on methods it does not use.
Let's start by an example which breaks ISP.
interface CharacterActions {
throwMagicPower(): number;
speak(): string;
}
class PlayableCharacter implements CharacterActions {
throwMagicPower(): number {
return 100;
}
speak(): string {
return "I am a playable character";
}
}
class NonPlayableCharacter implements CharacterActions {
throwMagicPower(): number {
// not applicable to NPC...
return 0;
}
speak(): string {
return "I am a non playable character";
}
}
This breaks ISP because NonPlayableCharacter
is forced to depend of method throwMagicPower
it doesn't use.
To be compliant, we can split CharacterActions
into smaller interfaces and only implement when needed.
interface Speaker {
speak(): string;
}
interface Magician {
throwMagicPower(): Damage;
}
class PlayableCharacter implements Magician, Speaker {
throwMagicPower(): Damage {
return 100;
}
speak(): string {
return "I am a playable character";
}
}
class NonPlayableCharacter implements Speaker {
speak(): string {
return "I am a non playable character";
}
}
Dependency Inversion Principle
| Depend upon abstractions, not concretions.
Dependency Inversion proposes that instead of depending on concrete class implementations, we should depend on abstractions.
Even more important when it concerns business logic !
Let's see what would be a bad code example
class NotifyUserUseCase {
constructor(private readonly emailSender = new EmailSender()) {}
execute({order, buyer}) {
const text = `An order of ${order.price}${order.currency} have been created with your account`;
this.emailNotificationSender.send({
text,
to: buyer
})
}
}
Here, our notify use-case is directly dependent of EmailSender.
First, it would be hard to unit test our use-case because we cannot easily fake our Notifier.
Secondly, we're coupled with an implementation detail: what if our product owner want to change from mail to SMS ?
Here is a proper version implementing DIP
interface NotificationProvider {
send(): void;
}
class EmailNotificationProvider implements NotificationProvider {
send(): {
// stuff to send email
}
}
class SMSNotificationProvider implements NotificationProvider {
send(): {
// stuff to send SMS
}
}
class FakeNotificationProvider implements NotificationProvider {
send(): {}
}
class NotifyUserUseCase {
constructor(private readonly notificationProvider: NotificationProvider) {}
execute({order, buyer}) {
const text = `An order of ${order.price}${order.currency} have been created with your account`;
// business logic don't care about notification being a SMS or Mail
this.notificationProvider.send({
text,
to: buyer
})
}
}
I'm Baptiste FAMCHON, Tech Lead specialized in frontend at Claranet.
I write regularly on dev.to and LinkedIn about web and software crafting topics.
At Claranet, we can also help you think about IT modernization, cloud infrastructure, security and web development.
Don't hesitate to contact us! 🚀
Posted on September 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.