SOLID Principles: The Architectural Antidote to Rigid and Fragile Code
Technical debt often stems from prioritizing speed over structure. This guide analyzes the 5 SOLID principles to cure common software pathologies like rigidity, fragility, and immobility, transforming "STUPID" code into maintainable systems.
The S.O.L.I.D design principles originate from Object-Oriented Programming (OOP) guidelines. They are designed to develop software that is easy to maintain and extend, prevents code smells, facilitates refactoring, and promotes agility. Ultimately, they allow rapid and frequent changes without introducing bugs.
Generally, technical debt is the result of prioritizing speedy delivery over perfect code. To keep it under control, developers should use SOLID principles during development.
Robert Martin (Uncle Bob) is credited with formulating the SOLID principles. He stated four major software issues that arise if S.O.L.I.D is not followed diligently:
- Rigidity: Implementing even a small change is difficult since it is likely to translate into a cascade of changes throughout the system.
- Fragility: Any change tends to break the software in many places, even in areas not conceptually related to the change.
- Immobility: We are unable to reuse modules from other projects or within the same project because those modules have too many dependencies.
- Viscosity: It is difficult to implement new features the right way, leading developers to choose the "easy" (hacky) path.
Note: SOLID is a guideline, not a rule. It is important to understand the crux of it and incorporate it with crisp judgment. There can be cases when only a few principles out of all are required.
S.O.L.I.D stands for:
- Single Responsibility Principle (SRP)
- Open Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
Every function, class, or module should have one, and only one, reason to change. This implies it should have only one job and encapsulate it within the class (promoting stronger cohesion).
It supports the concept of "Separation of Concerns" — do one thing, and do it well!
For example, consider this class:
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() {
return this.dish;
}
// Violates SRP: Handles DB logic
saveDish(dish) {
// database saving logic
}
}
This class violates SRP. It is managing the properties of the menu while also handling the database operations. If there is any update in the database management functions, it will affect the property management functions as well, resulting in tight coupling.
Here is a more cohesive and less coupled class instance:
// Responsible purely for menu properties
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() {
return this.dish;
}
}
// Responsible purely for Database management
class MenuDB {
getDishes(dish) {
// fetch logic
}
saveDishes(dish) {
// save logic
}
}
2. Open Closed Principle (OCP)
Classes, functions, or modules should be open for extensibility, but closed for modification. If you create and publish a class, changing implementation details inside it can break the code of those who have started using it. Abstraction is the key to getting OCP right.
Consider this problematic example:
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() { /*...*/ }
}
function getCuisines(dishes) {
for (let i = 0; i < dishes.length; i++) {
if (dishes[i].name === "Burrito") {
console.log("Mexican");
} else if (dishes[i].name === "Pizza") {
console.log("Italian");
}
}
}
The function getCuisines() does not meet the Open-Closed Principle because it cannot be closed against new kinds of dishes. If we add a new dish, say "Croissant", we need to modify the function explicitly:
// We are forced to modify existing code for new features
if (dishes[i].name === "Croissant") {
console.log("French");
}
Here is how we can make the codebase meet the OCP standard using polymorphism:
class Menu {
constructor(dish) {
this.dish = dish;
}
// Abstract method idea
getCuisine() {
throw new Error("Method 'getCuisine()' must be implemented.");
}
}
class Burrito extends Menu {
getCuisine() {
return "Mexican";
}
}
class Pizza extends Menu {
getCuisine() {
return "Italian";
}
}
class Croissant extends Menu {
getCuisine() {
return "French";
}
}
function getCuisines(dishes) {
for (let i = 0; i < dishes.length; i++) {
// Works for any new dish without modifying this function
console.log(dishes[i].getCuisine());
}
}
3. Liskov Substitution Principle (LSP)
A sub-class must be substitutable for its base type. This states that we can substitute a subclass for its base class without affecting behavior, confirming the "is-a" relationship.
Subclasses must fulfill a contract defined by the base class. In this sense, it is related to Design by Contract.
For example, Menu has a function getCuisines which is used by Burrito, Pizza, and Croissant without breaking the application logic.
class Menu {
constructor(dish) {
this.dish = dish;
}
getCuisine() {
return this.cuisine;
}
}
class Burrito extends Menu {
constructor() {
super("Burrito");
this.cuisine = "Mexican";
}
}
class Pizza extends Menu {
constructor() {
super("Pizza");
this.cuisine = "Italian";
}
}
// Both subclasses can replace the parent Menu class usage
const burrito = new Burrito();
const pizza = new Pizza();
4. Interface Segregation Principle (ISP)
A client should never be forced to implement an interface that it doesn’t use, and clients shouldn’t be forced to depend on methods they do not use.
Consider this monolithic interface:
// Conceptual Interface
interface ICuisines {
mexican();
italian();
french();
}
// Forced implementation
class Burrito implements ICuisines {
mexican() { /* implementation */ }
italian() { throw new Error("Not supported"); }
french() { throw new Error("Not supported"); }
}
If we add a new method to the interface, all other classes must declare that method. To solve it, split the interfaces:
interface MexicanFood {
mexican();
}
interface ItalianFood {
italian();
}
class Burrito implements MexicanFood {
mexican() { /* logic */ }
}
Many client-specific interfaces are better than one general-purpose interface.
5. Dependency Inversion Principle (DIP)
Entities must depend on abstractions, not on concretions. High-level modules must not depend on low-level modules; both should decouple and make use of abstractions.
- High-level modules: Solve real problems and use cases. They map to the business domain (what the software should do).
- Low-level modules: Contain implementation details required to execute business policies (how the software does it).
Bad Example (Tight Coupling):
const mysql = require('mysql');
const pool = mysql.createPool({});
class MenuDB {
constructor() {
// Direct dependency on a specific database driver
this.db = pool;
}
saveDishes() {
this.db.save();
}
}
Good Example (Decoupled):
// Abstraction
interface IDatabase {
save();
}
class MenuDB {
// Depends on interface/abstraction, not specific MySQL implementation
constructor(db) {
this.db = db;
}
saveDishes() {
this.db.save();
}
}
Ending Note
Code that follows S.O.L.I.D. principles can be easily shared, extended, modified, tested, and refactored. Anti-patterns and improper understanding can lead to STUPID code:
- Singleton
- Tight Coupling
- Untestability
- Premature Optimization
- Indescriptive Naming
- Duplication
SOLID helps developers steer clear of these pitfalls.
What's Your Reaction?
Like
0
Dislike
0
Love
0
Funny
0
Angry
0
Sad
0
Wow
0