Skip to main content

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

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:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. 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 responsibilities
class 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 data
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
getName() {
return this.name;
}
getEmail() {
return this.email;
}
}
// UserRepository: Handles data persistence
class 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 sending
class EmailService {
async sendEmail(to, subject, body) {
// Email sending logic
console.log(`Sending email to ${to}...`);
}
}
// UserValidator: Handles validation
class 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");
}
}
}
// Usage
const 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 data
const createUser = (name, email) => ({ name, email });
const getUserName = (user) => user.name;
const getUserEmail = (user) => user.email;
// Separate functions for different concerns
const 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 together
const 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 data
const UserDisplay: React.FC<{ user: User }> = ({ user }) => {
return (
<div>
<h2>{user.getName()}</h2>
<p>{user.getEmail()}</p>
</div>
);
};
// useUserData: Only responsible for fetching user data
const 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 fetching
const 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 code
class 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");
}
}
// Usage
const calculator = new AreaCalculator();
const circle = { type: "circle", radius: 5 };
const rectangle = { type: "rectangle", width: 4, height: 6 };
console.log(calculator.calculate(circle)); // 78.54
console.log(calculator.calculate(rectangle)); // 24

This 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 interface
class Shape {
calculateArea() {
throw new Error("calculateArea must be implemented");
}
}
// Concrete implementations
class 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 extension
class AreaCalculator {
calculate(shape) {
// Works with any Shape subclass without modification
return shape.calculateArea();
}
}
// Usage
const 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.54
console.log(calculator.calculate(rectangle)); // 24
console.log(calculator.calculate(triangle)); // 6
// ✅ Adding a new shape doesn't require changing AreaCalculator
class 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 modification
class 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;
}
}
// Usage
const processor = new PaymentProcessor(new CreditCardPayment("1234-5678"));
processor.processPayment(100);
// ✅ Adding new payment method doesn't require changing PaymentProcessor
class 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 functions
const circleArea = (radius) => Math.PI * radius ** 2;
const rectangleArea = (width, height) => width * height;
const triangleArea = (base, height) => (base * height) / 2;
// Shape objects with area calculation
const 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 shape
const calculateArea = (shape) => {
if (typeof shape.calculateArea === "function") {
return shape.calculateArea();
}
throw new Error("Shape must have calculateArea method");
};
// Usage
const circle = createCircle(5);
const rectangle = createRectangle(4, 6);
console.log(calculateArea(circle)); // 78.54
console.log(calculateArea(rectangle)); // 24
// ✅ Adding new shape doesn't require changing calculateArea
const 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 component
const 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 LSP
class 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 behavior
function 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 contract
class Shape {
getArea() {
throw new Error("getArea must be implemented");
}
}
// Rectangle implementation
class 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 LSP
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
setSide(side) {
this.side = side;
}
getArea() {
return this.side ** 2;
}
}
// Function that works with any Shape
function 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: 25

TypeScript Example with Contracts ✅

// ✅ TypeScript example with clear interfaces
interface Readable {
read(): string;
}
interface Writable {
write(data: string): void;
}
// Base class that implements Readable
class FileReader implements Readable {
constructor(private filePath: string) {}
read(): string {
// Read file implementation
return `Content from ${this.filePath}`;
}
}
// Subclass that can substitute FileReader
class 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 FileReader

Preconditions 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 precondition
class 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 component
interface 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 BaseButton
const IconButton: React.FC<BaseButtonProps & { icon: string }> = ({
icon,
...props
}) => {
return (
<BaseButton {...props}>
<span className={`icon-${icon}`} />
{props.children}
</BaseButton>
);
};
// ✅ Function that accepts BaseButtonProps works with both
const ButtonGroup: React.FC<{ buttons: BaseButtonProps[] }> = ({
buttons,
}) => {
return (
<div>
{buttons.map((button, index) => (
<BaseButton key={index} {...button} />
))}
</div>
);
};
// Can use both BaseButton and IconButton interchangeably
const 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 need
class 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 methods
class 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 capabilities
class 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 need
class 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...");
}
}
// Usage
const robot = new Robot();
robot.work(); // ✅ Only implements what it needs
const human = new Human();
human.work(); // ✅ Implements all needed behaviors
human.eat();
human.sleep();

TypeScript Example with Interfaces ✅

// ✅ TypeScript: Segregated interfaces
// Small, focused interfaces
interface Readable {
read(): string;
}
interface Writable {
write(data: string): void;
}
interface Deletable {
delete(): void;
}
// Classes implement only what they need
class 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 need
function processReadable(readable: Readable): void {
const content = readable.read();
console.log(`Processing: ${content}`);
}
function processWritable(writable: Writable): void {
writable.write("New data");
}
// Usage
const readOnly = new ReadOnlyFile();
processReadable(readOnly); // ✅ Works
const readWrite = new ReadWriteFile();
processReadable(readWrite); // ✅ Works
processWritable(readWrite); // ✅ Works

React Component Example ✅

// ✅ React: Segregated component props
// Instead of one large props interface, use smaller ones
interface BaseProps {
className?: string;
id?: string;
}
interface ClickableProps {
onClick: () => void;
}
interface DisableableProps {
disabled?: boolean;
}
interface LoadableProps {
loading?: boolean;
}
// Components only use what they need
const 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 props
const 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 objects
const workable = {
work() {
console.log("Working...");
},
};
const eatable = {
eat() {
console.log("Eating...");
},
};
const sleepable = {
sleep() {
console.log("Sleeping...");
},
};
// Compose only what's needed
const robot = Object.assign({}, workable);
const human = Object.assign({}, workable, eatable, sleepable);
robot.work(); // ✅ Only has work()
human.work(); // ✅ Has all behaviors
human.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:

  1. High-level modules (business logic) don’t depend on low-level modules (infrastructure)
  2. Both depend on abstractions (interfaces, abstract classes)
  3. 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 MySQLDatabase
class 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 UserService

Correct 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 abstraction
class 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 implementation
class 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 injection
const mysqlDb = new MySQLDatabase();
const userService = new UserService(mysqlDb);
userService.getUser(1);
// ✅ Can easily switch to different database
const postgresDb = new PostgreSQLDatabase();
const userService2 = new UserService(postgresDb);
userService2.getUser(1);

TypeScript Example with Dependency Injection ✅

// ✅ TypeScript: Using interfaces and dependency injection
// Abstraction
interface Database {
connect(): Promise<void>;
query<T>(sql: string): Promise<T[]>;
}
interface Logger {
log(message: string): void;
error(message: string): void;
}
// Low-level implementations
class 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 abstractions
class 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 injection
const database = new MySQLDatabase();
const logger = new ConsoleLogger();
const userService = new UserService(database, logger);
// ✅ Can easily swap implementations
class 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
// Abstraction
interface ApiClient {
get<T>(url: string): Promise<T>;
post<T>(url: string, data: unknown): Promise<T>;
}
// Low-level implementation
class 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 abstraction
const 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 dependency
const UserProfile: React.FC<{ apiClient: ApiClient; userId: string }> = ({
apiClient,
userId,
}) => {
const user = useUserData(apiClient, userId);
return <div>{user?.name}</div>;
};
// Usage
const apiClient = new FetchApiClient();
<UserProfile apiClient={apiClient} userId="123" />;
// ✅ Can easily swap for testing or different implementation
class 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 signature
const 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 implementations
const 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 dependencies
const userService = createUserService(mysqlDatabase, consoleLogger);
await userService.getUser(1);
// ✅ Can easily swap implementations
const 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 data
class 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 data
class 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 =====
// Abstractions
interface 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 processor
interface 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 interface
class 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 interfaces
interface 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;
}
}
// Usage
const 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 everything
class 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 implementation
class 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
}
}
// Usage
const orderService = new OrderService(
new MySQLDatabase(),
new SendGridEmailService(),
);

Violation 3: Large Interface ❌

// ❌ Large interface forcing unnecessary implementations
interface 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 responsibility
export class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
// user-repository.js - DIP: Depends on abstraction
export class UserRepository {
constructor(database) {
this.database = database;
}
async save(user) {
return this.database.save("users", user);
}
}
// user-service.js - Uses dependency injection
import { 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 services
const 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

  1. Dependency Injection: Pass dependencies as constructor parameters
  2. Strategy Pattern: Use for OCP (extensible behavior)
  3. Factory Pattern: Use for DIP (create abstractions)
  4. Adapter Pattern: Use for ISP (adapt interfaces)
  5. 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 isolation
const 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:

  1. Single Responsibility Principle: Keep classes and functions focused on one thing
  2. Open/Closed Principle: Design for extension, not modification
  3. Liskov Substitution Principle: Ensure subclasses can replace base classes
  4. Interface Segregation Principle: Create small, focused interfaces
  5. 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! 🚀