In the world of software development, we often talk about building "good" code. But what does "good" really mean? Is it code that runs fast? Code that has no bugs? Or is it something more? "Good" code, at its core, is code that is easy to understand, maintain, and extend. It's code that doesn't crumble under the pressure of new features or unexpected changes.
This is where the SOLID principles come in. More than just a set of rules, they are a philosophy for building software that is flexible, resilient, and ready for the future. Over the next few minutes, we'll dive into each of the five SOLID principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—and see how they can transform the way you write code. Get ready to move beyond the theory and discover how these principles can save you from late-night debugging sessions and make you a more confident, capable developer.
Let's take a look at them one by one.
S - Single Responsibility
Your class/function only has one RESPONSIBILITY and only ONE REASON TO CAHNGE.
Suppose we are having a NotificationService class, the job of this class is to send Notification to user.
// ❌ BAD: This class has two responsibilities
class NotificationService {
sendMessage(user, message) {
// 1. Responsibility: Formatting the message
const formattedMessage = `Hello ${user.name}, Message: ${message}`;
// 2. Responsibility: Sending the notification
console.log(`Sending: ${formattedMessage} to user: ${user.name}`);
//Actual message sending code goes here...
}
}
Here this function/class has two main responsibilities: Formatting the message + Sending the formatted message to user.
If we have to modify the formatting of the message to say "Hi" instead of "Hello", we have to modify the NotificationService class.
// ✅ GOOD: Each class has one clear job
class MessageFormatter {
formatMessage(user, message) {
return `Hello ${user.name}, Message: ${message}`;
}
}
class NotificationService {
constructor(formatter) {
this.messageFormatter = formatter;
}
sendMessage(user, message) {
const formattedMessage = this.messageFormatter.formatMessage(user, message);
console.log(`Sending: ${formattedMessage} to user: ${user.name}`);
//Actual message sending code goes here...
}
}
Now, if you have to change formatting of the message, our NotificationService class needs not to be modified. Only MessageFormatter class will be modified. And if something needs to be updated in sending logic, only that class gets the update is the NotificationService minimizing bugs and achieving the "S" in SOLID.
The DIFFERENCE?
If you update the formatting logic, there could be a possibility that a new bug get introduced in sending logic (accidentally), which will break your entire message sending logic.
In Short, the blast radius is huge.
On the other hand, making changes in MessageFormatter means you are not dealing with any sending logic, even if a bug got introduced, the only risk is that the formatting of the message is done incorrectly, but the other functionality(message sending logic) still works great. You've contained the risk of change to only the formatting itself.
[What if other services are using the same NotificationService?]
In short, the blast radius is tiny.
O - Open/closed principle
Your class/function should be open for extension, but closed for modifications.
Simply put, you should be able to add new feature without changing the existing code. This prevents bugs in the code that already works.
Suppose we are having a NotificationService class, the job of this class is to send Notification to user.
// ❌ BAD: We have to modify this class for every new notification type
class NotificationService {
sendMessage(type, message) {
switch (type) {
case "email":
console.log(`Sending Email: ${message}`);
break;
case "sms":
console.log(`Sending SMS: ${message}`);
break;
// 😱 We'd have to add a new 'case' for Push notifications here!
}
}
}
Suppose, we want to add a push notification to our sending logic, we need to modify the NotificationService class, which was already working fine + battle tested for bugs.
By modifying the logic inside the class/function, you are introducing a risk of accidentally creating bugs.
What if you forgot to add break statement in your new case block?
What if you changed some scoped variable in your logic?
For sending email, you need emailAddress, for sms you need phone number, for push notification you need device id, you are making the class bloated which is also
breaking the SRP(Single Responsibility) principle.
// ✅ GOOD: We can add new notifiers without touching existing code
class EmailNotifier {
send(message) {
console.log(`Sending Email: ${message}`);
}
}
class SmsNotifier {
send(message) {
console.log(`Sending SMS: ${message}`);
}
}
// ✨ NEW: Add a PushNotifier without changing NotificationService!
class PushNotifier {
send(message) {
console.log(`Sending Push Notification: ${message}`);
}
}
// The main service is now "closed" for modification but "open" to new notifiers
class NotificationService {
send(notifier, message) {
notifier.send(message);
}
}
Now, adding PushNotifier or any other type requires zero changes to the NotificationService.
The DIFFERENCE?
Updating the logic for adding the push notification functionality could potentially introduced new bugs + risks and could break the already working functionality for sms and email.
After the changes, adding new notification type service in our codebase is now easy and less error prone. No risk of disturbing the older services even if something breaks in the push notification as only those will stop working.
L - Liskov Substitution
The concept of this rule is very simple but yet it is hard to maintain.
Every child class should be substitutable with their parent class.
All child classes should extend the functionalities of the parent class not narrow down them.
Suppose you have a Notification class which takes in email and sends the message to the recipient. Enter a new GuestNotification class which also sends notification to the guest clients but only via push notification, because we do not have email addresses of the guests. So, we throw error in send function.
When we try to call the send function on this new class, our code breaks as we don't have the functionality to send message to guests.
// ❌ BAD: The subtype breaks the parent's contract
class Notification {
constructor(recipient) {
this.recipient = recipient;
}
send(message) {
console.log(`Sending "${message}" to ${this.recipient}`);
}
}
class EmailNotification extends Notification {}
class GuestNotification extends Notification {
constructor() {
// Guests don't have a recipient email, so we break the rule
super(""); // This is already awkward
}
send(message) {
// And now we can't fulfill the parent's primary function
throw new Error("Guests cannot receive notifications.");
}
}
function sendWelcomeMessage(notification) {
notification.send("Welcome!"); // This will crash for GuestNotification!
}
sendWelcomeMessage(new EmailNotification("dev@example.com")); // Works fine
sendWelcomeMessage(new GuestNotification()); // 💥 CRASH!
To solve this kind of situation, we need to rethink the our design schema.
Perhaps GuestNotification shouldn't be a Notification at all, or we need a different abstraction.
// ✅ GOOD: All subtypes are safely interchangeable
class Notification {
send(message) {
throw new Error(
"Method 'calculateArea()' must be implemented by subclasses."
);
}
}
class EmailNotification extends Notification {
constructor(recipient) {
super();
this.recipient = recipient;
}
send(message) {
console.log(`Sending Email "${message}" to ${this.recipient}`);
}
}
class PushNotification extends Notification {
constructor(deviceId) {
super();
this.deviceId = deviceId;
}
send(message) {
console.log(`Sending Push "${message}" to device ${this.deviceId}`);
}
}
function sendWelcomeMessage(notification) {
notification.send("Welcome!"); // Works for ANY notification type
}
sendWelcomeMessage(new EmailNotification("dev@example.com"));
sendWelcomeMessage(new PushNotification("device-123"));
Now, any function expecting a Notification will work perfectly with all its subtypes.
In future, we can also integrate any new Notification Type and inherit the send message function the way that class expects and our Notification class can be used anywhere in the code.
The DIFFERENCE?
Instead os having child subclasses narrowing some methods or functionalities of the parent class, it should extend them. By this, we can make sure, wherever in our code we are using the parent class, we can expect the same functionality no matter the type of child class we receive.
I - Interface Segregation
Keep your interfaces lean and they will keep your code maintainable.
Client or New classes should not be forced to implement the functions or properties they don't use.
Suppose you have an interface of NotficationService for different clients. One clients only uses Push Notification service, and you inherit this with the NotficationService class.
The NotficationService interface is too fat and bulky that it forces the class to implement the methods that they don't use.
// ❌ BAD: This interface is too "fat"
interface INotifier {
sendEmail(message: string): void;
sendSms(message: string): void;
sendPushNotification(message: string): void;
}
// This class only cares about push notifications but is forced to implement all methods
class PushNotifier implements INotifier {
sendEmail(message: string) {
// I don't do this! 🤷♂️
}
sendSms(message: string) {
// Or this!
}
sendPushNotification(message: string) {
console.log(`Sending Push Notification: ${message}`);
}
}
To solve this situation, we break the bulky interface into smaller, role-based interfaces. A class can then implement only the interfaces it needs.
// ✅ GOOD: Small, focused interfaces are better
interface IEmailNotifier {
sendEmail(message: string): void;
}
interface ISmsNotifier {
sendSms(message: string): void;
}
interface IPushNotifier {
sendPushNotification(message: string): void;
}
// Now our class implements ONLY what it needs. So clean!
class PushNotifier implements IPushNotifier {
sendPushNotification(message: string) {
console.log(`Sending Push Notification: ${message}`);
}
}
// Another class could handle multiple types if needed
class AllInOneNotifier implements IEmailNotifier, ISmsNotifier {
sendEmail(message: string) {
console.log(`Sending Email: ${message}`);
}
sendSms(message: string) {
console.log(`Sending SMS: ${message}`);
}
}
This approach keeps your code clean, understandable, and prevents you from implementing useless methods.
The DIFFERENCE?
Now you can implement a new Notification Service for a new client that only uses email or both email and sms notification service.
D - Dependency Inversion
The goal of this principle is to decouple the modules.
The high-level modules or classes should not depend on low-level modules or classes, instead both should depend on abstractions (or interfaces).
Suppose you have NotificationService that creates an instance of EmailService. This means that NotificationService is tightly coupled to EmailService. In future, if we want to implement a new SMS service we need to modify the NotificationService class which could potentially lead to bugs related to sending email code which was already working fine and battle-tested.
// ❌ BAD: High-level module depends directly on a low-level module
// Low-level detail
class EmailService {
send(message) {
console.log(`Sending Email: ${message}`);
}
}
// High-level module
class NotificationService {
constructor() {
// 😱 Dependency is created and hard-coded inside the class!
this.emailService = new EmailService();
}
notify(message) {
this.emailService.send(message);
}
}
Instead of directly creating the instance of EmailService, we will create an interface or abstract layer that defines how the notification sending service should look like. And use this interface for telling our NotificationService to send message. And we do not create instance inside the NotficationService and pass it from the outside.
// ✅ GOOD: Both modules depend on an abstraction
// The Abstraction (the "what")
interface IMessageSender {
send(message: string): void;
}
// Low-level details (the "how")
class EmailService implements IMessageSender {
send(message: string) {
console.log(`Sending Email: ${message}`);
}
}
class SmsService implements IMessageSender {
send(message: string) {
console.log(`Sending SMS: ${message}`);
}
}
// High-level module depends on the abstraction, not the detail
class NotificationService {
private sender: IMessageSender;
// The dependency is "injected" from the outside! ✨
constructor(sender: IMessageSender) {
this.sender = sender;
}
notify(message: string) {
this.sender.send(message);
}
}
// Now we can easily switch dependencies without changing NotificationService!
const emailNotifier = new NotificationService(new EmailService());
const smsNotifier = new NotificationService(new SmsService());
emailNotifier.notify("Hello via Email!");
smsNotifier.notify("Hello via SMS!");
This ensures that our code is ready. Even if a new notification type needs to be introduce, suppose push notification, we can use the interface (abstract layer) to inherit the properties and methods.
And no modifications are needed inside the NotificationService class.
The DIFFERENCE?
Even if you introduce a new notification service, you just need to use the abstraction to create it and even if you have accidentally introduced some bugs, they will only be for push notifications.
The Sms and Email notification service will work as it was working before.
You've now completed your journey through the five SOLID principles. From the Single Responsibility Principle, which teaches us to keep our classes focused, to the Dependency Inversion Principle, which frees us from rigid dependencies, these principles are more than just a theoretical framework. They are a practical toolkit for building software that is more robust, easier to maintain, and ready to adapt to the ever-changing demands of a project.
Adopting SOLID principles may seem like a challenge at first, but the long-term benefits are undeniable. Less technical debt, easier debugging, and the ability to confidently add new features without breaking existing ones are just some of the rewards. So, the next time you write a line of code, ask yourself: Is it SOLID? Your future self—and your team—will thank you for it.
That's it for this one. Thanks for reading and let me know your thoughts in the comments.
Umang Mittal... Signing off!
Top comments (0)