SOLID Principles

Five principles of object-oriented design that promote maintainable, flexible, and understandable code.

Overview

SOLID is an acronym for five design principles that help developers create software that’s easier to maintain, extend, and understand. These principles emerged from decades of software engineering experience and form the foundation of good object-oriented design.

The principles work together: following one often makes it easier to follow the others. They’re guidelines rather than rules—apply them where they add value, not as rigid constraints.

The Five Principles

S - Single Responsibility Principle

A class should have one, and only one, reason to change.

When a class has multiple responsibilities, changes to one responsibility risk breaking others. Separating responsibilities into different classes means each can evolve independently.

See: Single Responsibility Principle

O - Open/Closed Principle

Software entities should be open for extension but closed for modification.

You should be able to add new behavior without changing existing code. This is typically achieved through abstraction—defining interfaces that can have multiple implementations.

// Closed for modification, open for extension
interface PaymentProcessor {
  process(amount: number): Promise<PaymentResult>;
}
 
class CreditCardProcessor implements PaymentProcessor {
  async process(amount: number): Promise<PaymentResult> { ... }
}
 
class PayPalProcessor implements PaymentProcessor {
  async process(amount: number): Promise<PaymentResult> { ... }
}
 
// Adding new payment methods doesn't require changing existing code
class CryptoProcessor implements PaymentProcessor {
  async process(amount: number): Promise<PaymentResult> { ... }
}

L - Liskov Substitution Principle

Subtypes must be substitutable for their base types.

If a function works with a base class, it should work correctly with any subclass without knowing the difference. Subclasses shouldn’t strengthen preconditions, weaken postconditions, or violate invariants of the base class.

// Violation: Square breaks Rectangle's behavior
class Rectangle {
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
}
 
class Square extends Rectangle {
  setWidth(w: number) {
    this.width = w;
    this.height = w; // Unexpected side effect!
  }
}
 
// Better: Don't use inheritance for this relationship
interface Shape {
  getArea(): number;
}
 
class Rectangle implements Shape { ... }
class Square implements Shape { ... }

I - Interface Segregation Principle

No client should be forced to depend on methods it doesn’t use.

Large interfaces should be split into smaller, more specific ones. Clients then only need to know about the methods relevant to them.

// Fat interface forces unnecessary dependencies
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}
 
// Segregated interfaces allow focused dependencies
interface Workable {
  work(): void;
}
 
interface Eatable {
  eat(): void;
}
 
class Human implements Workable, Eatable {
  work(): void { ... }
  eat(): void { ... }
}
 
class Robot implements Workable {
  work(): void { ... }
  // Doesn't need eat() or sleep()
}

D - Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Instead of high-level business logic depending directly on low-level details (databases, external services), both should depend on interfaces. This allows low-level components to be swapped without affecting business logic.

See: Dependency Injection

Example

Consider an order processing system applying all SOLID principles:

// Single Responsibility: Each class has one job
interface OrderValidator {
  validate(order: Order): ValidationResult;
}
 
interface PaymentProcessor {
  process(order: Order): Promise<PaymentResult>;
}
 
interface OrderRepository {
  save(order: Order): Promise<void>;
}
 
// Open/Closed: New validators don't require changes to OrderService
class InventoryValidator implements OrderValidator { ... }
class CreditLimitValidator implements OrderValidator { ... }
 
// Dependency Inversion: OrderService depends on abstractions
class OrderService {
  constructor(
    private validators: OrderValidator[],
    private paymentProcessor: PaymentProcessor,
    private repository: OrderRepository
  ) {}
 
  async processOrder(order: Order): Promise<void> {
    // Interface Segregation: Only uses what it needs from each dependency
    for (const validator of this.validators) {
      const result = validator.validate(order);
      if (!result.isValid) throw new ValidationError(result.errors);
    }
 
    await this.paymentProcessor.process(order);
    await this.repository.save(order);
  }
}

Common Mistakes

Applying SOLID dogmatically - These are principles, not laws. Over-engineering simple code to satisfy SOLID adds complexity without benefit.

Misunderstanding SRP as “do one thing” - SRP is about reasons to change (actors), not about having one method or one operation.

Breaking LSP with exceptions - A subtype that throws “not implemented” exceptions violates LSP by changing the contract.

Creating too many tiny interfaces - Interface segregation is about not forcing unnecessary dependencies, not about having one method per interface.

References

  • Martin, R. C. (2017). Clean Architecture: A Craftsman’s Guide to Software Structure and Design.
  • Martin, R. C. (2002). Agile Software Development, Principles, Patterns, and Practices.