Introduction
Robert C. Martin first established the five design concepts that make up SOLID in the early 2000s. These standards are regarded as the cornerstones for developing scalable and maintainable software systems. They are extensively utilised in object-oriented programming and can be leveraged by TypeScript developers.
The SOLID principles include:
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
Adhering to these principles can enhance software quality by encouraging loose coupling, simplicity, and cohesiveness. Code becomes more tested and manageable as a result, which makes it simpler to incorporate new features and address bugs.
It's crucial to remember that SOLID principles are not a one-size-fits-all approach and should be used sparingly while taking the unique use case into account. The guiding principles should be applied when creating a system so that it is simple to comprehend, expand upon, and maintain throughout time.
In order to improve the design of software systems, we'll examine instances of how each of the SOLID principles can be used in TypeScript in this blog article.
The example code snipptes in the following sections do not contain the type and interface definitions for the function parameters. However, if you are familiar with typescript. The example snippets should be fairly simple to follow. If not, you can only focus on the Class definitions and the OOP principles used to demonstrate the scenarios.
The examples included under every principle here on will be of a simple usecase where we will define classes for mocking the functionality of an e-commerce order and shipping module. The examples are only to understand the concepts and does not include implementation logic for all the classes.
Principles
Single Responsibility Principle (SRP)
The Single Responsibility Principle (SRP) states that a class should have one and only one reason to change, meaning that a class should have only one responsibility.
A responsibility is defined as a reason for change, which means that if you can think of more than one reason for a class to change, then it likely has more than one responsibility.
class Order {
constructor(private items: Item[], private shipping: Shipping) {}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.price, 0) + this.shipping.cost;
}
}
class OrderPrinter {
printReceipt(order: Order): void {
console.log("Order receipt:");
console.log("Shipping: ", order.shipping.name);
console.log("Items: ", order.items);
console.log("Total: ", order.calculateTotal());
}
}
class Shipping {
constructor(public name: string, public cost: number) {}
}
class Item {
constructor(public name: string, public price: number) {}
}
In this example, the Order
class has only one responsibility: calculating the total cost of an order using calculateTotal
. The class OrderPrinter
has the responsibility of printing the receipt. By doing this, it would be easier to change the way the total cost is calculated or the way the receipt is printed without affecting the other functionality.
Benefits of adhering to SRP include:
Classes become more focused and easier to understand
Classes become more reusable, as they are only concerned with one responsibility
Classes become more testable, as each class only has one area of concern
Changes to the system are less likely to affect multiple parts of the codebase
Code is more flexible and easier to maintain over time.
It's important to remember that following OCP can result in more smaller classes, but it is a useful tool for managing codebase complexity and making it more flexible to changes.
Open/Closed Principle (OCP)
The Open/Closed Principle (OCP) states that a class should be open for extension but closed for modification. This means that a class should be designed in such a way that new functionality can be added to it without modifying its existing code.
abstract class Order {
abstract calculateTotal(): number;
}
class PhysicalOrder extends Order {
constructor(private items: Item[], private shipping: Shipping) {}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.price, 0) + this.shipping.cost;
}
}
class OnlineOrder extends Order {
constructor(private items: Item[], private shipping: Shipping, private tax: number) {}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.price, 0) + this.shipping.cost + this.tax;
}
}
class OrderPrinter {
printReceipt(order: Order): void {
console.log("Order receipt:");
console.log("Shipping: ", order.shipping.name);
console.log("Items: ", order.items);
console.log("Total: ", order.calculateTotal());
}
}
class Shipping {
constructor(public name: string, public cost: number) {}
}
class Item {
constructor(public name: string, public price: number) {}
}
In this example, the base Order
class defines the calculateTotal()
method, which is implemented by both the PhysicalOrder
and OnlineOrder
classes. The OrderPrinter
class can print a receipt using OrderPrinter
for any type of order without knowing the specific implementation details. This allows for new types of orders (such as digital orders) to be added to the system without modifying the existing Order
, PhysicalOrder
or OnlineOrder
classes.
Benefits of adhering to OCP include:
Classes become more flexible, as new functionality can be added without modifying existing code
Changes to the system are less likely to introduce bugs Code is more maintainable over time
The system is more adaptable to changing requirements Code becomes more reusable and easier to test.
OCP is a strong tool for managing the complexity of the codebase and making it more flexible, but it can result in more smaller classes, as is necessary to keep in mind.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. In other words, a subclass should be a strict subtype of its superclass, meaning that it should not change the behavior of the superclass methods.
interface OrderOperations {
calculateTotal(): number;
}
class Order implements OrderOperations {
constructor(private items: Item[], private shipping: Shipping) {}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.price, 0) + this.shipping.cost;
}
}
class SpecialOrder extends Order {
constructor(private items: Item[], private shipping: Shipping, private discount: number) {
super(items, shipping);
}
calculateTotal(): number {
return super.calculateTotal() - this.discount;
}
}
class Shipping {
constructor(public name: string, public cost: number) {}
}
class Item {
constructor(public name: string, public price: number) {}
}
In this example, the Order
class implements the OrderOperations
interface which defines the calculateTotal()
method. The SpecialOrder
class extends the Order
class and overrides the calculateTotal()
method with its own implementation that includes a discount
.
However, it still calls the calculateTotal()
method of the Order
class and then applies the discount
, ensuring that the behavior of the OrderOperations
interface is not changed. This allows objects of the SpecialOrder
class to be used in place of objects of the Order
class without affecting the correctness of the program.
Benefits of adhering to LSP include:
Classes become more reusable and interchangeable
Classes become easier to test and maintain
Changes to the system are less likely to introduce bugs
Code is more adaptable to changing requirements
The system becomes more robust and less prone to errors
It is important to note that following LSP can be difficult and necessitates a thorough comprehension of the superclass's behaviour and methods. However, it enables a more resilient and adaptive codebase, which is advantageous in the long run.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states that a class should not be forced to implement interfaces it does not use. This principle encourages creating small, specific interfaces that are only implemented by the classes that need them.
interface OrderOperations {
calculateTotal(): number;
}
interface PrintOperations {
printReceipt(): void;
}
class Order implements OrderOperations {
constructor(private items: Item[], private shipping: Shipping) {}
calculateTotal(): number {
return this.items.reduce((total, item) => total + item.price, 0) + this.shipping.cost;
}
}
class OrderPrinter implements PrintOperations {
printReceipt(order: Order): void {
console.log("Order receipt:");
console.log("Shipping: ", order.shipping.name);
console.log("Items: ", order.items);
console.log("Total: ", order.calculateTotal());
}
}
class Shipping {
constructor(public name: string, public cost: number) {}
}
class Item {
constructor(public name: string, public price: number) {}
}
In this example, the Order
class implements the OrderOperations
interface which defines the calculateTotal()
method. The OrderPrinter
class implements the PrintOperations
interface which defines the printReceipt()
method. This way, the Order
class and OrderPrinter
class are not forced to implement methods they don't need, making the code more maintainable and easier to understand.
Benefits of adhering to ISP include:
Classes become more focused and easier to understand
Classes become more reusable, as they only need to implement the methods they need
Classes become more testable, as each class only has methods relevant to its functionality
Changes to the system are less likely to affect multiple parts of the codebase
Code is more flexible and easier to maintain over time.
The adoption of ISP can result in more compact interfaces, but in the long term, this is advantageous because it allows for greater structure and control of the codebase.
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions. This means that the implementation details of a module should be hidden behind an interface, allowing for a more flexible and maintainable codebase.
interface OrderDataAccess {
getOrders(): Order[];
saveOrder(order: Order): void;
}
class OrderService {
constructor(private dataAccess: OrderDataAccess) {}
getOrders(): Order[] {
return this.dataAccess.getOrders();
}
saveOrder(order: Order): void {
this.dataAccess.saveOrder(order);
}
}
class OrderFileDataAccess implements OrderDataAccess {
getOrders(): Order[] {
// implementation to get orders from a file
}
saveOrder(order: Order): void {
// implementation to save order to a file
}
}
class OrderDatabaseDataAccess implements OrderDataAccess {
getOrders(): Order[] {
// implementation to get orders from a database
}
saveOrder(order: Order): void {
// implementation to save order to a database
}
}
In this example, the OrderService
class depends on an abstraction
(the OrderDataAccess
interface) rather than a specific implementation (OrderFileDataAccess
or OrderDatabaseDataAccess
). This allows the OrderService
class to work with any implementation of the OrderDataAccess
interface, making the code more flexible and adaptable to changing requirements.
Here is how the classes can be used during instantiation:
//using file data access
const fileDataAccess = new OrderFileDataAccess();
const orderService = new OrderService(fileDataAccess);
console.log(orderService.getOrders()); //outputs the orders from the file
orderService.saveOrder(new Order()); //saves the order to the file
//using database data access
const dbDataAccess = new OrderDatabaseDataAccess();
const orderService = new OrderService(dbDataAccess);
console.log(orderService.getOrders()); //outputs the orders from the database
orderService.saveOrder(new Order()); //saves the order to the database
Here, we can see that OrderService
depends on the abstraction OrderDataAccess
interface, and we can use different implementations like OrderFileDataAccess
or OrderDatabaseDataAccess
without affecting the OrderService
class. This allows us to switch the data access method without changing the OrderService
code, making the system more flexible and adaptable to changing requirements.
Benefits of adhering to DIP include:
Classes become more reusable and interchangeable
Classes become easier to test and maintain
Changes to the system are less likely to introduce bugs
Code is more adaptable to changing requirements
The system becomes more robust and less prone to errors.
It's important to remember that sticking to DIP can result in more complex code, but this is offset by the longer-term benefits of a more adaptable and manageable codebase.
Conclusion
The SOLID principles are a set of guidelines for creating maintainable and flexible software.
SOLID principles are essential in software development, they help to create a well-structured and easy-to-maintain codebase which is crucial for long-term success. These principles are not only important for creating new software but also for maintaining and updating existing systems. Implementing SOLID principles will save time, effort and money in the long run.
However, using these principles is subject to the targeted codebase and may or may not be needed depending on the use case and complexity of the system. If these principles are used where they are not needed, they can lead to over-engineered solutions that can result in a very complex and hard-to-follow codebase.