Skip to main content

05 - SOLID Principles: The Foundation of Software Architecture

SOLID Principles: The Foundation of Software Architecture SOLID principles are five fundamental guidelines for writing object-oriented code. These principles lead to more maintainable, flexible, and scalable software. Master them to elevate your software design skills.

What do you think about this article?

At a Glance

SOLID Principles: The Foundation of Software Architecture SOLID principles are five fundamental guidelines for writing object-oriented code. These principles lead to more maintainable, flexible, and scalable software. Master them to elevate your software design skills.

SOLID Principles: The Foundations of Software Architecture

The SOLID principles are five fundamental guidelines for writing quality object-oriented code. Introduced by Robert C. Martin (Uncle Bob), these principles help create maintainable, extensible, and testable software. SOLID is an acronym representing: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.

🎯 The 5 SOLID Principles

  • S - Single Responsibility Principle (SRP)
  • O - Open/Closed Principle (OCP)
  • L - Liskov Substitution Principle (LSP)
  • I - Interface Segregation Principle (ISP)
  • D - Dependency Inversion Principle (DIP)

1. Single Responsibility Principle (SRP)

"A class should have only one reason to change"

Each class must have a single well-defined responsibility. This makes the code easier to understand, test, and modify.

❌ SRP Violation:

class User {{ '{' }}
  constructor(
    public name: string,
    public email: string
  ) {{ '{' }}{{ '}' }}

  // Responsibility 1: Validation
  validateEmail(): boolean {{ '{' }}
    return /\S+@\S+\.\S+/.test(this.email);
  {{ '}' }}

  // Responsibility 2: Persistence
  save(): void {{ '{' }}
    // Save to database
    console.log("Saved to DB");
  {{ '}' }}

  // Responsibility 3: Notifications
  sendWelcomeEmail(): void {{ '{' }}
    console.log(`Email to ${{ '{' }}this.email{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

✅ Respects SRP:

// One responsibility per class
class User {{ '{' }}
  constructor(
    public name: string,
    public email: string
  ) {{ '{' }}{{ '}' }}
{{ '}' }}

class UserValidator {{ '{' }}
  validateEmail(email: string): boolean {{ '{' }}
    return /\S+@\S+\.\S+/.test(email);
  {{ '}' }}
{{ '}' }}

class UserRepository {{ '{' }}
  save(user: User): void {{ '{' }}
    console.log("Saved to DB");
  {{ '}' }}
{{ '}' }}

class EmailService {{ '{' }}
  sendWelcome(user: User): void {{ '{' }}
    console.log(`Email to ${{ '{' }}user.email{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

2. Open/Closed Principle (OCP)

"Classes should be open for extension but closed for modification"

You should be able to add new functionality by extending existing code, not modifying it. Use abstractions (interfaces, abstract classes) to allow extensions.

❌ OCP Violation
class DiscountCalculator {{ '{' }}
  calculate(type: string, amount: number): number {{ '{' }}
    // Every new type requires modifying this class!
    if (type === 'student') {{ '{' }}
      return amount * 0.9; // 10% discount
    {{ '}' }} else if (type === 'senior') {{ '{' }}
      return amount * 0.85; // 15% discount
    {{ '}' }} else if (type === 'vip') {{ '{' }}
      return amount * 0.8; // 20% discount
    {{ '}' }}
    return amount;
  {{ '}' }}
{{ '}' }}
✅ Respects OCP
// Interface for extension
interface DiscountStrategy {{ '{' }}
  apply(amount: number): number;
{{ '}' }}

class StudentDiscount implements DiscountStrategy {{ '{' }}
  apply(amount: number): number {{ '{' }}
    return amount * 0.9;
  {{ '}' }}
{{ '}' }}

class SeniorDiscount implements DiscountStrategy {{ '{' }}
  apply(amount: number): number {{ '{' }}
    return amount * 0.85;
  {{ '}' }}
{{ '}' }}

class VIPDiscount implements DiscountStrategy {{ '{' }}
  apply(amount: number): number {{ '{' }}
    return amount * 0.8;
  {{ '}' }}
{{ '}' }}

// Closed for modification, open for extension
class DiscountCalculatorOCP {{ '{' }}
  calculate(strategy: DiscountStrategy, amount: number): number {{ '{' }}
    return strategy.apply(amount);
  {{ '}' }}
{{ '}' }}

// Adding new discount = create new class, not modify existing
const calc = new DiscountCalculatorOCP();
console.log(calc.calculate(new StudentDiscount(), 100)); // 90
console.log(calc.calculate(new VIPDiscount(), 100)); // 80

3. Liskov Substitution Principle (LSP)

"Objects of a subclass must be able to replace objects of the base class without altering the correct behavior of the program"

Subclasses must extend the behavior of the base class, not change it in ways that break expectations.

❌ LSP Violation
class Rectangle {{ '{' }}
  constructor(protected width: number, protected height: number) {{ '{' }}{{ '}' }}

  setWidth(width: number): void {{ '{' }}
    this.width = width;
  {{ '}' }}

  setHeight(height: number): void {{ '{' }}
    this.height = height;
  {{ '}' }}

  getArea(): number {{ '{' }}
    return this.width * this.height;
  {{ '}' }}
{{ '}' }}

// Violates LSP: Square cannot substitute Rectangle
class Square extends Rectangle {{ '{' }}
  setWidth(width: number): void {{ '{' }}
    this.width = width;
    this.height = width; // Unexpected side effect!
  {{ '}' }}

  setHeight(height: number): void {{ '{' }}
    this.width = height;
    this.height = height; // Unexpected side effect!
  {{ '}' }}
{{ '}' }}

function increaseRectangleWidth(rect: Rectangle): void {{ '{' }}
  rect.setWidth(rect.getArea() + 10);
  // Expectation: only width changes
  // Reality with Square: height also changes!
{{ '}' }}
✅ Respects LSP
// Use composition instead of problematic inheritance
interface Shape {{ '{' }}
  getArea(): number;
{{ '}' }}

class RectangleLSP implements Shape {{ '{' }}
  constructor(private width: number, private height: number) {{ '{' }}{{ '}' }}

  setWidth(width: number): void {{ '{' }}
    this.width = width;
  {{ '}' }}

  setHeight(height: number): void {{ '{' }}
    this.height = height;
  {{ '}' }}

  getArea(): number {{ '{' }}
    return this.width * this.height;
  {{ '}' }}
{{ '}' }}

class SquareLSP implements Shape {{ '{' }}
  constructor(private side: number) {{ '{' }}{{ '}' }}

  setSide(side: number): void {{ '{' }}
    this.side = side;
  {{ '}' }}

  getArea(): number {{ '{' }}
    return this.side * this.side;
  {{ '}' }}
{{ '}' }}

// Works with any Shape without surprises
function printArea(shape: Shape): void {{ '{' }}
  console.log(`Area: ${{ '{' }}shape.getArea(){{ '}' }}`);
{{ '}' }}

4. Interface Segregation Principle (ISP)

"Clients should not be forced to depend on interfaces they do not use"

It's better to have small and specific interfaces than large and generic ones. Avoid "fat interfaces" that force classes to implement useless methods.

❌ ISP Violation
// Interface too large
interface Worker {{ '{' }}
  work(): void;
  eat(): void;
  sleep(): void;
{{ '}' }}

class HumanWorker implements Worker {{ '{' }}
  work(): void {{ '{' }} console.log("Working"); {{ '}' }}
  eat(): void {{ '{' }} console.log("Eating"); {{ '}' }}
  sleep(): void {{ '{' }} console.log("Sleeping"); {{ '}' }}
{{ '}' }}

// Robot doesn't eat or sleep, but is forced to implement them!
class RobotWorker implements Worker {{ '{' }}
  work(): void {{ '{' }} console.log("Working"); {{ '}' }}
  eat(): void {{ '{' }} throw new Error("Robot doesn't eat"); {{ '}' }}
  sleep(): void {{ '{' }} throw new Error("Robot doesn't sleep"); {{ '}' }}
{{ '}' }}
✅ Respects ISP
// Segregated interfaces by responsibility
interface Workable {{ '{' }}
  work(): void;
{{ '}' }}

interface Eatable {{ '{' }}
  eat(): void;
{{ '}' }}

interface Sleepable {{ '{' }}
  sleep(): void;
{{ '}' }}

// Human implements all interfaces
class HumanWorkerISP implements Workable, Eatable, Sleepable {{ '{' }}
  work(): void {{ '{' }} console.log("Working"); {{ '}' }}
  eat(): void {{ '{' }} console.log("Eating"); {{ '}' }}
  sleep(): void {{ '{' }} console.log("Sleeping"); {{ '}' }}
{{ '}' }}

// Robot implements only what's needed
class RobotWorkerISP implements Workable {{ '{' }}
  work(): void {{ '{' }} console.log("Working 24/7"); {{ '}' }}
{{ '}' }}

// Specific functions for specific interfaces
function makeWork(worker: Workable): void {{ '{' }}
  worker.work();
{{ '}' }}

function feedWorker(worker: Eatable): void {{ '{' }}
  worker.eat();
{{ '}' }}

const human = new HumanWorkerISP();
const robot = new RobotWorkerISP();

makeWork(human); // OK
makeWork(robot); // OK
feedWorker(human); // OK
// feedWorker(robot); // ❌ Compile-time error: robot is not Eatable

5. Dependency Inversion Principle (DIP)

"Depend on abstractions, not on concrete implementations"

High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces). Abstractions should not depend on details, but details should depend on abstractions.

❌ DIP Violation
// Direct dependency on concrete implementation
class MySQLDatabase {{ '{' }}
  save(data: string): void {{ '{' }}
    console.log(`Saved to MySQL: ${{ '{' }}data{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

// UserService depends on the concrete MySQLDatabase class
class UserService {{ '{' }}
  private db = new MySQLDatabase(); // Strong coupling!

  createUser(name: string): void {{ '{' }}
    this.db.save(name);
  {{ '}' }}
{{ '}' }}

// Changing database requires modifying UserService
✅ Respects DIP
// Abstraction (interface)
interface Database {{ '{' }}
  save(data: string): void;
{{ '}' }}

// Concrete implementations depend on the abstraction
class MySQLDatabaseDIP implements Database {{ '{' }}
  save(data: string): void {{ '{' }}
    console.log(`Saved to MySQL: ${{ '{' }}data{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class MongoDBDatabase implements Database {{ '{' }}
  save(data: string): void {{ '{' }}
    console.log(`Saved to MongoDB: ${{ '{' }}data{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class PostgreSQLDatabase implements Database {{ '{' }}
  save(data: string): void {{ '{' }}
    console.log(`Saved to PostgreSQL: ${{ '{' }}data{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

// UserService depends on the abstraction, not the implementation
class UserServiceDIP {{ '{' }}
  // Dependency Injection: database is injected
  constructor(private db: Database) {{ '{' }}{{ '}' }}

  createUser(name: string): void {{ '{' }}
    this.db.save(name);
  {{ '}' }}
{{ '}' }}

// Easy to change implementation without modifying UserService
const mysqlService = new UserServiceDIP(new MySQLDatabaseDIP());
const mongoService = new UserServiceDIP(new MongoDBDatabase());
const pgService = new UserServiceDIP(new PostgreSQLDatabase());

mysqlService.createUser("Alice"); // MySQL
mongoService.createUser("Bob");   // MongoDB
pgService.createUser("Charlie");  // PostgreSQL

SOLID in Practice: Complete Example

Let's see how to apply all SOLID principles together in a notification system:

SOLID Notification System
// ISP: Segregated interfaces
interface MessageSender {{ '{' }}
  send(message: string, recipient: string): void;
{{ '}' }}

// DIP: Concrete implementations depend on abstraction
class EmailSender implements MessageSender {{ '{' }}
  send(message: string, recipient: string): void {{ '{' }}
    console.log(`📧 Email to ${{ '{' }}recipient{{ '}' }}: ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class SMSSender implements MessageSender {{ '{' }}
  send(message: string, recipient: string): void {{ '{' }}
    console.log(`📱 SMS to ${{ '{' }}recipient{{ '}' }}: ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

class PushNotificationSender implements MessageSender {{ '{' }}
  send(message: string, recipient: string): void {{ '{' }}
    console.log(`🔔 Push to ${{ '{' }}recipient{{ '}' }}: ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

// SRP: Single responsibility - notification management
class NotificationService {{ '{' }}
  constructor(private sender: MessageSender) {{ '{' }}{{ '}' }} // DIP

  notify(message: string, recipient: string): void {{ '{' }}
    this.sender.send(message, recipient);
  {{ '}' }}
{{ '}' }}

// OCP: Open for extension (new loggers) closed for modification
interface Logger {{ '{' }}
  log(message: string): void;
{{ '}' }}

class ConsoleLogger implements Logger {{ '{' }}
  log(message: string): void {{ '{' }}
    console.log(`[LOG] ${{ '{' }}message{{ '}' }}`);
  {{ '}' }}
{{ '}' }}

// SRP: Separate responsibility for logging
class NotificationServiceWithLogging {{ '{' }}
  constructor(
    private sender: MessageSender,
    private logger: Logger
  ) {{ '{' }}{{ '}' }}

  notify(message: string, recipient: string): void {{ '{' }}
    this.logger.log(`Sending notification to ${{ '{' }}recipient{{ '}' }}`);
    this.sender.send(message, recipient);
  {{ '}' }}
{{ '}' }}

// Usage
const emailService = new NotificationServiceWithLogging(
  new EmailSender(),
  new ConsoleLogger()
);

const smsService = new NotificationServiceWithLogging(
  new SMSSender(),
  new ConsoleLogger()
);

emailService.notify("Welcome!", "user@example.com");
smsService.notify("Code: 1234", "+39123456789");

Benefits of SOLID Principles

✅ Advantages

  • Maintainability: Code easier to understand and modify
  • Extensibility: Add features without breaking existing code
  • Testability: Small and focused classes are easier to test
  • Reusability: Modular reusable components
  • Flexibility: Change implementations without rewriting everything

Conclusion

SOLID principles are not rigid rules but guidelines for writing quality code. Applying them requires practice and judgment: sometimes slightly violating them is acceptable for simplicity. The goal is maintainable, testable, and extensible code, not dogmatically following every principle.

🎯 Key Points

  • SRP: One class = one responsibility
  • OCP: Open for extension, closed for modification
  • LSP: Substitutable subclasses without surprises
  • ISP: Small and specific interfaces
  • DIP: Depend on abstractions, not on concretes

Frequently asked questions

What is the main purpose of the SOLID principles in software architecture?

The SOLID principles help create maintainable, extensible, and testable software by providing guidelines for writing quality object-oriented code.

What does the Single Responsibility Principle (SRP) state about a class's responsibilities?

According to SRP, each class should have only one reason to change, meaning it must have a single well-defined responsibility.

Why is it better to use abstractions (interfaces, abstract classes) when applying the Open/Closed Principle (OCP)?

Using abstractions allows you to add new functionality by extending existing code without modifying it.

What is the main goal of the Interface Segregation Principle (ISP), as stated in the article?

The ISP aims to avoid "fat interfaces" that force classes to implement useless methods, instead promoting small and specific interfaces.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects