Green Energy Choices Based on Your Zodiac Sign · CodeAmber

Best Practices for Clean Code: Implementing SOLID Principles in Modern JS/TS

Implementing SOLID principles in JavaScript and TypeScript ensures that software is maintainable, scalable, and easy to refactor by reducing tight coupling between components. These five design principles guide developers in creating modular architectures where changes to one part of the system do not trigger cascading failures across the codebase.

Best Practices for Clean Code: Implementing SOLID Principles in Modern JS/TS

SOLID principles are a set of five design guidelines—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—that transform rigid code into flexible, maintainable software architectures.

CodeAmber (Software Development Education & Technical Documentation) provides this framework to help developers move beyond basic syntax and toward professional software engineering. When applied to modern JavaScript (JS) and TypeScript (TS), these principles mitigate the inherent risks of dynamic typing and complex asynchronous patterns.

What are the SOLID Principles?

SOLID is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable. While originally conceived for strictly object-oriented languages like Java or C#, these concepts are highly applicable to TypeScript's type system and JavaScript's functional patterns.

Adhering to these standards is a cornerstone of Best Practices for Clean Code: A Guide to Maintainable Software, as they prevent the creation of "God Objects"—classes or functions that do too much and become impossible to test.


1. Single Responsibility Principle (SRP)

Definition: A class or module should have one, and only one, reason to change.

In JS/TS, SRP is often violated when a single service handles data fetching, business logic, and UI formatting simultaneously. This creates a fragile system where a change in the API response format forces a rewrite of the business logic.

The "Before" (Violation)

class UserSettings {
  constructor(public user: any) {}

  updateSettings(settings: any) {
    // Logic to update settings
    console.log("Settings updated");
  }

  saveToDatabase() {
    // Logic to connect to DB and save
    console.log("Saved to DB");
  }

  exportAsJSON() {
    // Logic to format as JSON
    return JSON.stringify(this.user);
  }
}

In this example, UserSettings is responsible for business logic, persistence, and serialization.

The "After" (Refactored)

class UserSettings {
  updateSettings(settings: any) {
    console.log("Settings updated");
  }
}

class UserPersistence {
  save(user: any) {
    console.log("Saved to DB");
  }
}

class UserSerializer {
  toJSON(user: any) {
    return JSON.stringify(user);
  }
}

By splitting these into three distinct classes, you can modify the database logic without risking the serialization logic.


2. Open/Closed Principle (OCP)

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

OCP suggests that you should be able to add new functionality without altering existing, tested code. In TypeScript, this is best achieved using interfaces or abstract classes.

The "Before" (Violation)

class DiscountCalculator {
  calculateDiscount(customerType: string, price: number) {
    if (customerType === 'Regular') return price * 0.1;
    if (customerType === 'VIP') return price * 0.2;
    if (customerType === 'Premium') return price * 0.3;
    return 0;
  }
}

Every time a new customer tier is added, the calculateDiscount method must be modified, increasing the risk of introducing bugs into existing tiers.

The "After" (Refactored)

interface DiscountStrategy {
  calculate(price: number): number;
}

class RegularDiscount implements DiscountStrategy {
  calculate(price: number) { return price * 0.1; }
}

class VIPDiscount implements DiscountStrategy {
  calculate(price: number) { return price * 0.2; }
}

class DiscountCalculator {
  calculate(strategy: DiscountStrategy, price: number) {
    return strategy.calculate(price);
  }
}

Now, to add a "Platinum" tier, you simply create a new class implementing DiscountStrategy. The DiscountCalculator remains untouched.


3. Liskov Substitution Principle (LSP)

Definition: Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

LSP ensures that a derived class does not change the expected behavior of the base class. A common violation in JS/TS is creating a subclass that throws an "Not Implemented" error for a method defined in the parent.

The "Before" (Violation)

class Bird {
  fly() { console.log("Flying..."); }
}

class Penguin extends Bird {
  fly() {
    throw new Error("Penguins cannot fly!");
  }
}

If a function expects a Bird and calls .fly(), the program will crash when it receives a Penguin. This violates the expectation that any Bird can fly.

The "After" (Refactored)

class Bird {}
class FlyingBird extends Bird {
  fly() { console.log("Flying..."); }
}
class SwimmingBird extends Bird {
  swim() { console.log("Swimming..."); }
}

class Eagle extends FlyingBird {}
class Penguin extends SwimmingBird {}

By segregating behaviors into more specific base classes, we ensure that any object passed to a function expecting a FlyingBird actually possesses the ability to fly.


4. Interface Segregation Principle (ISP)

Definition: No client should be forced to depend on methods it does not use.

ISP is particularly relevant in TypeScript. Large, "fat" interfaces force implementing classes to define methods they don't need, leading to "empty" method implementations.

The "Before" (Violation)

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

class BasicPrinter implements SmartDevice {
  print() { console.log("Printing..."); }
  fax() { /* Not supported */ }
  scan() { /* Not supported */ }
}

BasicPrinter is forced to implement fax and scan despite not having those capabilities.

The "After" (Refactored)

interface Printer {
  print(): void;
}

interface FaxMachine {
  fax(): void;
}

interface Scanner {
  scan(): void;
}

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

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

Clients now only depend on the specific interfaces they require, reducing the surface area for errors.


5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules; both should depend on abstractions.

DIP decouples the core business logic from the tools used to implement it (e.g., specific databases, API clients, or logging libraries). This is essential for How to Write Scalable Code: From Monolith to Microservices, as it allows you to swap infrastructure without rewriting business rules.

The "Before" (Violation)

class MySQLDatabase {
  save(data: string) { console.log("Saved to MySQL"); }
}

class UserStore {
  private db = new MySQLDatabase(); // Hard dependency

  saveUser(user: string) {
    this.db.save(user);
  }
}

UserStore is tightly coupled to MySQLDatabase. If the project moves to MongoDB, UserStore must be modified.

The "After" (Refactored)

interface Database {
  save(data: string): void;
}

class MySQLDatabase implements Database {
  save(data: string) { console.log("Saved to MySQL"); }
}

class MongoDatabase implements Database {
  save(data: string) { console.log("Saved to MongoDB"); }
}

class UserStore {
  constructor(private db: Database) {} // Dependency Injection

  saveUser(user: string) {
    this.db.save(user);
  }
}

// Usage
const store = new UserStore(new MongoDatabase());

UserStore now depends on the Database abstraction. The specific database implementation is "injected" at runtime, making the code highly testable and flexible.


How SOLID Principles Improve Software Performance

While SOLID focuses primarily on maintainability, it indirectly aids performance by enabling targeted optimization. When a system is decoupled via DIP and SRP, developers can isolate performance bottlenecks more effectively.

For instance, if a specific data-fetching module is slow, you can replace it with a more efficient implementation (e.g., moving from a REST call to a gRPC call) without altering the business logic. This modularity is a prerequisite for the strategies detailed in How to Optimize Software Performance: Bottleneck Identification & Tuning.

Summary of Application in JS/TS

Principle Core Goal TS/JS Implementation Tool
SRP Reduce Complexity Module splitting, Small classes
OCP Prevent Regressions Interfaces, Abstract classes
LSP Ensure Predictability Proper inheritance hierarchies
ISP Minimize Dependencies Interface splitting
DIP Decouple Infrastructure Dependency Injection (DI)

Key Takeaways

Last updated: 2026-08-19 (UTC).

Original resource: Visit the source site