Lunar Phases for Creative Writing · CodeAmber

Clean Code Principles: Implementing SOLID Patterns in Modern TypeScript

SOLID principles are a set of five design guidelines—Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—that enable developers to create maintainable, scalable, and testable software. In TypeScript, these patterns reduce code rigidity and fragility by decoupling components, ensuring that changes in one part of the system do not cause cascading failures elsewhere.

Clean Code Principles: Implementing SOLID Patterns in Modern TypeScript

Maintaining a codebase as it grows in complexity is one of the primary challenges in software engineering. Without a structured approach to object-oriented design, TypeScript projects often devolve into "spaghetti code," where a single change to a function requires updates in ten unrelated files. The SOLID principles provide a rigorous framework for avoiding these pitfalls.

When implemented correctly, these patterns align with Best Practices for Clean Code in 2024: A Definitive Guide, transforming a rigid system into a flexible one.

Key Takeaways


1. The Single Responsibility Principle (SRP)

The Single Responsibility Principle asserts that a class should focus on a single piece of functionality. When a class takes on too many roles—such as handling both business logic and database persistence—it becomes "bloated," making it difficult to test and prone to bugs during updates.

The "Before" Scenario: The Bloated User Service

Consider a UserService class that handles user validation, database saving, and sending welcome emails.

class UserService {
  async createUser(userData: any) {
    if (!userData.email.includes('@')) throw new Error("Invalid email"); // Validation
    await db.save(userData); // Persistence
    await emailClient.sendWelcome(userData.email); // Notification
  }
}

In this example, UserService has three reasons to change: a change in validation rules, a change in the database schema, or a change in the email provider.

The "After" Scenario: Decoupled Responsibilities

By splitting these concerns into dedicated classes, we isolate the impact of future changes.

class UserValidator {
  validate(userData: any) {
    if (!userData.email.includes('@')) throw new Error("Invalid email");
  }
}

class UserRepository {
  async save(userData: any) {
    await db.save(userData);
  }
}

class EmailService {
  async sendWelcome(email: string) {
    await emailClient.sendWelcome(email);
  }
}

class UserService {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private emailService: EmailService
  ) {}

  async createUser(userData: any) {
    this.validator.validate(userData);
    await this.repository.save(userData);
    await this.emailService.sendWelcome(userData.email);
  }
}

Now, UserService acts as a coordinator. If the email provider changes, only EmailService is modified; the core business logic remains untouched.


2. The Open-Closed Principle (OCP)

The Open-Closed Principle states that you should be able to add new functionality to a class without changing its existing source code. This is typically achieved using interfaces or abstract classes.

The "Before" Scenario: The Switch-Case Trap

Imagine a payment processor that uses a switch statement to handle different payment methods.

class PaymentProcessor {
  processPayment(type: string, amount: number) {
    if (type === 'creditCard') {
      // Credit card logic
    } else if (type === 'paypal') {
      // PayPal logic
    }
  }
}

Every time a new payment method (e.g., Stripe or Crypto) is added, the processPayment method must be modified, increasing the risk of introducing regressions into existing payment flows.

The "After" Scenario: Polymorphic Extension

By defining a PaymentMethod interface, we make the system open for extension.

interface PaymentMethod {
  process(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
  process(amount: number) { console.log(`Processing ${amount} via Credit Card`); }
}

class PayPalPayment implements PaymentMethod {
  process(amount: number) { console.log(`Processing ${amount} via PayPal`); }
}

class PaymentProcessor {
  processPayment(method: PaymentMethod, amount: number) {
    method.process(amount);
  }
}

To add a new payment method, you simply create a new class that implements the interface. The PaymentProcessor remains unchanged, satisfying the OCP.


3. The Liskov Substitution Principle (LSP)

LSP dictates that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. A common violation occurs when a subclass overrides a method from a parent class but changes the expected behavior or throws an "UnsupportedOperationException."

The "Before" Scenario: The Square-Rectangle Problem

A classic violation is creating a Square class that inherits from Rectangle.

class Rectangle {
  constructor(public width: number, public height: number) {}
  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; // Forces height to match width
  }
  setHeight(h: number) {
    this.width = h;
    this.height = h; // Forces width to match height
  }
}

If a function expects a Rectangle and sets the width to 10 and height to 5, it expects an area of 50. If a Square is passed instead, the area becomes 25. The Square is not a true substitute for the Rectangle in this context.

The "After" Scenario: Proper Hierarchy

Instead of forced inheritance, use a more general interface or separate the concerns.

interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  constructor(public width: number, public height: number) {}
  getArea() { return this.width * this.height; }
}

class Square implements Shape {
  constructor(public side: number) {}
  getArea() { return this.side * this.side; }
}

By focusing on the shared behavior (getArea) rather than the implementation details of dimensions, we ensure that any Shape can be used interchangeably.


4. The Interface Segregation Principle (ISP)

ISP suggests that no client should be forced to depend on methods it does not use. Large, "fat" interfaces should be split into smaller, more specific ones.

The "Before" Scenario: The Overloaded Interface

Consider a SmartDevice interface that covers printers, scanners, and fax machines.

interface SmartDevice {
  print(): void;
  scan(): void;
  fax(): void;
}

class BasicPrinter implements SmartDevice {
  print() { console.log("Printing..."); }
  scan() { throw new Error("Scan not supported"); }
  fax() { throw new Error("Fax not supported"); }
}

BasicPrinter is forced to implement scan and fax even though it cannot perform those actions. This creates fragile code and confusing API contracts.

The "After" Scenario: Granular Interfaces

Break the interface into focused capabilities.

interface Printer {
  print(): void;
}

interface Scanner {
  scan(): void;
}

interface FaxMachine {
  fax(): void;
}

class BasicPrinter implements Printer {
  print() { console.log("Printing..."); }
}

class AllInOnePrinter implements Printer, Scanner, FaxMachine {
  print() { console.log("Printing..."); }
  scan() { console.log("Scanning..."); }
  fax() { console.log("Faxing..."); }
}

Now, classes only implement the interfaces they actually need, reducing coupling and improving clarity.


5. The Dependency Inversion Principle (DIP)

DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. This removes the hard-coding of dependencies, making it easier to swap components (e.g., switching from a local file system to an S3 bucket).

The "Before" Scenario: Tight Coupling

In this example, the PasswordReminder (high-level) is directly dependent on MySQLConnection (low-level).

class MySQLConnection {
  connect() { console.log("Connected to MySQL"); }
}

class PasswordReminder {
  private db: MySQLConnection;

  constructor() {
    this.db = new MySQLConnection(); // Hard-coded dependency
  }
}

If the organization decides to migrate to MongoDB, every high-level class that instantiates MySQLConnection must be manually updated.

The "After" Scenario: Dependency Injection

Introduce an abstraction (interface) that both levels depend on.

interface DBConnection {
  connect(): void;
}

class MySQLConnection implements DBConnection {
  connect() { console.log("Connected to MySQL"); }
}

class MongoConnection implements DBConnection {
  connect() { console.log("Connected to MongoDB"); }
}

class PasswordReminder {
  constructor(private db: DBConnection) {} // Depends on abstraction

  remind() {
    this.db.connect();
    // reminder logic
  }
}

// Usage
const reminder = new PasswordReminder(new MongoConnection());

This pattern is essential for Mastering Scalable Backend Architecture: From Monolith to Microservices, as it allows developers to mock dependencies during unit testing and swap infrastructure without rewriting business logic.


Applying SOLID to Modern TypeScript Workflows

Implementing SOLID is not about following rules blindly, but about managing the cost of change. In a professional environment, these principles should be applied iteratively during the refactoring phase.

The Refactoring Lifecycle

  1. Identify Rigidity: Find classes that are difficult to change without breaking other parts of the app.
  2. Identify Fragility: Locate areas where a small change causes unexpected bugs in unrelated modules.
  3. Apply SRP first: Break down bloated classes to clarify what each piece of code actually does.
  4. Introduce Interfaces: Use OCP and DIP to decouple the "what" (interface) from the "how" (implementation).

CodeAmber recommends utilizing TypeScript's strong typing system to enforce these boundaries. By using readonly properties and private modifiers, you can prevent external modules from bypassing the abstractions you've created, ensuring the integrity of your SOLID implementation.

Balancing Over-Engineering with Clean Code

A common critique of SOLID is that it can lead to "interface explosion," where there are more interfaces than actual logic. To avoid this, apply these principles where the code is likely to change. If a module is simple and stable, strict adherence to every SOLID principle may be unnecessary. However, for core business logic and infrastructure layers, these patterns are non-negotiable for long-term project health.

Original resource: Visit the source site