Understanding SOLID Principles: A Guide to Writing Better Code in TypeScript
In the world of software development, adhering to best practices can significantly enhance the quality and maintainability of your code. One such set of best practices is the SOLID principles. SOLID is an acronym for five design principles that help developers create more understandable, flexible, and maintainable software. In this article, we'll explore each principle, provide practical examples of both incorrect and correct implementations in TypeScript, and discuss the benefits of using these principles.
1. Single Responsibility Principle (SRP)
Definition
The Single Responsibility Principle states that a class or function should have only one reason to change. In other words, a class should have only one job or responsibility.
Incorrect Implementation
class Report {
constructor(
private title: string,
) {}
generateReport(): void {
// generate report
}
printReport(): void {
console.log(`Printing report...\n${this.content}`);
}
saveReport(): void {
const fs = require('fs');
fs.writeFileSync(filePath, this.content);
}
}
In this example, the Report
class is responsible for generating, printing, and saving reports. If you need to change how reports are saved, you might inadvertently affect the report generation and printing logic, since you will need to change the whole class.
Correct Implementation
class ReportGenerator {
generateReport(): void {
// Generate report logic
}
}
class ReportPrinter {
printReport(report: string): void {
// Print report logic
}
}
class ReportSaver {
saveReport(report: string): void {
// Save report logic
}
}
In this improved design, each class has a single responsibility: generating, printing, or saving reports. This makes each class easier to modify and understand.
Benefits
Improved Code Maintainability: Changes in one part of the system are less likely to impact other parts.
Enhanced Readability: Code is more straightforward and easier to understand.
Simpler Testing: Each class can be tested independently
2. Open/Closed Principle (OCP)
Definition
The Open/Closed Principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
Incorrect Implementation
class PaymentProcessor {
processPayment(type: string, amount: number) {
if (type === 'creditCard') {
// logic for credit card payments
} else if (type === 'paypal') {
// logic for PayPal payments
} else {
throw new Error('Unknown payment type');
}
}
}
If we need to add a new payment method, we would have to modify the existing code, which violates OCP.
Correct Implementation
interface PaymentMethod {
process(amount: number): void;
}
// Implement different payment methods
class CreditCardPayment implements PaymentMethod {
process(amount: number): void {
// logic for credit card payments
}
}
class PayPalPayment implements PaymentMethod {
process(amount: number): void {
// logic for PayPal payments
}
}
// New payment method
class BitcoinPayment implements PaymentMethod {
process(amount: number): void {
// logic for Bitcoin payments
}
}
// Payment processor that uses the PaymentMethod interface
class PaymentProcessor {
private paymentMethod: PaymentMethod;
constructor(paymentMethod: PaymentMethod) {
this.paymentMethod = paymentMethod;
}
processPayment(amount: number) {
this.paymentMethod.process(amount);
}
}
In this approach, we don’t modify the existing PaymentProcessor class when adding a Bitcoin payment method. Instead, we use an interface to extend functionality.
Benefits
Reduced Risk of Bugs: The existing code has not been altered, so there's less of a risk of introducing new bugs.
Ease of Extension: New features or functionality can be added with minimal changes.
Improved Code Organization: Code adheres to a more predictable and scalable structure.
3. Liskov Substitution Principle (LSP)
Definition
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.
Incorrect Implementation
// Base class
class Vehicle {
fuelCapacity: number;
fuelConsumptionRate: number;
constructor(fuelCapacity: number, fuelConsumptionRate: number) {
this.fuelCapacity = fuelCapacity;
this.fuelConsumptionRate = fuelConsumptionRate;
}
calculateFuelEfficiency(): number {
return this.fuelCapacity / this.fuelConsumptionRate;
}
}
// Incorrect subclass representing an electric vehicle
class ElectricVehicle extends Vehicle {
batteryCapacity: number;
constructor(fuelCapacity: number, fuelConsumptionRate: number, batteryCapacity: number) {
super(fuelCapacity, fuelConsumptionRate);
this.batteryCapacity = batteryCapacity;
}
calculateFuelEfficiency(): number {
throw new Error("Electric vehicles do not use fuel, cannot calculate fuel efficiency");
}
}
Substituting a Vehicle
object with an ElectricVehicle
object in the codebase can break the expected behavior of the calculateFuelEfficiency
method, as it will now throw an error instead of returning a numeric value as intended.
Correct Implementation
abstract class Vehicle {
abstract getPerformance(): number;
// I can have common methods here
}
// Subclass for fuel-based vehicles
class FuelVehicle extends Vehicle {
private fuelCapacity: number;
private fuelConsumptionRate: number;
constructor(fuelCapacity: number, fuelConsumptionRate: number) {
super();
this.fuelCapacity = fuelCapacity;
this.fuelConsumptionRate = fuelConsumptionRate;
}
getPerformance(): number {
return this.fuelCapacity / this.fuelConsumptionRate;
}
}
// Subclass for electric vehicles
class ElectricVehicle extends Vehicle {
private batteryCapacity: number;
constructor(batteryCapacity: number) {
super();
this.batteryCapacity = batteryCapacity;
}
getPerformance(): number {
return this.batteryCapacity * 5;
}
}
In this example, FuelVehicle
and ElectricVehicle
both adhere to the getPerformance
method, but each implements it differently according to their capabilities.
Benefits
Enhanced Reliability: Subtypes can be used interchangeably without altering program behavior.
Increased Flexibility: The system can be extended with new subclasses without affecting existing functionality.
Clearer Code Design: Ensures that subclasses honor the contract established by their superclasses.
4. Interface Segregation Principle (ISP)
Definition
The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Interfaces should be designed to be specific to the needs of the client.
Incorrect Implementation
interface DocumentProcessor {
read(): void;
write(): void;
print(): void;
format(): void;
calculate(): void;
}
class TextFileProcessor implements DocumentProcessor {
read() {
console.log("Reading text file...");
}
write() {
console.log("Writing to text file...");
}
print() {
console.log("Printing text file...");
}
format() {
// Text files typically don't need formatting
throw new Error("Not supported");
}
calculate() {
// Text files typically don't need calculations
throw new Error("Not supported");
}
}
In this case, the TextFileProcessor
class must implement all methods from the interface because other document types require additional methods. However, two of these methods are not necessary for text documents. To adhere to the Interface Segregation Principle, we should refactor the design by breaking the class into smaller, more specific interfaces.
Correct Implementation
interface Readable {
read(): void;
}
interface Writable {
write(): void;
}
interface Printable {
print(): void;
}
interface Formattable {
format(): void;
}
interface Calculable {
calculate(): void;
}
class TextFileProcessor implements Readable, Writable, Printable {
read() {
console.log("Reading text file...");
}
write() {
console.log("Writing to text file...");
}
print() {
console.log("Printing text file...");
}
}
By separating concerns into different interfaces, we ensure that each class only implements what it truly needs.
Benefits
Reduced Impact of Changes: Changes to one interface do not affect clients who don’t use it.
Increased Code Reusability: Interfaces are more versatile and can be used in various contexts.
Improved Code Organization: A clearer separation of concerns leads to more maintainable code.
5. Dependency Inversion Principle (DIP)
Definition
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.
Incorrect Implementation
import orderRepository from '../order-repository.ts';
class CreditCardPayment {
processPayment(orderId: string, amount: number): void {
// Retrieve the order and process payment
const order = orderRepository.getOrderById(orderId);
if (order) {
// Logic to process payment
}
}
}
In this example, CreditCardPayment
directly depends on orderRepository
, making it hard to change or test.
Correct Implementation
interface IOrderRepository {
saveOrder(order: Order): void;
getOrderById(id: string): Order | null;
}
class CreditCardPayment {
private orderRepository: IOrderRepository;
constructor(orderRepository: IOrderRepository) {
this.orderRepository = orderRepository;
}
processPayment(orderId: string, amount: number): void {
const order = this.orderRepository.getOrderById(orderId);
// logic to process payment..
}
}
Here, CreditCardPayment
depends on an abstraction IOrderRepository
rather than a specific implementation. This allows for more flexibility and easier testing.
Benefits
Decoupled Code: High-level modules are less affected by changes in low-level modules.
Enhanced Flexibility: Components can be easily replaced or modified without altering the overall system.
Easier Testing: Dependencies can be easily mocked or stubbed for testing.
Conclusion
Adhering to the SOLID principles can greatly improve the design and maintainability of your software. By ensuring that your code adheres to these principles, you can create more flexible, understandable, and reliable systems. Each principle offers specific benefits that contribute to the overall quality of your code, making it easier to manage and evolve over time.
Subscribe to my newsletter
Read articles from Luis Gustavo Ganimi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by