Skip to content

SOLID Principles in TypeScript — Clean Architecture Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about SOLID Principles in TypeScript. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript's interface and type system makes SOLID principles more enforceable than in plain JavaScript — interfaces act as contracts, generics enforce abstractions, and the compiler verifies that your architecture follows clean design rules.

What You'll Learn

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle
  • Applying SOLID in TypeScript projects

Why It Matters

SOLID principles prevent the most common causes of software rot: classes that do too much, changes that cascade through the codebase, and tight coupling that makes testing impossible. TypeScript enforces these principles through its type system — violations become compile-time errors.

Real-World Use

The Doda browser extension architecture follows SOLID principles strictly. Each extension feature (bookmarks, history, downloads) has a single responsibility, interfaces define contracts between modules, and dependency injection decouples business logic from storage implementation.

Learning Path

flowchart LR
  A[Advanced Patterns] --> B[SOLID Principles]
  B --> C[Error Handling]
  B --> D[You Are Here]
  C --> E[Async Patterns]
  D --> F[Performance]

S — Single Responsibility Principle

A class should have one reason to change. Each class should do one thing and do it well.

// ❌ Bad — violates SRP: handles users AND sends emails
class UserManager {
  createUser(name: string, email: string) {
    const user = { id: Date.now(), name, email };
    console.log('User created:', user);
    console.log(`Sending welcome email to ${email}...`);
    return user;
  }
}

// ✅ Good — separate responsibilities
interface User {
  id: number;
  name: string;
  email: string;
}

class UserRepository {
  private users: User[] = [];

  save(user: Omit<User, 'id'>): User {
    const newUser = { id: Date.now(), ...user };
    this.users.push(newUser);
    return newUser;
  }

  findById(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}

class EmailService {
  sendWelcomeEmail(email: string): void {
    console.log(`Sending welcome email to ${email}`);
  }
}

class CreateUserUseCase {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService
  ) {}

  execute(name: string, email: string): User {
    const user = this.userRepo.save({ name, email });
    this.emailService.sendWelcomeEmail(email);
    return user;
  }
}

Why this matters: If the UserManager class needs to change because email format changes, you risk breaking user creation logic. Separate classes mean independent evolution.

O — Open-Closed Principle

Classes should be open for extension but closed for modification. Add new behavior without changing existing code.

// ❌ Bad — adding a new payment method requires modifying this class
class PaymentProcessor {
  processPayment(type: string, amount: number): void {
    if (type === 'credit_card') {
      console.log(`Processing credit card payment: $${amount}`);
    } else if (type === 'paypal') {
      console.log(`Processing PayPal payment: $${amount}`);
    }
    // Adding "crypto" requires adding another else-if
  }
}

// ✅ Good — open for extension, closed for modification
interface PaymentMethod {
  process(amount: number): void;
  readonly name: string;
}

class CreditCardPayment implements PaymentMethod {
  readonly name = 'Credit Card';

  process(amount: number): void {
    console.log(`Processing credit card payment: $${amount}`);
    // Validate card, charge, etc.
  }
}

class PayPalPayment implements PaymentMethod {
  readonly name = 'PayPal';

  process(amount: number): void {
    console.log(`Processing PayPal payment: $${amount}`);
    // Redirect to PayPal, handle callback, etc.
  }
}

class CryptoPayment implements PaymentMethod {
  readonly name = 'Cryptocurrency';

  process(amount: number): void {
    console.log(`Processing crypto payment: $${amount}`);
    // Generate wallet address, verify transaction, etc.
  }
}

class PaymentProcessor {
  constructor(private methods: Map<string, PaymentMethod>) {}

  processPayment(methodName: string, amount: number): void {
    const method = this.methods.get(methodName);
    if (!method) {
      throw new Error(`Unknown payment method: ${methodName}`);
    }
    method.process(amount);
  }
}

// Usage
const processor = new PaymentProcessor(new Map([
  ['credit_card', new CreditCardPayment()],
  ['paypal', new PayPalPayment()],
  ['crypto', new CryptoPayment()],
]));

processor.processPayment('crypto', 100); // No code modification needed

Adding GiftCardPayment requires creating a new class and registering it — never touching PaymentProcessor.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program.

// ❌ Bad — violates LSP: Rectangle's behavior breaks for Square
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; }
}

class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }

  setWidth(width: number): void {
    this.width = width;
    this.height = width; // Side effect: changing width changes height
  }

  setHeight(height: number): void {
    this.width = height;
    this.height = height; // Side effect: changing height changes width
  }
}

// This test fails for Square
function resizeRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(10);
  console.log(rect.getArea()); // Expected: 50, Square gives: 100
}

// ✅ Good — use a common interface instead of inheritance
interface Shape {
  getArea(): number;
}

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

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

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

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

// Now both work correctly
function printArea(shape: Shape): void {
  console.log(shape.getArea());
}

Key insight: LSP violations often come from incorrect inheritance hierarchies. Prefer composition and interfaces over base class inheritance.

I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they don't use.

// ❌ Bad — fat interface forces all printers to implement all methods
interface AllInOnePrinter {
  print(document: string): void;
  scan(document: string): void;
  fax(document: string): void;
  staple(): void;
}

class BasicPrinter implements AllInOnePrinter {
  print(document: string): void { /* works */ }
  scan(_document: string): void { throw new Error('Not supported'); }
  fax(_document: string): void { throw new Error('Not supported'); }
  staple(): void { throw new Error('Not supported'); }
}

// ✅ Good — segregated interfaces
interface Printer {
  print(document: string): void;
}

interface Scanner {
  scan(document: string): void;
}

interface Fax {
  fax(document: string): void;
}

interface Stapler {
  staple(): void;
}

class BasicPrinter implements Printer {
  print(document: string): void {
    console.log(`Printing: ${document}`);
  }
}

class MultiFunctionPrinter implements Printer, Scanner, Fax, Stapler {
  print(document: string): void { console.log(`Printing: ${document}`); }
  scan(document: string): void { console.log(`Scanning: ${document}`); }
  fax(document: string): void { console.log(`Faxing: ${document}`); }
  staple(): void { console.log('Stapling'); }
}

When in doubt, split the interface. Small, focused interfaces are easier to implement, test, and compose.

D — Dependency Inversion Principle

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

// ❌ Bad — high-level class depends on concrete implementation
class UserService {
  private db = new MySQLDatabase(); // Direct dependency

  getUser(id: string): User {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

// ✅ Good — both depend on abstraction
interface Database {
  query<T>(sql: string, params?: unknown[]): Promise<T>;
}

class MySQLDatabase implements Database {
  async query<T>(sql: string, params?: unknown[]): Promise<T> {
    console.log(`MySQL query: ${sql}`, params);
    return [] as T;
  }
}

class PostgreSQLDatabase implements Database {
  async query<T>(sql: string, params?: unknown[]): Promise<T> {
    console.log(`PostgreSQL query: ${sql}`, params);
    return [] as T;
  }
}

// High-level module depends on abstraction
class UserService {
  constructor(private db: Database) {} // Dependency injection

  async getUser(id: string): Promise<User | null> {
    const users = await this.db.query<User[]>('SELECT * FROM users WHERE id = $1', [id]);
    return users[0] ?? null;
  }
}

// Swap databases without changing UserService
const service = new UserService(new PostgreSQLDatabase());

Common Mistakes

1. Making classes that do too much (SRP violation)

If you can't describe what a class does in one sentence without "and", it has too many responsibilities.

2. Using switch/if-else chains instead of polymorphism (OCP violation)

Every new case requires modifying existing code. Use interfaces and Strategy Patternegy" >}} pattern instead.

3. Inheritance hierarchies that break substitution (LSP violation)

Square extends Rectangle is the classic example. Prefer interfaces over base classes.

4. Huge interfaces with optional methods (ISP violation)

If a class has to throw NotImplementedError for some interface methods, the interface is too large.

5. Direct instantiation of dependencies (DIP violation)

new MySQLDatabase() inside a class couples it to MySQL forever. Always inject dependencies.

6. Over-applying SOLID to simple code

A 10-line script doesn't need interfaces and dependency injection. Apply SOLID where complexity justifies it.

7. SOLID dogmatism — breaking rules intentionally

Sometimes breaking SRP for performance (e.g., combining database calls) is acceptable. Understand the trade-offs.

Practice Questions

  1. What does SOLID stand for? Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion.

  2. How does TypeScript's interface help enforce the Interface Segregation Principle? TypeScript interfaces are explicit contracts. A class can implement multiple small interfaces, avoiding forced dependencies on unused methods.

  3. What's the relationship between Dependency Injection and DIP? Dependency Injection is a technique to implement DIP. Instead of creating dependencies internally, they are passed in (injected) from outside.

  4. Does LSP apply to TypeScript interfaces or only class inheritance? LSP applies whenever you have a subtype relationship — both interface implementations and class inheritance.

  5. How do you test whether you've violated SRP? List all the reasons a class might change. If there's more than one reason, it violates SRP.

Challenge

Refactor a monolithic e-commerce checkout class that handles payment processing, inventory checking, email notifications, and order logging into separate classes following all five SOLID principles.

FAQ

Is SOLID only relevant for object-oriented programming?

The principles apply broadly. Even in functional TypeScript, SRP (single-purpose functions) and DIP (inject dependencies) are valuable patterns.

Do I need classes to apply SOLID in TypeScript?

No. You can apply SOLID with functions and modules. Interfaces work with plain objects too. Classes are one tool, not a requirement.

How strict should I be about SOLID?

SOLID is a guideline, not a law. Apply it liberally in application code (where maintainability matters) and relax it in scripts or prototypes.

Does SOLID work with functional programming?

Yes. SRP = pure functions with single purpose. OCP = higher-order functions. DIP = pass dependencies as arguments. ISP = focused function signatures.

How does SOLID help with testing?

SRP makes classes easier to mock. DIP makes it possible to inject test doubles. ISP means tests only need to implement relevant interfaces.

What's the most commonly violated SOLID principle?

Single Responsibility Principle. It's the easiest to recognize yet the most frequently broken — classes accumulate responsibilities over time through feature additions.

Mini Project

Refactor a notification system to follow SOLID principles:

  • Initial code: A single NotificationManager class that sends emails, SMS, push notifications, logs to database, and tracks analytics.
  • Apply SRP: Split into EmailNotifier, SMSNotifier, PushNotifier, NotificationLogger, AnalyticsTracker.
  • Apply OCP: Define a Notifier interface. Add new notification channels without modifying existing code.
  • Apply ISP: Separate Notifier, Logger, and Tracker interfaces.
  • Apply DIP: Inject all dependencies into the orchestrator class.
  • Apply LSP: Ensure all notifier implementations can be used interchangeably.

What's Next

You've mastered SOLID principles with TypeScript. Now learn how to handle errors systematically with {{< ref "51-error-handling" >}}, or explore async patterns with {{< ref "52-async-await" >}}.

For more on Clean Architecture, see {{< ref "58-migration-from-js" >}} for patterns to migrate JavaScript codebases.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro