SOLID Principles
SOLID is an acronym that describes the set of design principles used in object-oriented software development. Object-oriented software development involves creating systems that are easy to read, maintain, understand, and extend in the future. It helps engineers reduce dependencies so that changes to one area of software will not impact others.
You want the systems you design to be flexible, adaptable to changing requirements, reusable across different parts of the system, and capable of taking on new functionalities without breaking existing functionality. Adhering to SOLID principles makes all those goals possible.
1. Single Responsibility Principle
The Single Responsibility Principle (SRP) states that “a class should have only one reason to change." It implies that every class should have a singular responsibility, job, or purpose.
This doesn’t necessarily mean that a class should only have one method or property, but rather that its functionality should be related to a single responsibility. This leads to classes becoming smaller, cleaner, and easier to maintain. When a class is responsible for multiple tasks or purposes, it can become complex, difficult to modify, and hard to debug.
Bad Approach
It's common for programmers when tasked with adding new features or behaviors, to try and implement them directly into an existing class. The problem is, when you continue adding different responsibilities to a single class — as shown in the code example below — deciphering the logic and flow of the code becomes overwhelming and difficult to understand.
The code below violates SRP as BuyProducts class has multiple responsibilities, including validating users, making payments, sending emails, etc. If modifications or updates need to be made later, it will take a lot of time and effort to identify and modify the relevant parts of the code.
class BuyProducts {
async buy(user, products) {
//User Validation
const validUser = await User.findOne(user.id);
if (!validUser) {
throw new Error("User not found");
}
//Total Cost Calculation
const totalCost = _.sumBy(products, "price");
// Payment Logic
await stripe.makePayment(totalCost);
// Sending Email Logic
const body = {
to: user.email,
from: "outside.tech",
text: "Payment SuccessFull",
};
await sendGrid.send(body);
}
}
Correct Approach
Instead, a better approach is to use the Single Responsibility Principle and create new classes to handle each new functionality. This approach helps ensure that each class has a clear and specific purpose, making it easier to understand and modify later on.
By separating functionality into different classes, you reduce the complexity of the codebase, which naturally makes it more manageable in the future. This approach can also promote code reuse, as specific classes can be used in multiple places throughout the application.
class BuyProducts {
constructor(user) {
this.customer = new Customer();
this.stripe = new Stripe();
this.email = new SendGrid();
}
async buy(purchaseDetails) {
await this.customer.isValidCustomer(purchaseDetails.buyer);
const totalCost = _.sumBy(purchaseDetails.products, "price");
await this.stripe.makePayment(totalCost);
await this.email.sendEmail(purchaseDetails.buyer.email);
}
}
class Customer {
async isValidCustomer(user) {
const validUser = await User.findOne(user.id);
if (!validUser) {
throw new Error("User not found");
}
return;
}
}
class Stripe {
async makePayment(totalCost) {
await stripe.makePayment(totalCost);
}
}
class SendGrid {
async sendEmail(email) {
const body = {
to: email,
from: "outside.tech",
text: "Payment SuccessFull",
};
await sendGrid.send(body);
}
}
2. Open-Closed Principle
The Open-Closed Principle (OCP) is a key principle in software design that states that software entities, such as classes, modules, and methods, should be open for extension and closed for modification.
In other words, once a piece of code has been tested and deployed in production, it should be left untouched as much as possible, as modifying it may introduce new bugs or issues. Instead, the system should be designed in a way that allows for extension without modifying existing code.
The primary benefit of this approach is that it introduces loose coupling between different components in a system. This means that each component can be developed and tested independently, without affecting the behavior of the other components. When extending an existing feature in a system, it's important that the new functionality can be added and developed independently, without impacting pre-existing features.
Bad Approach
The code below violates OCP because the BuyProducts class is not closed for modification. The buy method is tightly coupled with the two specific payment methods (paymentWithStripe and paymentWithPayPal) and would require modification if a new payment method were added.
class BuyProducts {
customer: Customer;
email: SendGrid;
constructor() {
this.customer = new Customer();
}
async buy(purchaseDetails: IPurchaseDetails) {
await this.customer.isValidCustomer(purchaseDetails.buyer);
const totalCost = sumBy(purchaseDetails.products, "price");
switch (purchaseDetails.paymentMethod) {
case PaymentMethod.STRIPE:
this.paymentWithStripe(totalCost);
break;
case PaymentMethod.PAYPAL:
this.paymentWithPayPal(totalCost);
break;
default:
throw new Error("Invalid payment method");
}
private async paymentWithStripe(totalCost: number) {
//payment Implementation
}
private async paymentWithPayPal(totalCost: number) {
//payment Implementation
}
}
Correct Approach
One way to implement OCP is to create an interface, such as IPaymentProvider, that defines a set of methods that any implementing class must have. In this case, the interface has a makePayment method, which specifies how a payment should be processed.
If there is a need to add another payment type to the system, developers can create a new class that implements the IPaymentProvider interface and provides its implementation of the makePayment method. This new class can then be added to the system without modifying the existing code.
interface IPaymentProvider {
makePayment(totalCost: number): Promise<void>;
}
class Stripe implements IPaymentProvider {
async makePayment(totalCost: number) {
console.log(`${totalCost} has been paid from Stripe`);
}
}
class PayPal implements IPaymentProvider {
async makePayment(totalCost: number) {
console.log(`${totalCost} has been paid from PayPal`);
}
}
class BuyProducts {
customer: Customer;
paymentProvider: IPaymentProvider;
constructor(
customer: Customer,
paymentProvider: IPaymentProvider,
) {
this.paymentProvider = paymentProvider;
}
async buy(purchaseDetails: IPurchaseDetails) {
await this.customer.isValidCustomer(purchaseDetails.buyer);
const totalCost = sumBy(purchaseDetails.products, "price");
await this.paymentProvider.makePayment(totalCost);
}
}
3. Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that if a program is designed to work with a superclass object, it should be able to work with any object of its subclass without breaking.
In other words, if class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A, and the method should work correctly without producing unexpected or incorrect results.
This principle is essential for ensuring that the program behaves correctly when different objects are used in place of one another. It requires that derived classes are substitutable for their base class, not the other way around.
Bad Approach
Let’s look at the code below, which creates an instance of the Customer class and calls the isValidCustomer and addBio methods.
class Customer {
async isValidCustomer(user: { id: string; name: string; email: string }) {
const validUser = await User.findOne(user.id);
if (!validUser) {
throw new Error("User not found");
}
return;
}
addBio() {
console.log("Bio has been added");
}
}
class Guest extends Customer {
async isValidCustomer(user: { id: string; name: string; email: string }) {
throw new Error("No need for validation");
}
addBio() {
console.log("Bio has been added");
}
}
const customer = new Customer();
const guest = new Guest();
customer.isValidCustomer()
customer.addBio()
guest.isValidCustomer() //throws error
guest.addBio()
If we were to create an instance of the Guest class and call the same methods, we would see that the isValidCustomer method always throws an error, which is not expected behavior for the Customer class. This violates the LSP because the Guest class does not behave like its superclass in all cases.
Correct Approach
Here we have an abstract BaseCustomer class that defines the common functionality for all customers, such as the addBio method. This method can be overridden by any derived class, but the base functionality remains the same. The Customer class extends the BaseCustomer class and defines a unique behavior in the isValidCustomer method. The Guest class also extends the BaseCustomer class but does not define any additional behavior
abstract class BaseCustomer {
addBio() {
console.log("Bio has been added");
}
}
class Customer extends BaseCustomer {
async isValidCustomer(user: { id: string; name: string; email: string }) {
const validUser = await User.findOne(user.id);
if (!validUser) {
throw new Error("User not found");
}
return;
}
}
class Guest extends BaseCustomer {}
const customer = new Customer();
const guest = new Guest();
customer.addBio()
guest.addBio()
The code now conforms to the Liskov Substitution Principle, as both the Customer and Guest classes behave like their superclass in all cases. In this updated implementation, the behavior that is unique to the Customer class is defined within the Customer class itself, rather than overriding a method defined in the BaseCustomer class.
By using an abstract base class to define the common functionality and behavior of all customers, we can ensure that any derived classes follow the same interface, making the code easier to maintain, more flexible, and easier to test and extend.
4. Interface Segregation Principle
The Interface Segregation Principle (ISP) is a principle in object-oriented programming that states that clients should not be forced to depend on methods they do not use. In other words, if a class provides multiple methods, clients only need to be exposed to the methods they use.
Bad Approach
By violating the ISP, developers may become confused by methods they don’t need. This can create problems when a change in an interface forces developers to change classes that do not implement the interface, leading to side effects and potential bugs.
In the given code, the IPaymentProvider interface is defined with four methods. However, the classes Stripe and PayPal do not use every method and are being forced to implement methods that they don't use. This can lead to code bloat and maintenance issues, making it harder to modify the code in the future.
interface IPaymentProvider {
makePayment(totalCost: number): Promise<void>;
retryFailedPayment(): Promise<void>;
setUpFreeTrials(): Promise<void>;
multipleRecipientsPayout(): Promise<void>;
}
class Stripe implements IPaymentProvider {
async makePayment(totalCost: number) {
// Implementation
}
async retryFailedPayment(): Promise<void> {
// Implementation
}
async setUpFreeTrials(): Promise<void> {
// Implementation
}
async multipleRecipientsPayout(): Promise<void> {}
}
class PayPal implements IPaymentProvider {
async makePayment(totalCost: number) {
// Implementation
}
async retryFailedPayment(): Promise<void> {
throw new Error("Retry Failed");
}
async setUpFreeTrials(): Promise<void> {}
async multipleRecipientsPayout(): Promise<void> {
// Implementation
}
}
Correct Approach
To solve this issue, we can split the IPaymentProvider interface into smaller, more focused interfaces, each containing only the methods that are relevant to the implementing classes. Here we define two new interfaces, IStripePaymentProvider and IPayPalPaymentProvider, which extend the IPaymentProvider interface and define the additional methods specific to each provider.
interface IPaymentProvider {
makePayment(totalCost: number): Promise<void>;
}
interface IStripePaymentProvider extends IPaymentProvider {
retryFailedPayment(): Promise<void>;
setUpFreeTrials(): Promise<void>;
}
interface IPayPalPaymentProvider extends IPaymentProvider {
multipleRecipientsPayout(): Promise<void>;
}
class Stripe implements IStripePaymentProvider {
async makePayment(totalCost: number) {
// Implementation
}
async retryFailedPayment(): Promise<void> {
// Implementation
}
async setUpFreeTrials(): Promise<void> {
// Implementation
}
}
class PayPal implements IPayPalPaymentProvider {
async makePayment(totalCost: number) {
// Implementation
}
async multipleRecipientsPayout(): Promise<void> {
// Implementation
}
}
5. Dependency Inversion Principle
The Dependency Inversion Principle is a crucial design principle in object-oriented programming that emphasizes the importance of decoupling high-level modules from low-level ones by depending on abstractions instead of concrete implementations. This helps us make changes to low-level modules without affecting high-level modules, leading to a more flexible and maintainable software system.
Following this principle ensures that high-level modules do not import anything from low-level modules, and both should depend on abstractions instead of concrete implementations. By following the Dependency Inversion Principle, developers can avoid the pitfalls of tightly coupled code. Loose coupling makes the software system more manageable, reduces the risk of introducing new bugs, and makes it easier to maintain the system over time.
Bad Approach
The code below violates the Dependency Inversion Principle (DIP) because the BuyProducts class is tightly coupled with the SendGrid class. The BuyProducts class directly creates an instance of SendGrid within its constructor, which makes it difficult to change the email service in the future without modifying the BuyProducts class.
class BuyProducts {
customer: Customer;
emailService: SendGrid;
constructor(customer: Customer) {
this.customer = customer;
this.emailService = new SendGrid();
}
async buy(purchaseDetails: IPurchaseDetails) {
await this.customer.isValidCustomer(purchaseDetails.buyer);
const totalCost = sumBy(purchaseDetails.products, "price");
await this.emailService.sendEmail(purchaseDetails.buyer.email);
}
}
class SendGrid {
async sendEmail(emailAddress: string) {
console.log(`Email has been sent to ${emailAddress}`);
}
}
Correct Approach
In the new implementation, SendGridProvider and NodeMailerProvider both implement the IEmailProvider interface, which abstracts the implementation details of sending emails. This decouples BuyProducts from the implementation details of email sending and allows it to depend on the IEmailProvider interface instead.
interface IEmailProvider {
sendEmail(emailAddress: string): Promise<void>;
}
class SendGridProvider implements IEmailProvider {
sendGrid: SendGrid;
constructor() {
this.sendGrid = new SendGrid();
}
async sendEmail(emailAddress: string): Promise<void> {
await this.sendGrid.send(emailAddress);
}
}
class NodeMailerProvider implements IEmailProvider {
nodeMailer: NodeMailer;
constructor() {
this.nodeMailer = new NodeMailer();
}
async sendEmail(emailAddress: string): Promise<void> {
await this.nodeMailer.sendEmail(emailAddress);
}
}
Now, when BuyProducts needs to send an email, it can simply ask for an instance of IEmailProvider through its constructor, without having to know the implementation details of the email service. This approach makes it easy to replace the implementation of IEmailProvider with another implementation without having to modify the BuyProducts class.
Setting a SOLID Foundation
The SOLID principles in software development provide guidelines for creating code that is easier to maintain, easier to scale, and more adaptable to change, as all good code should be. It's like building a high-quality foundation for the future of your program — one that you, and others on your team, can test, modify, and manage efficiently and effectively. Investing your time in these principles at the start of a project will pay off in the long run.