SOLID Principles: Writing Robust & Maintainable Code (with TypeScript examples)
And why change and speed matter in today's world and competition.
Intro
Many Software Developers don’t know what SOLID Principles stand for, blindly apply them, or can’t elaborate on them when asked during an interview.
The latter happened to me last month.
I barely remember what SOLID means even though I do write clean and maintainable code.
The theory is not my strongest side.
However, I think it’s good to be aware of the SOLID principles, even in theory. That’s why I decided to write a simple and intuitive article about them.
After reading this article, you’ll learn:
the importance of writing robust and maintainable code
what are the SOLID Principles
what to avoid and prefer while applying the SOLID Principles
Why does writing robust and maintainable code matter?
Change
💡 Writing new code is easy. Maintaining it is an art.
We all wrote some code in the past. After some time, it didn’t suit our needs, so we had to change it.
So, change is inevitable.
There’s only one thing in the universe that is 100% sure → change.
So if we write code, we must write it in a way that can be changed. And better, the code is easy to change. Otherwise, we may end up with a headache.
Speed
Another crucial thing related to change is how fast we can make it.
In today’s world and competition speed matters.
We must be agile which means we do many iterations and incremental improvements in a project.
💡 To go fast, we need to go well!
So learning a bit about SOLID Principles and following them in your next project could help you make changes easily and faster.
What are the SOLID Principles?
The SOLID Principles are software design principles that help us structure and organize our functions, classes, and modules, so they are robust, easy to understand, maintainable, and flexible to change.
SOLID is an acronym for five key design principles:
S: Single Responsibility Principle (SRP)
O: Open-Closed Principle (OCP)
L: Liskov-Substitution Principle (LSP)
I: Interface Segregation Principle (ISP)
D: Dependency Inversion Principle (DIP)
SOLID Principles were popularized by Robert C. Martin aka Uncle Bob around 2004.
Primary Benefits
In my opinion, following the SOLID Principles can help you write code that is:
testable
understandable
maintainable
extensible
flexible
This enables things like:
adjusting and/or extending an existing functionality without introducing any bugs
swapping the inner implementation. For example. switching from one Email Provider to another, like SendGrid to Mailgun.
not spending a lot of time to navigate and find what you need throughout the codebase
and much more…
S: Single-Responsibility Principle (SRP)
By definition, the principle states:
”A class should only have one reason to change.”
This rule also includes modules and functions.
In other words, a class, function, or module should have a single responsibility.
For example, if there’s a need in our application for logging, caching, or storing then these concerns need to be separated and designed into separate classes each fulfilling its own SRP.
Another way to say it is that each class or function should do one thing and do it well. For example, the UNIX system is built on this principle. In UNIX, there’re multiple commands like grep
, ls,
and sed
which do only one thing, do it well, and could be used with others to compose a bigger application.
Okay, now let’s see another example in TypeScript to better illustrate the principle.
Let’s imagine we have an enterprise application for a company. The company has employees inside different departments like HR and Accounting. For each employee, we must be able to calculate the salary. Since each department and its employees have different salaries, it’s better to split and abstract this logic.
⛔ Avoid
class Employee {
calculateSalary(): number {
// code goes here
// if-else statements to check the employee's department
// if HR ...
// if Accounting ...
}
}
This violets the SRP because the calculateSalary
inside the Employee
class is responsible for two things - calculating the salary both for the employees from the HR and Accounting departments.
✅ Prefer
abstract class Employee {
abstract calculateSalary(): number;
}
class HR extends Employee {
calculateSalary(): number {
// code goes here (specific for the HR department)
}
}
class Accounting extends Employee {
calculateSalary(): number {
// code goes here (specific for the Accounting department)
}
}
This way we’ve separated the responsibility based on the different departments inside the business and company.
Whenever something has to be changed regarding the salary inside the Accounting department, we’ll only update the Accounting
class.
Note: It’s up to the team and company how granular they want to be when defining the responsibilities. Some people will argue that if we have more methods inside the class HR we should extract them as well. However, I don’t always think this makes the code better.
O: Open-Closed Principle (OCP)
By definition, the principle states:
“A software artifact should be open for extension but closed for modification”.
Basically, this means that you should strive to write your code in a way that when you add a new functionality, it shouldn’t require changing the existing code.
Note: Bug fixes are allowed to be fixed and therefore modify the existing code if necessary. Otherwise, how we’ll fix them. 😅
And that’s what we aim for in software architecture, isn’t it? 😳 To design the software in a way so that with minimum effort and changes we can go from point A to point B.
Okay, now let’s see an example in TypeScript to better illustrate the principle.
Let’s say the CEO forms a new IT department inside the company.
⛔ Avoid
class Employee {
calculateSalary(): number {
// code goes here
// if-else statements to check the department
// if HR ...
// if Accounting ...
// if IT ... (NEW)
}
}
Here, it violates the OCP because we’re modifying the Employee
class. Employee
class is not open for extension if new departments are added.
✅ Prefer
abstract class Employee {
abstract calculateSalary(): number;
}
class HR extends Employee {
calculateSalary(): number {
// code goes here (specific for the HR department)
}
}
class Accounting extends Employee {
calculateSalary(): number {
// code goes here (specific for the Accounting department)
}
}
class IT extends Employee {
calculateSalary(): number {
// code goes here (specific for the IT department)
}
}
This way we separated the higher-level concept of an Employee from the details - the different employees inside the department.
Higher-level components are protected from changes to lower components and their details.
L: Liskov-Substitution Principle (LSP)
By definition, the principle states:
”Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.”
Well, I don’t get it. It’s so confusing… 🤷♂️
Let’s rephrase it to something more intuitive:
“Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.”
So basically, we’re talking about using interfaces
and abstract classes
.
To better illustrate the concept, let’s see an example.
Following the examples from above with the employees and the departments, let’s say we have to add a paySalaries
method.
⛔ Avoid
abstract class Employee {
abstract paySalaries(): boolean;
}
class Accounting extends Employee {
paySalaries(): boolean { ... }
}
class IT extends Employee {
paySalaries(): boolean { ... }
}
This violates the LSP because the employee from the IT department cannot pay salaries, but it’s a subclass of Employee
which has a paySalaries
method.
✅ Prefer
abstract class Employee {
// ...
}
class Accounting extends Employee {
paySalaries(): boolean;
// ...
}
class IT extends Employee {
// ...
}
Now, Accounting
and Employee
are separate classes, correctly representing employees that can and cannot pay salaries.
I: Interface Segregation Principle (ISP)
By definition, the principle states:
“Prevent classes from relying on things that they don’t need”.
So to accomplish that, we should make sure to split up the unique functionality into interfaces.
Now, let’s see an example in TypeScript to better illustrate the principle.
⛔ Avoid
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work() { ... }
eat() { ... }
}
class RobotWorker implements Worker {
work() { ... }
eat() { ... } // irrelevant
}
RobotWorker
should not implement eat
. Robots do not eat. 😮
✅ Prefer
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class HumanWorker implements Workable, Eatable {
work() { ... }
eat() { ... }
}
class RobotWorker implements Workable {
work() { ... }
}
Now, we have separate interfaces - Workable
and Eatable
, ensuring that no class implements unnecessary methods.
D: Dependency Inversion Principle (DIP)
By definition, the principle states:
“Abstractions should not depend on details. Detail should depend on abstractions.”
Just to remind you, abstractions mean interface or abstract class whereas a detail means a concrete class.
An example is worth a thousand words. So, let’s see one.
⛔ Avoid
class LightBulb {
turnOn() { ... }
turnOff() { ... }
}
class ElectricPowerSwitch {
bulb: LightBulb;
isOn: boolean = false;
constructor(bulb: LightBulb) {
this.bulb = bulb;
}
press() {
if (this.isOn) {
this.bulb.turnOff();
isOn = false;
} else {
this.bulb.turnOn();
isOn = true;
}
}
}
The example violets the DIP principle because ElectricPowerSwitch
directly depends on the LightBulb
class.
The direct dependency between the ElectricPowerSwitch
and LightBulb
is problematic because of:
lack of flexibility - the direct dependency on
LightBulb
means thatElectricPowerSwitch
can only control instances ofLightBulb
. If you want to use the switch with a different type of device, like a fan or a heater, you would need to modify theElectricPowerSwitch
class, which violates the OCPtight coupling - the
ElectricPowerSwitch
is tightly coupled with theLightBulb
class. It makes the system less modular and more fragile. Changes in theLightBulb
class, such as its behavior, could directly impact theElectricPowerSwitch
class, leading to a higher risk of bugsdifficulties in testing - testing the
ElectricPowerSwitch
class in isolation becomes difficult due to the direct dependency on theLightBulb
class
✅ Prefer
interface SwitchableDevice {
turnOn() { ... }
turnOff() { ... }
}
class LightBulb implements SwitchableDevice {
turnOn() { ... }
turnOff() { ... }
}
class ElectricPowerSwitch {
device: SwitchableDevice;
constructor(device: SwitchableDevice) {
this.device = device;
}
press() {
if (this.device) {
this.device.turnOn();
} else {
this.device.turnOff();
}
}
}
Here, both ElectricPowerSwitch
and LightBulb
depend on the SwitchableDevice
interface, not on each other. This way the ElectricPowerSwitch
can work with different devices (concrete classes) as long as they implement the SwitchableDevice
interface.
This way we achieve:
Key principle: Loose coupling. 🍝
Note: This principle is a little more tricky to implement and follow in practice. I will write a follow-up article to dig deeper into the Dependency Inversion Principle and share how we can implement it with Dependency Injection Frameworks.
Conclusion
To summarize, SOLID Principles are a great way to write code that is easier and faster to change. However, you shouldn’t be applying the principles blindly since they may harm you more than help you.
The end goal when writing our code is to make it easy to change, understand, maintain, and test. This way our future selves will thank us.
After some time and practice, you won’t see a clear boundary between each other. You’ll be writing robust and maintainable code without so much thinking.
Thanks for summarizing these with examples Petar and for the mention!
Will save this for reference when people ask about SOLID
Much appreciated my friend
Interesting article Petar and thanks for the mention!