SOLID Principles in JavaScript: Practical Examples and Common Violations
Master SOLID principles in JavaScript and TypeScript with practical examples. Learn Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion with real-world code.
Table of Contents
- Introduction
- Understanding SOLID Principles
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
- Applying SOLID Principles Together
- Common Violations and How to Fix Them
- SOLID Principles in Modern JavaScript
- Best Practices and Guidelines
- Conclusion
Introduction
SOLID principles are fundamental guidelines for writing maintainable, scalable, and robust software. Originally formulated by Robert C. Martin (Uncle Bob) for object-oriented programming, these principles have proven invaluable in JavaScript and TypeScript development, even though JavaScript’s dynamic nature and functional programming capabilities offer different approaches to achieving the same goals.
Understanding SOLID principles helps you write code that is easier to understand, test, and modify. When you follow these principles, your code becomes more modular, reduces coupling between components, and increases cohesion within modules. This makes your codebase more resilient to change and easier to extend without breaking existing functionality.
In JavaScript and TypeScript, SOLID principles manifest differently than in traditional object-oriented languages. JavaScript’s flexibility allows for multiple patterns—from classes to functions, from interfaces to duck typing—but the underlying principles remain the same. Whether you’re building React components, Node.js APIs, or complex frontend applications, applying SOLID principles will significantly improve your code quality.
This comprehensive guide will teach you each SOLID principle with practical JavaScript and TypeScript examples. You’ll learn how to identify violations, refactor code to follow best practices, and apply these principles in real-world scenarios. By the end, you’ll be able to write more maintainable code that stands the test of time.
Understanding SOLID Principles
SOLID is an acronym representing five key principles of object-oriented design:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
These principles work together to create software that is:
- Maintainable: Easy to understand and modify
- Extensible: Can grow without breaking existing code
- Testable: Components can be tested in isolation
- Reusable: Code can be shared across different contexts
- Flexible: Can adapt to changing requirements
Why SOLID Matters in JavaScript
JavaScript’s dynamic nature and multiple paradigms (functional, object-oriented, procedural) make SOLID principles even more important. Without clear guidelines, JavaScript codebases can quickly become tangled webs of dependencies and responsibilities.
✅ Benefits of SOLID in JavaScript:
- Prevents “God objects” and “God functions” that do too much
- Makes code easier to test with mocks and stubs
- Enables better code reuse and composition
- Reduces bugs by isolating concerns
- Improves team collaboration through clear boundaries
❌ Without SOLID:
- Code becomes tightly coupled and hard to change
- Small changes require modifications in multiple places
- Testing becomes difficult due to dependencies
- Code reuse is limited
- Technical debt accumulates rapidly
Let’s dive into each principle with practical examples.
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class or function should have only one reason to change. In other words, each module should be responsible for a single part of the functionality.
Understanding SRP
A “reason to change” means a change in requirements that affects that specific responsibility. If a class has multiple reasons to change, it violates SRP and becomes harder to maintain.
Violation Example ❌
// ❌ Violates SRP: User class handles multiple responsibilitiesclass User { constructor(name, email) { this.name = name; this.email = email; }
// Responsibility 1: User data management getName() { return this.name; }
getEmail() { return this.email; }
// Responsibility 2: Data persistence (should be separate) saveToDatabase() { // Database logic here console.log(`Saving ${this.name} to database...`); }
// Responsibility 3: Email sending (should be separate) sendEmail(subject, body) { // Email logic here console.log(`Sending email to ${this.email}...`); }
// Responsibility 4: Data validation (could be separate) validate() { if (!this.email.includes("@")) { throw new Error("Invalid email"); } }}This User class violates SRP because it has multiple reasons to change:
- Changes to user data structure
- Changes to database schema or ORM
- Changes to email service provider
- Changes to validation rules
Correct Implementation ✅
// ✅ Follows SRP: Each class has a single responsibility
// User class: Only manages user dataclass User { constructor(name, email) { this.name = name; this.email = email; }
getName() { return this.name; }
getEmail() { return this.email; }}
// UserRepository: Handles data persistenceclass UserRepository { async save(user) { // Database logic here console.log(`Saving ${user.getName()} to database...`); // Return saved user or ID }
async findById(id) { // Database query logic }}
// EmailService: Handles email sendingclass EmailService { async sendEmail(to, subject, body) { // Email sending logic console.log(`Sending email to ${to}...`); }}
// UserValidator: Handles validationclass UserValidator { validate(user) { if (!user.getEmail().includes("@")) { throw new Error("Invalid email"); } if (!user.getName() || user.getName().trim().length === 0) { throw new Error("Name is required"); } }}
// Usageconst user = new User("John Doe", "john@example.com");const validator = new UserValidator();const repository = new UserRepository();const emailService = new EmailService();
validator.validate(user);await repository.save(user);await emailService.sendEmail( user.getEmail(), "Welcome", "Welcome to our platform!",);TypeScript Example with Interfaces ✅
// ✅ TypeScript implementation with clear interfaces
interface UserData { name: string; email: string;}
class User { constructor(private data: UserData) {}
getName(): string { return this.data.name; }
getEmail(): string { return this.data.email; }
getData(): UserData { return { ...this.data }; // Return copy to prevent mutation }}
interface UserRepository { save(user: User): Promise<void>; findById(id: string): Promise<User | null>;}
class DatabaseUserRepository implements UserRepository { async save(user: User): Promise<void> { // Database implementation console.log(`Saving ${user.getName()} to database...`); }
async findById(id: string): Promise<User | null> { // Database query return null; }}
interface EmailService { send(to: string, subject: string, body: string): Promise<void>;}
class SMTPEmailService implements EmailService { async send(to: string, subject: string, body: string): Promise<void> { // Email sending implementation console.log(`Sending email to ${to}...`); }}Functional Programming Approach ✅
In functional programming, SRP translates to functions that do one thing:
// ✅ Functional approach: Each function has a single responsibility
// Pure functions for user dataconst createUser = (name, email) => ({ name, email });const getUserName = (user) => user.name;const getUserEmail = (user) => user.email;
// Separate functions for different concernsconst validateUser = (user) => { if (!user.email.includes("@")) { throw new Error("Invalid email"); } return user;};
const saveUser = async (user) => { // Database logic console.log(`Saving ${user.name}...`); return user;};
const sendWelcomeEmail = async (user) => { // Email logic console.log(`Sending email to ${user.email}...`);};
// Compose functions togetherconst registerUser = async (name, email) => { const user = createUser(name, email); validateUser(user); await saveUser(user); await sendWelcomeEmail(user); return user;};React Component Example ✅
// ✅ React component following SRP
// Separate concerns into different components/hooks
// UserDisplay: Only responsible for displaying user dataconst UserDisplay: React.FC<{ user: User }> = ({ user }) => { return ( <div> <h2>{user.getName()}</h2> <p>{user.getEmail()}</p> </div> );};
// useUserData: Only responsible for fetching user dataconst useUserData = (userId: string) => { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true);
useEffect(() => { const fetchUser = async () => { const repository = new DatabaseUserRepository(); const fetchedUser = await repository.findById(userId); setUser(fetchedUser); setLoading(false); }; fetchUser(); }, [userId]);
return { user, loading };};
// UserProfile: Composes display and data fetchingconst UserProfile: React.FC<{ userId: string }> = ({ userId }) => { const { user, loading } = useUserData(userId);
if (loading) return <div>Loading...</div>; if (!user) return <div>User not found</div>;
return <UserDisplay user={user} />;};💡 Key Takeaway: If you find yourself using “and” to describe what a class or function does, it likely violates SRP. Split it into separate, focused modules.
Open/Closed Principle (OCP)
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.
Understanding OCP
The principle encourages you to design systems where new features can be added through:
- Extension: Adding new classes, functions, or modules
- Not Modification: Avoiding changes to existing, working code
Violation Example ❌
// ❌ Violates OCP: Adding new shapes requires modifying existing codeclass AreaCalculator { calculate(shape) { if (shape.type === "circle") { return Math.PI * shape.radius ** 2; } else if (shape.type === "rectangle") { return shape.width * shape.height; } else if (shape.type === "triangle") { return (shape.base * shape.height) / 2; } // Adding a new shape requires modifying this method throw new Error("Unknown shape type"); }}
// Usageconst calculator = new AreaCalculator();const circle = { type: "circle", radius: 5 };const rectangle = { type: "rectangle", width: 4, height: 6 };
console.log(calculator.calculate(circle)); // 78.54console.log(calculator.calculate(rectangle)); // 24This violates OCP because adding a new shape (e.g., ellipse) requires modifying the calculate method, which could break existing functionality.
Correct Implementation ✅
// ✅ Follows OCP: New shapes can be added without modifying existing code
// Abstract base class or interfaceclass Shape { calculateArea() { throw new Error("calculateArea must be implemented"); }}
// Concrete implementationsclass Circle extends Shape { constructor(radius) { super(); this.radius = radius; }
calculateArea() { return Math.PI * this.radius ** 2; }}
class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
calculateArea() { return this.width * this.height; }}
class Triangle extends Shape { constructor(base, height) { super(); this.base = base; this.height = height; }
calculateArea() { return (this.base * this.height) / 2; }}
// AreaCalculator is closed for modification but open for extensionclass AreaCalculator { calculate(shape) { // Works with any Shape subclass without modification return shape.calculateArea(); }}
// Usageconst calculator = new AreaCalculator();const circle = new Circle(5);const rectangle = new Rectangle(4, 6);const triangle = new Triangle(3, 4);
console.log(calculator.calculate(circle)); // 78.54console.log(calculator.calculate(rectangle)); // 24console.log(calculator.calculate(triangle)); // 6
// ✅ Adding a new shape doesn't require changing AreaCalculatorclass Ellipse extends Shape { constructor(radiusX, radiusY) { super(); this.radiusX = radiusX; this.radiusY = radiusY; }
calculateArea() { return Math.PI * this.radiusX * this.radiusY; }}
const ellipse = new Ellipse(5, 3);console.log(calculator.calculate(ellipse)); // Works without modifying calculator!Strategy Pattern Example ✅
// ✅ Using Strategy Pattern to follow OCP
interface PaymentStrategy { pay(amount: number): void;}
class CreditCardPayment implements PaymentStrategy { constructor(private cardNumber: string) {}
pay(amount: number): void { console.log(`Paying ${amount} with credit card ${this.cardNumber}`); }}
class PayPalPayment implements PaymentStrategy { constructor(private email: string) {}
pay(amount: number): void { console.log(`Paying ${amount} with PayPal ${this.email}`); }}
class BankTransferPayment implements PaymentStrategy { constructor(private accountNumber: string) {}
pay(amount: number): void { console.log(`Paying ${amount} via bank transfer to ${this.accountNumber}`); }}
// PaymentProcessor is closed for modificationclass PaymentProcessor { constructor(private strategy: PaymentStrategy) {}
processPayment(amount: number): void { this.strategy.pay(amount); }
// Can change strategy without modifying the class setStrategy(strategy: PaymentStrategy): void { this.strategy = strategy; }}
// Usageconst processor = new PaymentProcessor(new CreditCardPayment("1234-5678"));processor.processPayment(100);
// ✅ Adding new payment method doesn't require changing PaymentProcessorclass CryptocurrencyPayment implements PaymentStrategy { constructor(private walletAddress: string) {}
pay(amount: number): void { console.log(`Paying ${amount} with crypto to ${this.walletAddress}`); }}
processor.setStrategy(new CryptocurrencyPayment("0x123..."));processor.processPayment(100);Functional Approach with Composition ✅
// ✅ Functional approach: Using composition and higher-order functions
// Base shape functionsconst circleArea = (radius) => Math.PI * radius ** 2;const rectangleArea = (width, height) => width * height;const triangleArea = (base, height) => (base * height) / 2;
// Shape objects with area calculationconst createCircle = (radius) => ({ type: "circle", radius, calculateArea: () => circleArea(radius),});
const createRectangle = (width, height) => ({ type: "rectangle", width, height, calculateArea: () => rectangleArea(width, height),});
const createTriangle = (base, height) => ({ type: "triangle", base, height, calculateArea: () => triangleArea(base, height),});
// Calculator function that works with any shapeconst calculateArea = (shape) => { if (typeof shape.calculateArea === "function") { return shape.calculateArea(); } throw new Error("Shape must have calculateArea method");};
// Usageconst circle = createCircle(5);const rectangle = createRectangle(4, 6);
console.log(calculateArea(circle)); // 78.54console.log(calculateArea(rectangle)); // 24
// ✅ Adding new shape doesn't require changing calculateAreaconst createEllipse = (radiusX, radiusY) => ({ type: "ellipse", radiusX, radiusY, calculateArea: () => Math.PI * radiusX * radiusY,});
const ellipse = createEllipse(5, 3);console.log(calculateArea(ellipse)); // Works without modification!React Component Example ✅
// ✅ React component following OCP
// Base button component (closed for modification)interface ButtonProps { onClick: () => void; children: React.ReactNode; className?: string;}
const Button: React.FC<ButtonProps> = ({ onClick, children, className }) => { return ( <button onClick={onClick} className={className}> {children} </button> );};
// Extended button components (open for extension)const PrimaryButton: React.FC<Omit<ButtonProps, 'className'>> = (props) => { return <Button {...props} className="btn-primary" />;};
const SecondaryButton: React.FC<Omit<ButtonProps, 'className'>> = (props) => { return <Button {...props} className="btn-secondary" />;};
const DangerButton: React.FC<Omit<ButtonProps, 'className'>> = (props) => { return <Button {...props} className="btn-danger" />;};
// ✅ New button variants can be added without modifying Button componentconst IconButton: React.FC<ButtonProps & { icon: string }> = ({ icon, children, ...props}) => { return ( <Button {...props} className="btn-icon"> <span className={`icon-${icon}`} /> {children} </Button> );};💡 Key Takeaway: Design your code so that new features are added by creating new classes or functions, not by modifying existing ones. Use polymorphism, composition, and interfaces to achieve this.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. In simpler terms, derived classes must be substitutable for their base classes.
Understanding LSP
LSP ensures that inheritance is used correctly. If a class B extends class A, then anywhere you use A, you should be able to use B without the program breaking or behaving unexpectedly.
Violation Example ❌
// ❌ Violates LSP: Rectangle and Square relationship
class Rectangle { constructor(width, height) { this.width = width; this.height = height; }
setWidth(width) { this.width = width; }
setHeight(height) { this.height = height; }
getArea() { return this.width * this.height; }}
// Square extends Rectangle but violates LSPclass Square extends Rectangle { constructor(side) { super(side, side); }
// Violates LSP: Changing width also changes height setWidth(width) { this.width = width; this.height = width; // This breaks the rectangle contract! }
setHeight(height) { this.width = height; this.height = height; // This breaks the rectangle contract! }}
// Function that expects Rectangle behaviorfunction testRectangle(rectangle) { const areaBefore = rectangle.getArea(); rectangle.setWidth(rectangle.width + 1); const areaAfter = rectangle.getArea();
// This assertion will fail for Square! console.log(`Area increased: ${areaAfter > areaBefore}`);}
const rect = new Rectangle(5, 4);testRectangle(rect); // ✅ Works: Area increased: true
const square = new Square(5);testRectangle(square); // ❌ Breaks: Square doesn't behave like Rectangle!The problem: Square cannot be substituted for Rectangle because it changes the expected behavior (changing width should only affect width, not height).
Correct Implementation ✅
// ✅ Follows LSP: Both Rectangle and Square can be used interchangeably
// Base class with clear contractclass Shape { getArea() { throw new Error("getArea must be implemented"); }}
// Rectangle implementationclass Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
setWidth(width) { this.width = width; }
setHeight(height) { this.height = height; }
getArea() { return this.width * this.height; }}
// Square implementation that follows LSPclass Square extends Shape { constructor(side) { super(); this.side = side; }
setSide(side) { this.side = side; }
getArea() { return this.side ** 2; }}
// Function that works with any Shapefunction testShape(shape) { const area = shape.getArea(); console.log(`Area: ${area}`);}
const rect = new Rectangle(5, 4);testShape(rect); // ✅ Works: Area: 20
const square = new Square(5);testShape(square); // ✅ Works: Area: 25TypeScript Example with Contracts ✅
// ✅ TypeScript example with clear interfaces
interface Readable { read(): string;}
interface Writable { write(data: string): void;}
// Base class that implements Readableclass FileReader implements Readable { constructor(private filePath: string) {}
read(): string { // Read file implementation return `Content from ${this.filePath}`; }}
// Subclass that can substitute FileReaderclass BufferedFileReader extends FileReader { private buffer: string = "";
read(): string { if (this.buffer) { const content = this.buffer; this.buffer = ""; return content; } return super.read(); }
// Additional method doesn't break LSP bufferContent(content: string): void { this.buffer = content; }}
// Function that accepts Readable (works with both)function processReadable(readable: Readable): void { const content = readable.read(); console.log(`Processing: ${content}`);}
const fileReader = new FileReader("file.txt");processReadable(fileReader); // ✅ Works
const bufferedReader = new BufferedFileReader("file.txt");processReadable(bufferedReader); // ✅ Works: Can substitute FileReaderPreconditions and Postconditions ✅
LSP also means that:
- Preconditions (requirements before a method runs) cannot be strengthened in subclasses
- Postconditions (guarantees after a method runs) cannot be weakened in subclasses
// ✅ Example showing preconditions and postconditions
interface Database { // Precondition: connection must be established // Postcondition: returns user data or null getUser(id: number): Promise<User | null>;}
class SQLDatabase implements Database { async getUser(id: number): Promise<User | null> { // Precondition: id must be positive if (id <= 0) { throw new Error("ID must be positive"); } // Implementation return null; }}
class CachedDatabase extends SQLDatabase { private cache: Map<number, User> = new Map();
// ✅ LSP: Precondition not strengthened (still accepts any number) // ✅ LSP: Postcondition not weakened (still returns User | null) async getUser(id: number): Promise<User | null> { // Can check cache first, but doesn't change contract if (this.cache.has(id)) { return this.cache.get(id)!; } const user = await super.getUser(id); if (user) { this.cache.set(id, user); } return user; }}
// ❌ Violation: Strengthening preconditionclass StrictDatabase extends SQLDatabase { // ❌ Violates LSP: Requires id > 1000 (stronger precondition) async getUser(id: number): Promise<User | null> { if (id <= 1000) { throw new Error("ID must be greater than 1000"); } return super.getUser(id); }}React Component Example ✅
// ✅ React components following LSP
// Base button componentinterface BaseButtonProps { onClick: () => void; disabled?: boolean; children: React.ReactNode;}
const BaseButton: React.FC<BaseButtonProps> = ({ onClick, disabled = false, children,}) => { return ( <button onClick={onClick} disabled={disabled}> {children} </button> );};
// Subclass that can substitute BaseButtonconst IconButton: React.FC<BaseButtonProps & { icon: string }> = ({ icon, ...props}) => { return ( <BaseButton {...props}> <span className={`icon-${icon}`} /> {props.children} </BaseButton> );};
// ✅ Function that accepts BaseButtonProps works with bothconst ButtonGroup: React.FC<{ buttons: BaseButtonProps[] }> = ({ buttons,}) => { return ( <div> {buttons.map((button, index) => ( <BaseButton key={index} {...button} /> ))} </div> );};
// Can use both BaseButton and IconButton interchangeablyconst buttons: BaseButtonProps[] = [ { onClick: () => {}, children: 'Click me' }, // IconButton props are compatible with BaseButtonProps];💡 Key Takeaway: Subclasses should enhance, not restrict, the behavior of their base classes. They should accept the same inputs and produce compatible outputs.
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that clients should not be forced to depend on interfaces they don’t use. In other words, create small, focused interfaces rather than large, monolithic ones.
Understanding ISP
Instead of one large interface with many methods, create multiple smaller interfaces. This prevents classes from implementing methods they don’t need.
Violation Example ❌
// ❌ Violates ISP: Large interface forcing unnecessary implementations
class Worker { work() { console.log("Working..."); }
eat() { console.log("Eating..."); }
sleep() { console.log("Sleeping..."); }}
// Robot is forced to implement eat() and sleep() which it doesn't needclass Robot extends Worker { work() { console.log("Robot working..."); }
eat() { // ❌ Robot doesn't eat, but forced to implement throw new Error("Robots don't eat!"); }
sleep() { // ❌ Robot doesn't sleep, but forced to implement throw new Error("Robots don't sleep!"); }}
// Human needs all methodsclass Human extends Worker { work() { console.log("Human working..."); }
eat() { console.log("Human eating..."); }
sleep() { console.log("Human sleeping..."); }}Correct Implementation ✅
// ✅ Follows ISP: Segregated interfaces
// Separate interfaces for different capabilitiesclass Workable { work() { throw new Error("work() must be implemented"); }}
class Eatable { eat() { throw new Error("eat() must be implemented"); }}
class Sleepable { sleep() { throw new Error("sleep() must be implemented"); }}
// Classes only implement what they needclass Robot extends Workable { work() { console.log("Robot working..."); } // ✅ No need to implement eat() or sleep()}
class Human extends Workable { constructor() { super(); // Compose behaviors Object.assign(this, new Eatable(), new Sleepable()); }
work() { console.log("Human working..."); }
eat() { console.log("Human eating..."); }
sleep() { console.log("Human sleeping..."); }}
// Usageconst robot = new Robot();robot.work(); // ✅ Only implements what it needs
const human = new Human();human.work(); // ✅ Implements all needed behaviorshuman.eat();human.sleep();TypeScript Example with Interfaces ✅
// ✅ TypeScript: Segregated interfaces
// Small, focused interfacesinterface Readable { read(): string;}
interface Writable { write(data: string): void;}
interface Deletable { delete(): void;}
// Classes implement only what they needclass ReadOnlyFile implements Readable { read(): string { return "File content"; } // ✅ Doesn't need to implement Writable or Deletable}
class ReadWriteFile implements Readable, Writable { read(): string { return "File content"; }
write(data: string): void { console.log(`Writing: ${data}`); } // ✅ Doesn't need to implement Deletable}
class FullAccessFile implements Readable, Writable, Deletable { read(): string { return "File content"; }
write(data: string): void { console.log(`Writing: ${data}`); }
delete(): void { console.log("Deleting file..."); }}
// Functions accept only what they needfunction processReadable(readable: Readable): void { const content = readable.read(); console.log(`Processing: ${content}`);}
function processWritable(writable: Writable): void { writable.write("New data");}
// Usageconst readOnly = new ReadOnlyFile();processReadable(readOnly); // ✅ Works
const readWrite = new ReadWriteFile();processReadable(readWrite); // ✅ WorksprocessWritable(readWrite); // ✅ WorksReact Component Example ✅
// ✅ React: Segregated component props
// Instead of one large props interface, use smaller onesinterface BaseProps { className?: string; id?: string;}
interface ClickableProps { onClick: () => void;}
interface DisableableProps { disabled?: boolean;}
interface LoadableProps { loading?: boolean;}
// Components only use what they needconst Button: React.FC<BaseProps & ClickableProps & DisableableProps> = ({ onClick, disabled, className, children,}) => { return ( <button onClick={onClick} disabled={disabled} className={className}> {children} </button> );};
const LoadingButton: React.FC< BaseProps & ClickableProps & DisableableProps & LoadableProps> = ({ loading, ...props }) => { return ( <Button {...props} disabled={props.disabled || loading}> {loading ? 'Loading...' : props.children} </Button> );};
// ✅ Simple link doesn't need all button propsconst Link: React.FC<BaseProps & ClickableProps & { href: string }> = ({ href, onClick, className, children,}) => { return ( <a href={href} onClick={onClick} className={className}> {children} </a> );};JavaScript Object Composition ✅
// ✅ JavaScript: Using object composition instead of inheritance
// Small, focused objectsconst workable = { work() { console.log("Working..."); },};
const eatable = { eat() { console.log("Eating..."); },};
const sleepable = { sleep() { console.log("Sleeping..."); },};
// Compose only what's neededconst robot = Object.assign({}, workable);const human = Object.assign({}, workable, eatable, sleepable);
robot.work(); // ✅ Only has work()human.work(); // ✅ Has all behaviorshuman.eat();human.sleep();💡 Key Takeaway: Create small, focused interfaces. If a class is forced to implement methods it doesn’t use, split the interface into smaller ones.
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.
Understanding DIP
DIP promotes loose coupling by ensuring that:
- High-level modules (business logic) don’t depend on low-level modules (infrastructure)
- Both depend on abstractions (interfaces, abstract classes)
- Dependencies point toward abstractions, not concrete implementations
Violation Example ❌
// ❌ Violates DIP: High-level module depends on low-level module
// Low-level module (infrastructure)class MySQLDatabase { connect() { console.log("Connecting to MySQL..."); }
query(sql) { console.log(`Executing MySQL query: ${sql}`); return []; }}
// High-level module (business logic) depends directly on MySQLDatabaseclass UserService { constructor() { // ❌ Tightly coupled to MySQLDatabase this.database = new MySQLDatabase(); }
getUser(id) { this.database.connect(); return this.database.query(`SELECT * FROM users WHERE id = ${id}`); }}
// Problem: Changing database requires modifying UserServiceCorrect Implementation ✅
// ✅ Follows DIP: Both depend on abstraction
// Abstraction (interface)class Database { connect() { throw new Error("connect() must be implemented"); }
query(sql) { throw new Error("query() must be implemented"); }}
// Low-level modules implement the abstractionclass MySQLDatabase extends Database { connect() { console.log("Connecting to MySQL..."); }
query(sql) { console.log(`Executing MySQL query: ${sql}`); return []; }}
class PostgreSQLDatabase extends Database { connect() { console.log("Connecting to PostgreSQL..."); }
query(sql) { console.log(`Executing PostgreSQL query: ${sql}`); return []; }}
class MongoDBDatabase extends Database { connect() { console.log("Connecting to MongoDB..."); }
query(sql) { console.log(`Executing MongoDB query: ${sql}`); return []; }}
// High-level module depends on abstraction, not concrete implementationclass UserService { constructor(database) { // ✅ Depends on abstraction (Database), not concrete class this.database = database; }
getUser(id) { this.database.connect(); return this.database.query(`SELECT * FROM users WHERE id = ${id}`); }}
// Usage: Dependency injectionconst mysqlDb = new MySQLDatabase();const userService = new UserService(mysqlDb);userService.getUser(1);
// ✅ Can easily switch to different databaseconst postgresDb = new PostgreSQLDatabase();const userService2 = new UserService(postgresDb);userService2.getUser(1);TypeScript Example with Dependency Injection ✅
// ✅ TypeScript: Using interfaces and dependency injection
// Abstractioninterface Database { connect(): Promise<void>; query<T>(sql: string): Promise<T[]>;}
interface Logger { log(message: string): void; error(message: string): void;}
// Low-level implementationsclass MySQLDatabase implements Database { async connect(): Promise<void> { console.log("Connecting to MySQL..."); }
async query<T>(sql: string): Promise<T[]> { console.log(`Executing MySQL query: ${sql}`); return []; }}
class ConsoleLogger implements Logger { log(message: string): void { console.log(`[LOG] ${message}`); }
error(message: string): void { console.error(`[ERROR] ${message}`); }}
// High-level module depends on abstractionsclass UserService { constructor( private database: Database, // ✅ Depends on interface private logger: Logger, // ✅ Depends on interface ) {}
async getUser(id: number): Promise<User | null> { try { await this.database.connect(); this.logger.log(`Fetching user ${id}`); const users = await this.database.query<User>( `SELECT * FROM users WHERE id = ${id}`, ); return users[0] || null; } catch (error) { this.logger.error(`Failed to fetch user ${id}: ${error}`); throw error; } }}
// Usage: Dependency injectionconst database = new MySQLDatabase();const logger = new ConsoleLogger();const userService = new UserService(database, logger);
// ✅ Can easily swap implementationsclass FileLogger implements Logger { log(message: string): void { // Write to file }
error(message: string): void { // Write error to file }}
const fileLogger = new FileLogger();const userService2 = new UserService(database, fileLogger);React Component Example ✅
// ✅ React: Dependency injection with context
// Abstractioninterface ApiClient { get<T>(url: string): Promise<T>; post<T>(url: string, data: unknown): Promise<T>;}
// Low-level implementationclass FetchApiClient implements ApiClient { async get<T>(url: string): Promise<T> { const response = await fetch(url); return response.json(); }
async post<T>(url: string, data: unknown): Promise<T> { const response = await fetch(url, { method: 'POST', body: JSON.stringify(data), }); return response.json(); }}
// High-level hook depends on abstractionconst useUserData = (apiClient: ApiClient, userId: string) => { const [user, setUser] = useState<User | null>(null);
useEffect(() => { apiClient.get<User>(`/api/users/${userId}`).then(setUser); }, [apiClient, userId]);
return user;};
// Component receives dependencyconst UserProfile: React.FC<{ apiClient: ApiClient; userId: string }> = ({ apiClient, userId,}) => { const user = useUserData(apiClient, userId); return <div>{user?.name}</div>;};
// Usageconst apiClient = new FetchApiClient();<UserProfile apiClient={apiClient} userId="123" />;
// ✅ Can easily swap for testing or different implementationclass MockApiClient implements ApiClient { async get<T>(url: string): Promise<T> { return { id: '123', name: 'Test User' } as T; }
async post<T>(url: string, data: unknown): Promise<T> { return {} as T; }}
const mockClient = new MockApiClient();<UserProfile apiClient={mockClient} userId="123" />;Functional Programming Approach ✅
// ✅ Functional approach: Higher-order functions and dependency injection
// Abstraction: Functions that match a signatureconst createUserService = (database, logger) => { // High-level logic depends on injected dependencies return { getUser: async (id) => { logger.log(`Fetching user ${id}`); await database.connect(); const users = await database.query( `SELECT * FROM users WHERE id = ${id}`, ); return users[0] || null; }, };};
// Low-level implementationsconst mysqlDatabase = { connect: async () => console.log("Connecting to MySQL..."), query: async (sql) => { console.log(`Executing: ${sql}`); return []; },};
const consoleLogger = { log: (message) => console.log(`[LOG] ${message}`), error: (message) => console.error(`[ERROR] ${message}`),};
// Usage: Inject dependenciesconst userService = createUserService(mysqlDatabase, consoleLogger);await userService.getUser(1);
// ✅ Can easily swap implementationsconst postgresDatabase = { connect: async () => console.log("Connecting to PostgreSQL..."), query: async (sql) => { console.log(`Executing: ${sql}`); return []; },};
const userService2 = createUserService(postgresDatabase, consoleLogger);💡 Key Takeaway: Depend on abstractions (interfaces, function signatures) rather than concrete implementations. Use dependency injection to provide implementations at runtime.
Applying SOLID Principles Together
Real-world applications benefit from applying all SOLID principles together. Let’s see a comprehensive example:
E-Commerce Order Processing System ✅
// ✅ Complete example applying all SOLID principles
// ===== SRP: Single Responsibility =====
// User entity: Only manages user dataclass User { constructor( private id: string, private name: string, private email: string, ) {}
getId(): string { return this.id; }
getName(): string { return this.name; }
getEmail(): string { return this.email; }}
// Order entity: Only manages order dataclass Order { constructor( private id: string, private userId: string, private items: OrderItem[], private total: number, ) {}
getId(): string { return this.id; }
getTotal(): number { return this.total; }
getItems(): OrderItem[] { return [...this.items]; }}
// ===== DIP: Dependency Inversion =====
// Abstractionsinterface UserRepository { findById(id: string): Promise<User | null>;}
interface OrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>;}
interface PaymentProcessor { processPayment( amount: number, paymentMethod: PaymentMethod, ): Promise<PaymentResult>;}
interface NotificationService { sendEmail(to: string, subject: string, body: string): Promise<void>;}
// ===== OCP: Open/Closed Principle =====
// Payment methods can be extended without modifying processorinterface PaymentMethod { process(amount: number): Promise<PaymentResult>;}
class CreditCardPayment implements PaymentMethod { constructor(private cardNumber: string) {}
async process(amount: number): Promise<PaymentResult> { console.log(`Processing credit card payment: ${amount}`); return { success: true, transactionId: "tx_123" }; }}
class PayPalPayment implements PaymentMethod { constructor(private email: string) {}
async process(amount: number): Promise<PaymentResult> { console.log(`Processing PayPal payment: ${amount}`); return { success: true, transactionId: "tx_456" }; }}
// ===== LSP: Liskov Substitution Principle =====
// All payment methods can substitute PaymentMethod interfaceclass BankTransferPayment implements PaymentMethod { constructor(private accountNumber: string) {}
async process(amount: number): Promise<PaymentResult> { // ✅ Follows same contract as other payment methods console.log(`Processing bank transfer: ${amount}`); return { success: true, transactionId: "tx_789" }; }}
// ===== ISP: Interface Segregation =====
// Small, focused interfacesinterface OrderValidator { validate(order: Order): Promise<ValidationResult>;}
interface OrderCalculator { calculateTotal(items: OrderItem[]): number;}
// ===== High-level service using all principles =====
class OrderService { constructor( private userRepository: UserRepository, // DIP private orderRepository: OrderRepository, // DIP private paymentProcessor: PaymentProcessor, // DIP private notificationService: NotificationService, // DIP private validator: OrderValidator, // ISP private calculator: OrderCalculator, // ISP ) {}
async processOrder( userId: string, items: OrderItem[], paymentMethod: PaymentMethod, // OCP: accepts any PaymentMethod ): Promise<Order> { // SRP: Each step has a single responsibility
// 1. Validate user exists const user = await this.userRepository.findById(userId); if (!user) { throw new Error("User not found"); }
// 2. Calculate total const total = this.calculator.calculateTotal(items);
// 3. Create order const order = new Order(`order_${Date.now()}`, userId, items, total);
// 4. Validate order const validation = await this.validator.validate(order); if (!validation.isValid) { throw new Error( `Order validation failed: ${validation.errors.join(", ")}`, ); }
// 5. Process payment (OCP: works with any PaymentMethod) const paymentResult = await paymentMethod.process(total); if (!paymentResult.success) { throw new Error("Payment failed"); }
// 6. Save order await this.orderRepository.save(order);
// 7. Send notification await this.notificationService.sendEmail( user.getEmail(), "Order Confirmed", `Your order ${order.getId()} has been confirmed.`, );
return order; }}
// Usageconst orderService = new OrderService( userRepository, orderRepository, paymentProcessor, notificationService, validator, calculator,);
// ✅ Can use any PaymentMethod (OCP, LSP)await orderService.processOrder( "user_123", items, new CreditCardPayment("1234"),);await orderService.processOrder( "user_123", items, new PayPalPayment("user@example.com"),);await orderService.processOrder( "user_123", items, new BankTransferPayment("ACC123"),);Common Violations and How to Fix Them
Let’s examine common SOLID violations and their fixes:
Violation 1: God Object/Class ❌
// ❌ God class doing everythingclass UserManager { createUser(data) { /* ... */ } updateUser(id, data) { /* ... */ } deleteUser(id) { /* ... */ } sendEmail(user, subject, body) { /* ... */ } validateEmail(email) { /* ... */ } hashPassword(password) { /* ... */ } generateToken(user) { /* ... */ } logActivity(user, action) { /* ... */ } generateReport(users) { /* ... */ }}
// ✅ Fix: Split into focused classes (SRP)class User { constructor(data) { this.id = data.id; this.name = data.name; this.email = data.email; }}
class UserRepository { async create(user) { /* ... */ } async update(id, data) { /* ... */ } async delete(id) { /* ... */ }}
class EmailService { async send(to, subject, body) { /* ... */ }}
class UserValidator { validateEmail(email) { /* ... */ }}
class PasswordHasher { hash(password) { /* ... */ }}
class TokenGenerator { generate(user) { /* ... */ }}
class ActivityLogger { log(user, action) { /* ... */ }}
class ReportGenerator { generate(users) { /* ... */ }}Violation 2: Tight Coupling ❌
// ❌ Tight coupling to concrete implementationclass OrderService { constructor() { this.database = new MySQLDatabase(); // Hard-coded dependency this.emailService = new SendGridEmailService(); // Hard-coded dependency }}
// ✅ Fix: Dependency injection (DIP)class OrderService { constructor(database, emailService) { this.database = database; // Depends on abstraction this.emailService = emailService; // Depends on abstraction }}
// Usageconst orderService = new OrderService( new MySQLDatabase(), new SendGridEmailService(),);Violation 3: Large Interface ❌
// ❌ Large interface forcing unnecessary implementationsinterface Animal { walk(): void; fly(): void; swim(): void; makeSound(): void;}
class Dog implements Animal { walk() { /* ... */ } fly() { throw new Error("Dogs cannot fly"); } // ❌ Forced to implement swim() { /* ... */ } makeSound() { /* ... */ }}
// ✅ Fix: Segregated interfaces (ISP)interface Walkable { walk(): void;}
interface Flyable { fly(): void;}
interface Swimmable { swim(): void;}
interface Soundable { makeSound(): void;}
class Dog implements Walkable, Swimmable, Soundable { walk() { /* ... */ } swim() { /* ... */ } makeSound() { /* ... */ } // ✅ No need to implement fly()}SOLID Principles in Modern JavaScript
Modern JavaScript features make applying SOLID principles easier:
Using ES6 Classes and Modules ✅
// ✅ ES6 modules for better organization
// user.js - SRP: Single responsibilityexport class User { constructor(name, email) { this.name = name; this.email = email; }}
// user-repository.js - DIP: Depends on abstractionexport class UserRepository { constructor(database) { this.database = database; }
async save(user) { return this.database.save("users", user); }}
// user-service.js - Uses dependency injectionimport { User } from "./user.js";import { UserRepository } from "./user-repository.js";
export class UserService { constructor(repository) { this.repository = repository; }
async createUser(name, email) { const user = new User(name, email); return this.repository.save(user); }}Using TypeScript Interfaces ✅
// ✅ TypeScript interfaces for better abstractions
interface Repository<T> { save(entity: T): Promise<T>; findById(id: string): Promise<T | null>; findAll(): Promise<T[]>;}
class UserRepository implements Repository<User> { async save(user: User): Promise<User> { // Implementation return user; }
async findById(id: string): Promise<User | null> { // Implementation return null; }
async findAll(): Promise<User[]> { // Implementation return []; }}Using Functional Composition ✅
// ✅ Functional approach to SOLID
// Small, focused functions (SRP)const validateEmail = (email) => email.includes("@");const hashPassword = (password) => `hashed_${password}`;const createUser = (name, email) => ({ name, email });
// Higher-order functions for dependency injection (DIP)const createUserService = (repository, validator) => ({ register: async (name, email, password) => { if (!validator(email)) { throw new Error("Invalid email"); } const user = createUser(name, email); return repository.save({ ...user, password: hashPassword(password) }); },});
// Compose servicesconst userService = createUserService(userRepository, validateEmail);Best Practices and Guidelines
When to Apply SOLID Principles
✅ Apply SOLID when:
- Building libraries or frameworks
- Working on long-term projects
- Building systems that need to scale
- Working in teams (improves collaboration)
- Code needs to be testable
⚠️ Consider pragmatism when:
- Building prototypes or MVPs
- Working on small, throwaway scripts
- Over-engineering would slow development
- Team doesn’t understand SOLID yet
SOLID Checklist
Use this checklist when reviewing code:
Common Patterns for SOLID
- Dependency Injection: Pass dependencies as constructor parameters
- Strategy Pattern: Use for OCP (extensible behavior)
- Factory Pattern: Use for DIP (create abstractions)
- Adapter Pattern: Use for ISP (adapt interfaces)
- Composition over Inheritance: Use for LSP (avoid inheritance issues)
Testing SOLID Code
SOLID principles make testing easier:
// ✅ Easy to test with SOLID principles
// Mock dependencies (DIP makes this easy)const mockRepository: UserRepository = { save: jest.fn(), findById: jest.fn(),};
const mockLogger: Logger = { log: jest.fn(), error: jest.fn(),};
// Test in isolationconst userService = new UserService(mockRepository, mockLogger);await userService.createUser("John", "john@example.com");
expect(mockRepository.save).toHaveBeenCalled();Conclusion
SOLID principles provide a foundation for writing maintainable, scalable, and robust JavaScript and TypeScript code. While these principles originated in object-oriented programming, they apply equally well to modern JavaScript development, whether you’re using classes, functions, or a mix of both.
Key Takeaways:
- Single Responsibility Principle: Keep classes and functions focused on one thing
- Open/Closed Principle: Design for extension, not modification
- Liskov Substitution Principle: Ensure subclasses can replace base classes
- Interface Segregation Principle: Create small, focused interfaces
- Dependency Inversion Principle: Depend on abstractions, not concrete implementations
Remember that SOLID principles are guidelines, not strict rules. Apply them pragmatically based on your project’s needs. Over-engineering can be as problematic as under-engineering. Start with understanding the principles, then gradually apply them as your codebase grows.
As you continue to develop JavaScript and TypeScript applications, keep SOLID principles in mind. They’ll help you write code that’s easier to understand, test, and maintain. For more advanced patterns, check out our guide on design patterns in JavaScript and clean architecture principles.
Additional Resources:
Happy coding! 🚀