In the realm of software development, a field known for its diverse and strongly held opinions, few practices have achieved consensus as a guaranteed path to becoming a better software engineer quite like the S.O.L.I.D principles.
The 5 golden rules, which were formalized in the early 2000s by Robert C. Martin, have drastically influenced the software development industry and set new standards for better code quality and decision-making process, preserving its relevance up to this present moment.
S.O.L.I.D principles are specifically designed to support the OOP (Object-Oriented Programming) paradigm. Hence, this article is designed for OOP developers who wish to level up their development skills and to write more elegant, maintainable and scalable code.
The language which will be used here is TypeScript, following common cross-language OOP concepts. Basic OOP knowledge is required.
1. S = Single Responsibility Principle (SRP)
Single Responsibility Principle (SRP) is one of the five S.O.L.I.D principles, which states the each class should have only one responsibility, in order to preserve meaningful separation of concerns.
This patterns is a solution to a common anti-pattern called "The God Object" which simply refers to a class or object that holds too many responsibilities, making it difficult to understand, test and maintain.
Following the SRP rule helps making code components reusable, loosely-coupled and easily comprehensible. Let's explore this principle, showcasing an SRP violation and resolution.
Global Declarations
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
Violation
In the following code, the ProductManager
class is responsible for both creation and storage of products, violating the single-responsibility principle.
class ProductManager {
private _products: Product[] = [];
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
const productManager: ProductManager = new ProductManager();
const product: Product = productManager.createProduct('Product 1', Color.BLUE, Size.LARGE);
productManager.storeProduct(product);
const allProducts: Product[] = productManager.getProducts();
Resolution
Separating the handling of products creation and storage to two distinct classes reduces the number of responsibilities of ProductManager
class. This approach further modularizes the code and makes it more maintainable.
class ProductManager {
createProduct (name: string, color: Color, size: Size): Product {
return new Product(name, color, size);
}
}
class ProductStorage {
private _products: Product[] = [];
storeProduct (product: Product): void {
this._products.push(product);
}
getProducts (): Product[] {
return this._products;
}
}
Usage:
const productManager: ProductManager = new ProductManager();
const productStorage: ProductStorage = new ProductStorage();
const product: Product = productManager.createProduct("Product 1", Color.BLUE, Size.LARGE);
productStorage.storeProduct(product);
const allProducts: Product[] = productStorage.getProducts();
2. O = Open-Closed Principle (OCP)
"Software entities should be open for extension but closed for modification"
The Open-Closed Principle (OCP) is all about "write it once, write it well enough to be extensible and forget about it."
The importance of this principle relies on the fact that a module may change from time to time based on new requirements. In case the new requirements arrive after the module was written, tested and uploaded to production, modifying this module is usually bad practice, especially when other modules depend on it. In order to prevent this situation, we can use the Open-Closed Principle.
Global Declarations
enum Color {
BLUE = 'blue',
GREEN = 'green',
RED = 'red'
}
enum Size {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large'
}
class Product {
private _name: string;
private _color: Color;
private _size: Size;
constructor (name: string, color: Color, size: Size) {
this._name = name;
this._color = color;
this._size = size;
}
public get name(): string { return this._name; }
public get color(): Color { return this._color; }
public get size(): Size { return this._size; }
}
class Inventory {
private _products: Product[] = [];
public add(product: Product): void {
this._products.push(product);
}
addArray(products: Product[]) {
for (const product of products) {
this.add(product);
}
}
public get products(): Product[] {
return this._products;
}
}
Violation
Let's describe a scenario where we implement a products filtering class. Let's add the ability to filter the products by color.
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
}
We've tested and deployed this code to production.
A few days later the client requests for a new ability - filtering by size as well. We then modify the class to support the new requirement.
The Open-Closed Principle is now violated!
class ProductsFilter {
byColor(inventory: Inventory, color: Color): Product[] {
return inventory.products.filter(p => p.color === color);
}
bySize(inventory: Inventory, size: Size): Product[] {
return inventory.products.filter(p => p.size === size);
}
}
Resolution
The correct way to implement the filtering mechanism without violating OCP should be made using "Specifications" classes.
abstract class Specification {
public abstract isValid(product: Product): boolean;
}
class ColorSpecification extends Specification {
private _color: Color;
constructor (color) {
super();
this._color = color;
}
public isValid(product: Product): boolean {
return product.color === this._color;
}
}
class SizeSpecification extends Specification {
private _size: Size;
constructor (size) {
super();
this._size = size;
}
public isValid(product: Product): boolean {
return product.size === this._size;
}
}
// A robust mechanism to allow different combinations of specifications
class AndSpecification extends Specification {
private _specifications: Specification[];
// "...rest" operator, groups the arguments into an array
constructor ((...specifications): Specification[]) {
super();
this._specifications = specifications;
}
public isValid (product: Product): boolean {
return this._specifications.every(specification => specification.isValid(product));
}
}
class ProductsFilter {
public filter (inventory: Inventory, specification: Specification): Product[] {
return inventory.products.filter(product => specification.isValid(product));
}
}
Usage:
const p1: Product = new Product('Apple', Color.GREEN, Size.LARGE);
const p2: Product = new Product('Pear', Color.GREEN, Size.LARGE);
const p3: Product = new Product('Grapes', Color.GREEN, Size.SMALL);
const p4: Product = new Product('Blueberries', Color.BLUE, Size.LARGE);
const p5: Product = new Product('Watermelon', Color.RED, Size.LARGE);
const inventory: Inventory = new Inventory();
inventory.addArray([p1, p2, p3, p4, p5]);
const greenColorSpec: ColorSpecification = new ColorSpecification(Color.GREEN);
const largeSizeSpec: SizeSpecification = new SizeSpecification(Size.LARGE);
const andSpec: AndSpecification = new AndSpecification(greenColorSpec, largeSizeSpec);
const productsFilter: ProductsFilter = new ProductsFilter();
const filteredProducts: Product[] = productsFilter.filter(inventory, andSpec); // All large green products
The filtering mechanism is now fully extensible. The existing classes should never be modified anymore.
In case there is a new filtering requirement, we simply create a new specification. Or maybe If the specification combinations need to be changed, this can be done easily by using the AndSpecification
class.
3. L = Liskov Substitution Principle (LSP)
The Liskov's Substitution Principle (LSP) is an important rule for flexibility and robustness of software components. It was introduced by Barbara Liskov, and became a foundational element of the S.O.L.I.D principles.
LSP states that objects of a superclass should be replaceable with objects of the subclass without affecting the correctness of the program. In other words, a subclass should extend the behaviors of a superclass without changing its original functionality. Adopting this approach leads to increased quality of software components, ensuring reusability and decreases unintended side effects.
Violation
The example below illustrates a scenario where the Liskov Substitution Principle (LSP) is violated. An indication of this violation can be observed by examining the program's behavior when the Rectangle
object is replaced with a Square
object.
Declarations:
class Rectangle {
protected _width: number;
protected _height: number;
constructor (width: number, height: number) {
this._width = width;
this._height = height;
}
get width (): number { return this._width; }
get height (): number { return this._height; }
set width (width: number) { this._width = width; }
set height (height: number) { this._height = height; }
getArea (): number {
return this._width * this._height;
}
}
// A square is also rectangle
class Square extends Rectangle {
get width (): number { return this._width; }
get height (): number { return this._height; }
set height (height: number) {
this._height = this._width = height; // Changing both width & height
}
set width (width: number) {
this._width = this._height = width; // Changing both width & height
}
}
function increaseRectangleWidth(rectangle: Rectangle, byAmount: number) {
rectangle.width += byAmount;
}
Usage:
const rectangle: Rectangle = new Rectangle(5, 5);
const square: Square = new Square(5, 5);
console.log(rectangle.getArea()); // Expected: 25, Got: 25 (V)
console.log(square.getArea()); // Expected: 25, Got: 25 (V)
// LSP Violation Indication: Can't replace object 'rectangle' (superclass) with 'square' (subclass) since the results would be different.
increaseRectangleWidth(rectangle, 5);
increaseRectangleWidth(square, 5);
console.log(rectangle.getArea()); // Expected: 50, Got: 50 (V)
// LSP Violation, increaseRectangleWidth() changed both width and height of the square, unexpected behavior.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)
Resolution
The refactored code now adheres to LSP by ensuring that objects of the superclass Shape
can be replaced with objects of the subclasses Rectangle
and Square
without affecting the correctness of the calculated area, also without introducing any unwanted side-effects that alter the program's behavior.
Declarations:
abstract class Shape {
public abstract getArea(): number;
}
class Rectangle extends Shape {
private _width: number;
private _height: number;
constructor (width: number, height: number) {
super();
this._width = width;
this._height = height;
}
getArea (): number { return this._width * this._height; }
}
class Square extends Shape {
private _side: number;
constructor (side: number) {
super();
this._side = side;
}
getArea (): number { return this._side * this._side; }
}
function displayArea (shape: Shape): void {
console.log(shape.getArea());
}
Usage:
const rectangle: Rectangle = new Rectangle(5, 10);
const square: Square = new Square(5);
// The rectangle's area is correctly calculated
displayArea(rectangle); // Expected: 50, Got: 50 (V)
// The square's area is correctly calculated
displayArea(square); // Expected: 25, Got: 25 (V)
4. I = Interface Segregation Principle (ISP)
Interface Segregation Principle (ISP) emphasizes the importance of creating client-specific interfaces rather than one-size-fits-all.
This approach concentrates classes based on the client's needs, eliminating scenarios where a class must implement methods it does not actually use or need.
By applying the Interface Segregation Principle, software systems can be built in much more flexible, easy to understand and easy to refactor manners. Let's take a look at an example.
Violation
The ISP rule is violated here since Robot
must implement the eat()
function which is completely unnecessary.
interface Worker {
work(): void;
eat(): void;
}
class Developer implements Worker {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating..');
}
}
class Robot implements Worker {
public work(): void {
console.log('Building a car..');
}
// ISP Violation: Robot is forced to implement this function even when unnecessary
public eat(): void {
throw new Error('Cannot eat!');
}
}
Resolution
The example below represents a resolution for the problem we previously encountered. The interfaces are now more concise and more client-specific, allowing client classes to implement only the methods that are relevant to them.
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Developer implements Workable, Eatable {
public work(): void {
console.log('Coding..');
}
public eat(): void {
console.log('Eating...');
}
}
class Robot implements Workable {
public work(): void {
console.log('Building a car..');
}
// No need to implement eat(), adhering ISP.
}
ISP Before & After:
5. D = Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) is the final S.O.L.I.D principle with a focus on reducing coupling between low-level modules (e.g. data reading/writing) and high-level modules (that perform the key operations) by using abstractions.
DIP is crucial for designing software that is resilient to change, modular, and easy to update.
DIP Key Guidelines Are:
High level modules should not depend on low level modules. Both should depend on abstractions. Meaning that the functionality of the application should not rely on specific implementations, in order to make the system more flexible and easier to update or replace low-level implementations.
Abstractions should not depend on details. Details should depend on abstractions. This encourages the design to focus on what operations are actually needed rather than on how those operations are implemented.
Violation
Let's take a look at an example that showcases a Dependency Inversion Principle (DIP) violation.
MessageProcessor
(high-level module) is tightly coupled and directly dependent on the FileLogger
(low-level module), violating the principle because it does not depend on an abstraction layer, but rather on a concrete class implementation.
Bonus: There is also a violation of the Open-Closed Principle (OCP). If we would like to change the logging mechanism to write to the database instead of to a file, we would be forced to directly modify the MessageProcessor
function.
import fs from 'fs';
// Low Level Module
class FileLogger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// High Level Module
class MessageProcessor {
// DIP Violation: This high-level module is is tightly coupled with the low-level module (FileLogger), making the system less flexible and harder to maintain or extend.
private logger = new FileLogger();
processMessage(message: string): void {
this.logger.logMessage(message);
}
}
Resolution
The following refactored code represents the changes needed to be made in order to adhere to the Dependency Inversion Principle (DIP). In contrast to the previous example where the high-level class MessageProcessor
held a private property of the concrete low-level class FileLogger
, it now instead holds a private property of type Logger
- the interface that represents the abstraction layer.
This better approach reduces dependencies between the classes, thus making the code much more scalable and maintainable.
Declarations:
import fs from 'fs';
// Abstraction Layer
interface Logger {
logMessage(message: string): void;
}
// Low Level Module #1
class FileLogger implements Logger {
logMessage(message: string): void {
fs.writeFileSync('somefile.txt', message);
}
}
// Low Level Module #2
class ConsoleLogger implements Logger {
logMessage(message: string): void {
console.log(message);
}
}
// High Level Module
class MessageProcessor {
// Resolved: The high level module is now loosely coupled with the low level logger modules.
private _logger: Logger;
constructor (logger: Logger) {
this._logger = logger;
}
processMessage (message: string): void {
this._logger.logMessage(message);
}
}
Usage:
const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();
// Now the logging mechanism can be easily replaced
const messageProcessor = new MessageProcessor(consoleLogger);
messageProcessor.processMessage('Hello');
DIP Before & After:
Conclusion
By following the S.O.L.I.D principles, developers can avoid common pitfalls such as tight coupling, lack of flexibility, poor code reusability and general maintainance difficulties when developing or maintaining software systems at any scale. Mastering these principles is another step towards becoming a better software engineer.
Top comments (13)
Thanks for provding such a clear explantion to SOLID.
By the way, there is a small error in the article. The actual result and expected result in the following code snippet used in the LSP section should be swapped.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)
should be:
console.log(square.getArea()); //Expected: 100, Got: 50 (X)
Thanks for the comment Matthew!
The mentioned code block was meant to describe the Lisokv's principle violation, where increasing the square's width using
increaseRectangleWidth()
unexpectedly affects both dimensions (width & height) due to theSquare
class setter, eventually leading to an area of100
instead of50
.increaseRectangleWidth()
by its own nature should increase only the width of the provided rectangle, however all the square's sides were increased, leading to the actual violation.I really enjoyed catching up on the SOLID principles. Thanks!
Glad to hear Nathan, thanks for the feedback!
Do you have any linting solution to catch the SOLID violations automatically ?
From my experience that depends of language you are using
but any static analyzer would be work for you
Here are a list per language github.com/awesome-security/awesom...
Nice Read
Thanks for the feedback!
Great article! It is always more understandable and beneficial when the explanation has examples alongside the theory. :)
Thanks! Glad you liked it :)
Great article about what is arguably one of the most useful principles in software development.
Thanks for the feedback!
Single reason to change. Meaning that the product manager you initially presented is perfectly fine unless your analysis predicts high frequency changes form the start.
Otherwise you are looking at meaningless over engineering which is probably the worst you can do at the start.