SOLID Principles — Clear Guide

Short, practical explanations with concise TypeScript Node.js examples.

What is SOLID?

SOLID is a set of five principles for designing maintainable, testable, and extensible object‑oriented software.

1) Single Responsibility (SRP)

Each module/class should have one reason to change.

Keep classes cohesiveAvoid "god" classes
  • Signs of violation: Class handles unrelated concerns (logging + DB + validation)
  • Do: Split by responsibilities (e.g., Validator, Repository, Service)
Example
// === Node.js TypeScript ===
// ❌ VIOLATION: Single class doing too many things
class BadUserManager {
  async register(email: string, password: string) {
    // Validation logic mixed in
    if (!email.includes('@')) throw new Error('Invalid email');
    if (password.length < 8) throw new Error('Password too short');
    
    // Password hashing mixed in
    const hash = await this.hashPassword(password);
    
    // Database logic mixed in
    await this.db.query('INSERT INTO users (email, hash) VALUES ($1, $2)', [email, hash]);
    
    // Logging mixed in
    console.log(`User ${email} registered`);
    
    // Email sending mixed in
    await this.sendEmail(email, 'Welcome!');
  }
  
  private async hashPassword(pw: string): Promise {
    const bcrypt = require('bcrypt');
    return bcrypt.hash(pw, 10);
  }
  
  private async sendEmail(to: string, subject: string): Promise {
    // Email sending logic
  }
}

// ✅ GOOD: Separate responsibilities into focused classes

// Responsibility 1: Password validation
interface PasswordValidationResult {
  isValid: boolean;
  errors: string[];
}

class PasswordValidator {
  private readonly MIN_LENGTH = 8;
  private readonly REQUIRE_UPPERCASE = true;
  private readonly REQUIRE_LOWERCASE = true;
  private readonly REQUIRE_NUMBER = true;
  private readonly REQUIRE_SPECIAL = true;

  validate(password: string): PasswordValidationResult {
    const errors: string[] = [];

    if (password.length < this.MIN_LENGTH) {
      errors.push(`Password must be at least ${this.MIN_LENGTH} characters`);
    }

    if (this.REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) {
      errors.push('Password must contain at least one uppercase letter');
    }

    if (this.REQUIRE_LOWERCASE && !/[a-z]/.test(password)) {
      errors.push('Password must contain at least one lowercase letter');
    }

    if (this.REQUIRE_NUMBER && !/\d/.test(password)) {
      errors.push('Password must contain at least one number');
    }

    if (this.REQUIRE_SPECIAL && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
      errors.push('Password must contain at least one special character');
    }

    return {
      isValid: errors.length === 0,
      errors
    };
  }

  isStrong(password: string): boolean {
    return this.validate(password).isValid;
  }
}

// Responsibility 2: Email validation
class EmailValidator {
  private readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  isValid(email: string): boolean {
    if (!email || email.trim().length === 0) {
      return false;
    }
    return this.EMAIL_REGEX.test(email.trim().toLowerCase());
  }

  normalize(email: string): string {
    return email.trim().toLowerCase();
  }
}

// Responsibility 3: Password hashing
class PasswordHasher {
  async hash(password: string): Promise {
    const bcrypt = require('bcrypt');
    const saltRounds = 12;
    return bcrypt.hash(password, saltRounds);
  }

  async verify(password: string, hash: string): Promise {
    const bcrypt = require('bcrypt');
    return bcrypt.compare(password, hash);
  }
}

// Responsibility 4: User data access (Repository)
interface User {
  id: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
  updatedAt: Date;
}

interface IUserRepository {
  findByEmail(email: string): Promise;
  findById(id: string): Promise;
  create(user: Omit): Promise;
  update(id: string, data: Partial): Promise;
}

class UserRepository implements IUserRepository {
  constructor(private db: any) {}

  async findByEmail(email: string): Promise {
    const result = await this.db.query(
      'SELECT * FROM users WHERE email = $1',
      [email.toLowerCase()]
    );
    return result.rows[0] || null;
  }

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

  async create(user: Omit): Promise {
    const result = await this.db.query(
      `INSERT INTO users (email, password_hash, created_at, updated_at)
       VALUES ($1, $2, NOW(), NOW())
       RETURNING *`,
      [user.email, user.passwordHash]
    );
    return result.rows[0];
  }

  async update(id: string, data: Partial): Promise {
    const updates: string[] = [];
    const values: any[] = [];
    let paramIndex = 1;

    if (data.email) {
      updates.push(`email = $${paramIndex++}`);
      values.push(data.email);
    }
    if (data.passwordHash) {
      updates.push(`password_hash = $${paramIndex++}`);
      values.push(data.passwordHash);
    }

    updates.push(`updated_at = NOW()`);
    values.push(id);

    const result = await this.db.query(
      `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
      values
    );

    if (!result.rows[0]) {
      throw new Error('User not found');
    }

    return result.rows[0];
  }
}

// Responsibility 5: Logging
interface ILogger {
  info(message: string, meta?: Record): void;
  error(message: string, error?: Error, meta?: Record): void;
}

class Logger implements ILogger {
  info(message: string, meta?: Record): void {
    console.log(`[INFO] ${message}`, meta || '');
  }

  error(message: string, error?: Error, meta?: Record): void {
    console.error(`[ERROR] ${message}`, {
      error: error?.message,
      stack: error?.stack,
      ...meta
    });
  }
}

// Responsibility 6: Email sending
interface IEmailService {
  sendWelcomeEmail(to: string, name?: string): Promise;
}

class EmailService implements IEmailService {
  constructor(private mailer: any) {}

  async sendWelcomeEmail(to: string, name?: string): Promise {
    try {
      await this.mailer.send({
        to,
        subject: 'Welcome to our platform!',
        html: `

Welcome${name ? `, ${name}` : ''}!

Thank you for joining us.

` }); } catch (error) { console.error('Failed to send welcome email:', error); // Don't throw - email failure shouldn't break user registration } } } // Responsibility 7: Business logic orchestration (Service) class UserService { constructor( private userRepository: IUserRepository, private passwordValidator: PasswordValidator, private emailValidator: EmailValidator, private passwordHasher: PasswordHasher, private emailService: IEmailService, private logger: ILogger ) {} async register(email: string, password: string, name?: string): Promise { // Validate email if (!this.emailValidator.isValid(email)) { throw new Error('Invalid email address'); } const normalizedEmail = this.emailValidator.normalize(email); // Check if user already exists const existingUser = await this.userRepository.findByEmail(normalizedEmail); if (existingUser) { throw new Error('User with this email already exists'); } // Validate password const passwordValidation = this.passwordValidator.validate(password); if (!passwordValidation.isValid) { throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`); } try { // Hash password const passwordHash = await this.passwordHasher.hash(password); // Create user const user = await this.userRepository.create({ email: normalizedEmail, passwordHash }); // Log registration this.logger.info('User registered successfully', { userId: user.id, email: normalizedEmail }); // Send welcome email (non-blocking) this.emailService.sendWelcomeEmail(normalizedEmail, name).catch(error => { this.logger.error('Failed to send welcome email', error, { userId: user.id }); }); return user; } catch (error) { this.logger.error('Failed to register user', error as Error, { email: normalizedEmail }); throw error; } } async authenticate(email: string, password: string): Promise { const normalizedEmail = this.emailValidator.normalize(email); const user = await this.userRepository.findByEmail(normalizedEmail); if (!user) { throw new Error('Invalid email or password'); } const isValid = await this.passwordHasher.verify(password, user.passwordHash); if (!isValid) { throw new Error('Invalid email or password'); } this.logger.info('User authenticated', { userId: user.id }); return user; } } // Usage const db = require('./db'); // Database connection const mailer = require('./mailer'); // Email service const userService = new UserService( new UserRepository(db), new PasswordValidator(), new EmailValidator(), new PasswordHasher(), new EmailService(mailer), new Logger() ); // Register user try { const user = await userService.register( 'user@example.com', 'SecurePass123!', 'John Doe' ); console.log('User registered:', user.id); } catch (error) { console.error('Registration failed:', error.message); } // === NestJS === import { Injectable } from '@nestjs/common'; @Injectable() export class PasswordValidator { validate(password: string): PasswordValidationResult { // Same implementation } } @Injectable() export class EmailValidator { isValid(email: string): boolean { // Same implementation } } @Injectable() export class PasswordHasher { // Same implementation } @Injectable() export class UserRepository implements IUserRepository { // Same implementation with @InjectRepository decorator for TypeORM } @Injectable() export class UserService { constructor( private userRepository: UserRepository, private passwordValidator: PasswordValidator, private emailValidator: EmailValidator, private passwordHasher: PasswordHasher, private emailService: EmailService, private logger: Logger ) {} // Same implementation }

2) Open/Closed (OCP)

Open for extension, closed for modification.

Add behavior via new classesAvoid editing stable code
  • Signs of violation: Editing a stable class whenever a new variant is added
  • Do: Extend via new implementations or plugins; keep callers unchanged
Example
// === Node.js TypeScript ===
// ❌ VIOLATION: Modifying existing code to add new payment methods
class BadOrderService {
  async checkout(amount: number, method: 'card' | 'paypal'): Promise {
    if (method === 'card') {
      // Credit card logic
      const stripe = require('stripe')(process.env.STRIPE_KEY);
      const charge = await stripe.charges.create({
        amount: amount * 100,
        currency: 'usd'
      });
      return { success: charge.status === 'succeeded', transactionId: charge.id };
    } else if (method === 'paypal') {
      // PayPal logic - added by modifying this class
      const paypal = require('paypal-rest-sdk');
      // PayPal implementation
      return { success: true, transactionId: 'paypal_123' };
    }
    // Need to modify this class again to add BankTransfer, Crypto, etc.
    throw new Error('Unsupported payment method');
  }
}

// ✅ GOOD: Open for extension, closed for modification

interface PaymentResult {
  success: boolean;
  transactionId: string;
  gateway: string;
  error?: string;
}

interface PaymentMethod {
  process(amount: number, currency: string, metadata: Record): Promise;
  refund(transactionId: string, amount: number): Promise;
  validate(amount: number, metadata: Record): { valid: boolean; error?: string };
  getSupportedCurrencies(): string[];
}

// Base payment method implementation
abstract class BasePaymentMethod implements PaymentMethod {
  abstract process(amount: number, currency: string, metadata: Record): Promise;
  abstract refund(transactionId: string, amount: number): Promise;
  abstract getSupportedCurrencies(): string[];

  validate(amount: number, metadata: Record): { valid: boolean; error?: string } {
    if (amount <= 0) {
      return { valid: false, error: 'Amount must be greater than 0' };
    }
    const currency = metadata.currency || 'USD';
    if (!this.getSupportedCurrencies().includes(currency.toUpperCase())) {
      return { valid: false, error: `Currency ${currency} not supported` };
    }
    return { valid: true };
  }

  protected handleError(error: any, gateway: string): PaymentResult {
    return {
      success: false,
      transactionId: '',
      gateway,
      error: error.message || 'Payment processing failed'
    };
  }
}

// Concrete implementation 1: Credit Card
class CreditCardPayment extends BasePaymentMethod {
  private stripe: any;

  constructor() {
    super();
    const Stripe = require('stripe');
    this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  }

  getSupportedCurrencies(): string[] {
    return ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD'];
  }

  validate(amount: number, metadata: Record): { valid: boolean; error?: string } {
    const baseValidation = super.validate(amount, metadata);
    if (!baseValidation.valid) return baseValidation;

    if (!metadata.cardToken && !metadata.cardNumber) {
      return { valid: false, error: 'Card token or card number required' };
    }

    if (metadata.cardNumber && !this.isValidCardNumber(metadata.cardNumber)) {
      return { valid: false, error: 'Invalid card number' };
    }

    return { valid: true };
  }

  async process(amount: number, currency: string, metadata: Record): Promise {
    try {
      const validation = this.validate(amount, { ...metadata, currency });
      if (!validation.valid) {
        return {
          success: false,
          transactionId: '',
          gateway: 'stripe',
          error: validation.error
        };
      }

      const charge = await this.stripe.charges.create({
        amount: Math.round(amount * 100), // Convert to cents
        currency: currency.toLowerCase(),
        source: metadata.cardToken || this.createTokenFromCard(metadata),
        description: metadata.description || 'Order payment',
        metadata: {
          orderId: metadata.orderId || '',
          userId: metadata.userId || ''
        }
      });

      return {
        success: charge.status === 'succeeded',
        transactionId: charge.id,
        gateway: 'stripe'
      };
    } catch (error) {
      return this.handleError(error, 'stripe');
    }
  }

  async refund(transactionId: string, amount: number): Promise {
    try {
      const refund = await this.stripe.refunds.create({
        charge: transactionId,
        amount: Math.round(amount * 100)
      });

      return {
        success: refund.status === 'succeeded',
        transactionId: refund.id,
        gateway: 'stripe'
      };
    } catch (error) {
      return this.handleError(error, 'stripe');
    }
  }

  private isValidCardNumber(cardNumber: string): boolean {
    return /^\d{13,19}$/.test(cardNumber.replace(/\s/g, ''));
  }

  private createTokenFromCard(metadata: Record): string {
    // Create Stripe token from card details
    return 'tok_test_card';
  }
}

// Concrete implementation 2: PayPal
class PayPalPayment extends BasePaymentMethod {
  private paypal: any;

  constructor() {
    super();
    const paypal = require('paypal-rest-sdk');
    paypal.configure({
      mode: process.env.PAYPAL_MODE || 'sandbox',
      client_id: process.env.PAYPAL_CLIENT_ID,
      client_secret: process.env.PAYPAL_CLIENT_SECRET
    });
    this.paypal = paypal;
  }

  getSupportedCurrencies(): string[] {
    return ['USD', 'EUR', 'GBP', 'CAD', 'AUD'];
  }

  validate(amount: number, metadata: Record): { valid: boolean; error?: string } {
    const baseValidation = super.validate(amount, metadata);
    if (!baseValidation.valid) return baseValidation;

    if (!metadata.paypalEmail || !this.isValidEmail(metadata.paypalEmail)) {
      return { valid: false, error: 'Valid PayPal email required' };
    }

    return { valid: true };
  }

  async process(amount: number, currency: string, metadata: Record): Promise {
    try {
      const validation = this.validate(amount, { ...metadata, currency });
      if (!validation.valid) {
        return {
          success: false,
          transactionId: '',
          gateway: 'paypal',
          error: validation.error
        };
      }

      const createPaymentJson = {
        intent: 'sale',
        payer: {
          payment_method: 'paypal',
          payer_info: {
            email: metadata.paypalEmail
          }
        },
        transactions: [{
          amount: {
            total: amount.toFixed(2),
            currency: currency
          },
          description: metadata.description || 'Order payment'
        }]
      };

      return new Promise((resolve, reject) => {
        this.paypal.payment.create(createPaymentJson, (error: any, payment: any) => {
          if (error) {
            resolve(this.handleError(error, 'paypal'));
          } else {
            resolve({
              success: true,
              transactionId: payment.id,
              gateway: 'paypal'
            });
          }
        });
      });
    } catch (error) {
      return this.handleError(error, 'paypal');
    }
  }

  async refund(transactionId: string, amount: number): Promise {
    // PayPal refund implementation
    return {
      success: true,
      transactionId: `refund_${Date.now()}`,
      gateway: 'paypal'
    };
  }

  private isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

// Concrete implementation 3: Bank Transfer (added without modifying existing code)
class BankTransferPayment extends BasePaymentMethod {
  getSupportedCurrencies(): string[] {
    return ['USD', 'EUR', 'GBP'];
  }

  validate(amount: number, metadata: Record): { valid: boolean; error?: string } {
    const baseValidation = super.validate(amount, metadata);
    if (!baseValidation.valid) return baseValidation;

    if (!metadata.accountNumber || !metadata.routingNumber) {
      return { valid: false, error: 'Bank account details required' };
    }

    if (amount < 10) {
      return { valid: false, error: 'Minimum transfer amount is $10' };
    }

    return { valid: true };
  }

  async process(amount: number, currency: string, metadata: Record): Promise {
    // Bank transfer processing
    await new Promise(resolve => setTimeout(resolve, 200)); // Simulate processing

    return {
      success: true,
      transactionId: `bank_${Date.now()}`,
      gateway: 'bank_transfer'
    };
  }

  async refund(transactionId: string, amount: number): Promise {
    // Bank transfer refunds take time
    return {
      success: false,
      transactionId: '',
      gateway: 'bank_transfer',
      error: 'Bank transfer refunds must be processed manually'
    };
  }
}

// OrderService - closed for modification, open for extension
class OrderService {
  private paymentMethods: Map = new Map();

  registerPaymentMethod(name: string, method: PaymentMethod): void {
    this.paymentMethods.set(name, method);
  }

  async checkout(
    orderId: string,
    amount: number,
    currency: string,
    paymentMethodName: string,
    paymentMetadata: Record
  ): Promise {
    const paymentMethod = this.paymentMethods.get(paymentMethodName);
    
    if (!paymentMethod) {
      return {
        success: false,
        transactionId: '',
        gateway: paymentMethodName,
        error: `Payment method '${paymentMethodName}' not supported`
      };
    }

    const result = await paymentMethod.process(amount, currency, {
      ...paymentMetadata,
      orderId
    });

    if (result.success) {
      // Save payment record to database
      await this.recordPayment(orderId, result);
    }

    return result;
  }

  async refund(orderId: string, transactionId: string, amount: number, paymentMethodName: string): Promise {
    const paymentMethod = this.paymentMethods.get(paymentMethodName);
    
    if (!paymentMethod) {
      return {
        success: false,
        transactionId: '',
        gateway: paymentMethodName,
        error: `Payment method '${paymentMethodName}' not supported`
      };
    }

    return paymentMethod.refund(transactionId, amount);
  }

  private async recordPayment(orderId: string, result: PaymentResult): Promise {
    // Database operation
    console.log(`Recording payment for order ${orderId}:`, result);
  }
}

// Usage - Add new payment methods without modifying OrderService
const orderService = new OrderService();

// Register existing payment methods
orderService.registerPaymentMethod('creditcard', new CreditCardPayment());
orderService.registerPaymentMethod('paypal', new PayPalPayment());

// Add new payment method without modifying OrderService
orderService.registerPaymentMethod('banktransfer', new BankTransferPayment());

// Process orders with any registered payment method
await orderService.checkout('order_123', 99.99, 'USD', 'creditcard', {
  cardToken: 'tok_visa_test'
});

await orderService.checkout('order_456', 149.99, 'USD', 'banktransfer', {
  accountNumber: '123456789',
  routingNumber: '987654321'
});

// === NestJS ===
import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class OrderService {
  constructor(
    @Inject('PAYMENT_METHODS') private paymentMethods: Map
  ) {}

  async checkout(orderId: string, amount: number, currency: string, method: string, metadata: any): Promise {
    // Same implementation
  }
}

// Factory to register payment methods
const PAYMENT_METHODS_PROVIDER = {
  provide: 'PAYMENT_METHODS',
  useFactory: (): Map => {
    const methods = new Map();
    methods.set('creditcard', new CreditCardPayment());
    methods.set('paypal', new PayPalPayment());
    methods.set('banktransfer', new BankTransferPayment());
    // Easy to add more: methods.set('crypto', new CryptoPayment());
    return methods;
  }
};

@Module({
  providers: [PAYMENT_METHODS_PROVIDER, OrderService],
  exports: [OrderService]
})
export class PaymentModule {}

3) Liskov Substitution (LSP)

Subtypes must be usable via base types without surprises.

Honor contractsNo broken assumptions
  • Signs of violation: Subtype throws for valid base cases, changes return semantics
  • Do: Keep behavioral contracts identical for all implementations
Example
// === Node.js TypeScript ===
// ❌ VIOLATION: Subtype breaks the contract
interface Cache {
  get(key: string): string | null;
  set(key: string, value: string): void;
  delete(key: string): boolean;
}

class BadMemoryCache implements Cache {
  private data = new Map();

  get(key: string): string | null {
    // Violation: Throws exception instead of returning null
    if (!this.data.has(key)) {
      throw new Error('Key not found'); // Should return null!
    }
    return this.data.get(key) || null;
  }

  set(key: string, value: string): void {
    if (key.length > 100) {
      throw new Error('Key too long'); // Breaks contract - should accept any string
    }
    this.data.set(key, value);
  }

  delete(key: string): boolean {
    // Violation: Returns undefined instead of boolean
    return this.data.delete(key) as any; // Wrong return type
  }
}

// ✅ GOOD: All implementations honor the contract

interface CacheEntry {
  value: T;
  expiresAt: number | null;
}

interface ICache {
  get(key: string): Promise;
  set(key: string, value: T, ttlSeconds?: number): Promise;
  delete(key: string): Promise;
  exists(key: string): Promise;
  clear(): Promise;
}

// Implementation 1: In-memory cache
class MemoryCache implements ICache {
  private data = new Map>();
  private maxSize: number;

  constructor(maxSize: number = 10000) {
    this.maxSize = maxSize;
  }

  async get(key: string): Promise {
    const entry = this.data.get(key);
    
    if (!entry) {
      return null; // Always return null for missing keys
    }

    // Check expiration
    if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
      this.data.delete(key);
      return null; // Return null for expired keys
    }

    return entry.value as T;
  }

  async set(key: string, value: T, ttlSeconds?: number): Promise {
    // Enforce max size
    if (this.data.size >= this.maxSize && !this.data.has(key)) {
      // Remove oldest entry (simple LRU)
      const firstKey = this.data.keys().next().value;
      if (firstKey) {
        this.data.delete(firstKey);
      }
    }

    const expiresAt = ttlSeconds
      ? Date.now() + (ttlSeconds * 1000)
      : null;

    this.data.set(key, {
      value,
      expiresAt
    });
  }

  async delete(key: string): Promise {
    // Always returns boolean as per contract
    return this.data.delete(key);
  }

  async exists(key: string): Promise {
    const entry = this.data.get(key);
    if (!entry) {
      return false;
    }

    // Check if expired
    if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
      this.data.delete(key);
      return false;
    }

    return true;
  }

  async clear(): Promise {
    this.data.clear();
  }

  // Additional method that doesn't break contract
  getSize(): number {
    return this.data.size;
  }
}

// Implementation 2: Redis cache - fully substitutable
class RedisCache implements ICache {
  private client: any;

  constructor(redisUrl: string) {
    const redis = require('redis');
    this.client = redis.createClient({ url: redisUrl });
    this.client.connect().catch(console.error);
  }

  async get(key: string): Promise {
    try {
      const value = await this.client.get(key);
      
      if (value === null) {
        return null; // Returns null for missing keys - same contract
      }

      return JSON.parse(value) as T;
    } catch (error) {
      console.error('Redis get error:', error);
      return null; // Returns null on error - consistent behavior
    }
  }

  async set(key: string, value: T, ttlSeconds?: number): Promise {
    try {
      const serialized = JSON.stringify(value);
      
      if (ttlSeconds) {
        await this.client.setEx(key, ttlSeconds, serialized);
      } else {
        await this.client.set(key, serialized);
      }
    } catch (error) {
      console.error('Redis set error:', error);
      // Don't throw - cache failures shouldn't break the app
    }
  }

  async delete(key: string): Promise {
    try {
      const result = await this.client.del(key);
      return result > 0; // Returns boolean - same contract
    } catch (error) {
      console.error('Redis delete error:', error);
      return false; // Returns boolean on error - consistent
    }
  }

  async exists(key: string): Promise {
    try {
      const result = await this.client.exists(key);
      return result === 1; // Returns boolean - same contract
    } catch (error) {
      console.error('Redis exists error:', error);
      return false; // Returns boolean on error - consistent
    }
  }

  async clear(): Promise {
    try {
      await this.client.flushDb();
    } catch (error) {
      console.error('Redis clear error:', error);
      // Don't throw - consistent with contract
    }
  }
}

// Implementation 3: No-op cache - valid substitute (for testing/disabled cache)
class NoopCache implements ICache {
  async get(key: string): Promise {
    return null; // Always returns null - valid behavior
  }

  async set(key: string, value: T, ttlSeconds?: number): Promise {
    // Does nothing - but still honors the contract (no errors)
  }

  async delete(key: string): Promise {
    return false; // Always returns false - valid behavior
  }

  async exists(key: string): Promise {
    return false; // Always returns false - valid behavior
  }

  async clear(): Promise {
    // Does nothing - but still honors the contract
  }
}

// Implementation 4: Multi-layer cache (composite pattern, still substitutable)
class LayeredCache implements ICache {
  constructor(
    private l1Cache: ICache, // Fast cache (memory)
    private l2Cache: ICache  // Slower cache (Redis)
  ) {}

  async get(key: string): Promise {
    // Try L1 first
    const l1Value = await this.l1Cache.get(key);
    if (l1Value !== null) {
      return l1Value;
    }

    // Try L2
    const l2Value = await this.l2Cache.get(key);
    if (l2Value !== null) {
      // Populate L1
      await this.l1Cache.set(key, l2Value);
      return l2Value;
    }

    return null; // Returns null if not found - same contract
  }

  async set(key: string, value: T, ttlSeconds?: number): Promise {
    // Set in both layers
    await Promise.all([
      this.l1Cache.set(key, value, ttlSeconds),
      this.l2Cache.set(key, value, ttlSeconds)
    ]);
  }

  async delete(key: string): Promise {
    // Delete from both layers
    const [l1Result, l2Result] = await Promise.all([
      this.l1Cache.delete(key),
      this.l2Cache.delete(key)
    ]);
    return l1Result || l2Result; // Returns boolean - same contract
  }

  async exists(key: string): Promise {
    // Check L1 first
    if (await this.l1Cache.exists(key)) {
      return true;
    }
    return await this.l2Cache.exists(key); // Returns boolean - same contract
  }

  async clear(): Promise {
    await Promise.all([
      this.l1Cache.clear(),
      this.l2Cache.clear()
    ]);
  }
}

// Usage: All implementations are interchangeable
class CacheService {
  constructor(private cache: ICache) {}

  async getUser(userId: string): Promise {
    const cacheKey = `user:${userId}`;
    
    // Works with ANY cache implementation - they all honor the contract
    const cached = await this.cache.get(cacheKey);
    if (cached !== null) {
      return cached;
    }

    // Fetch from database
    const user = await this.fetchUserFromDB(userId);
    
    // Cache it (all implementations handle this the same way)
    if (user) {
      await this.cache.set(cacheKey, user, 3600); // 1 hour TTL
    }

    return user;
  }

  private async fetchUserFromDB(userId: string): Promise {
    // Database fetch logic
    return { id: userId, name: 'John Doe' };
  }
}

// All of these work - they're all substitutable
const memoryCacheService = new CacheService(new MemoryCache());
const redisCacheService = new CacheService(new RedisCache('redis://localhost:6379'));
const noopCacheService = new CacheService(new NoopCache());
const layeredCacheService = new CacheService(
  new LayeredCache(
    new MemoryCache(),
    new RedisCache('redis://localhost:6379')
  )
);

// === NestJS ===
import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class CacheService {
  constructor(@Inject('CACHE') private cache: ICache) {}

  async getUser(userId: string): Promise {
    // Same implementation - works with any cache implementation
  }
}

// Can inject any cache implementation
const CACHE_PROVIDER = {
  provide: 'CACHE',
  useFactory: (): ICache => {
    const env = process.env.NODE_ENV;
    if (env === 'test') {
      return new NoopCache(); // Test-friendly
    }
    if (env === 'production') {
      return new RedisCache(process.env.REDIS_URL || '');
    }
    return new MemoryCache(); // Development
  }
};

@Module({
  providers: [CACHE_PROVIDER, CacheService],
  exports: [CacheService]
})
export class CacheModule {}

4) Interface Segregation (ISP)

Prefer several small, specific interfaces over a large "fat" one.

Focused APIsLess coupling
  • Signs of violation: Clients implement methods they never use
  • Do: Split large interfaces so each client depends only on what it needs
Example
// === Node.js TypeScript ===
// ❌ VIOLATION: Fat interface - clients forced to implement unused methods
interface FileOperations {
  read(path: string): Promise;
  write(path: string, data: string): Promise;
  delete(path: string): Promise;
  copy(source: string, destination: string): Promise;
  move(source: string, destination: string): Promise;
  exists(path: string): Promise;
  listDirectory(path: string): Promise;
  createDirectory(path: string): Promise;
  getMetadata(path: string): Promise;
  setPermissions(path: string, permissions: number): Promise;
  compress(path: string, output: string): Promise;
  decompress(source: string, output: string): Promise;
}

class LogReader implements FileOperations {
  async read(path: string): Promise {
    // Only needs read, but must implement ALL methods
    return await fs.promises.readFile(path, 'utf-8');
  }

  // Forced to implement methods that are never used
  async write(path: string, data: string): Promise {
    throw new Error('LogReader cannot write'); // Violation!
  }

  async delete(path: string): Promise {
    throw new Error('LogReader cannot delete'); // Violation!
  }

  // ... must implement all other methods even though unused
}

// ✅ GOOD: Segregated interfaces - clients only depend on what they need

// Interface 1: Reading operations
interface IFileReader {
  read(path: string): Promise;
  readBinary(path: string): Promise;
  exists(path: string): Promise;
  getMetadata(path: string): Promise;
}

// Interface 2: Writing operations
interface IFileWriter {
  write(path: string, data: string): Promise;
  writeBinary(path: string, data: Buffer): Promise;
  append(path: string, data: string): Promise;
}

// Interface 3: File management operations
interface IFileManager {
  delete(path: string): Promise;
  copy(source: string, destination: string): Promise;
  move(source: string, destination: string): Promise;
  rename(oldPath: string, newPath: string): Promise;
}

// Interface 4: Directory operations
interface IDirectoryManager {
  listDirectory(path: string): Promise;
  createDirectory(path: string, recursive?: boolean): Promise;
  deleteDirectory(path: string, recursive?: boolean): Promise;
  exists(path: string): Promise;
}

// Interface 5: Permission operations
interface IPermissionManager {
  getPermissions(path: string): Promise;
  setPermissions(path: string, permissions: number): Promise;
  getOwner(path: string): Promise;
  setOwner(path: string, owner: string): Promise;
}

// Interface 6: Compression operations (optional feature)
interface ICompressionHandler {
  compress(source: string, output: string, format?: 'zip' | 'tar' | 'gzip'): Promise;
  decompress(source: string, output: string): Promise;
  listArchive(source: string): Promise;
}

// Concrete implementations - only implement what they need
class FileSystemReader implements IFileReader {
  async read(path: string): Promise {
    const fs = require('fs').promises;
    return await fs.readFile(path, 'utf-8');
  }

  async readBinary(path: string): Promise {
    const fs = require('fs').promises;
    return await fs.readFile(path);
  }

  async exists(path: string): Promise {
    const fs = require('fs').promises;
    try {
      await fs.access(path);
      return true;
    } catch {
      return false;
    }
  }

  async getMetadata(path: string): Promise {
    const fs = require('fs').promises;
    const stats = await fs.stat(path);
    return {
      size: stats.size,
      createdAt: stats.birthtime,
      modifiedAt: stats.mtime,
      isDirectory: stats.isDirectory(),
      isFile: stats.isFile()
    };
  }
}

class FileSystemWriter implements IFileWriter {
  async write(path: string, data: string): Promise {
    const fs = require('fs').promises;
    await fs.writeFile(path, data, 'utf-8');
  }

  async writeBinary(path: string, data: Buffer): Promise {
    const fs = require('fs').promises;
    await fs.writeFile(path, data);
  }

  async append(path: string, data: string): Promise {
    const fs = require('fs').promises;
    await fs.appendFile(path, data, 'utf-8');
  }
}

class FileSystemManager implements IFileManager {
  async delete(path: string): Promise {
    const fs = require('fs').promises;
    await fs.unlink(path);
  }

  async copy(source: string, destination: string): Promise {
    const fs = require('fs').promises;
    await fs.copyFile(source, destination);
  }

  async move(source: string, destination: string): Promise {
    const fs = require('fs').promises;
    await fs.rename(source, destination);
  }

  async rename(oldPath: string, newPath: string): Promise {
    const fs = require('fs').promises;
    await fs.rename(oldPath, newPath);
  }
}

class LogReaderService {
  // Only depends on IFileReader - doesn't need write/delete/compress
  constructor(private reader: IFileReader) {}

  async readLogFile(path: string): Promise {
    const content = await this.reader.read(path);
    return content.split('\n').filter(line => line.trim().length > 0);
  }

  async getLogStats(path: string): Promise {
    const metadata = await this.reader.getMetadata(path);
    const content = await this.reader.read(path);
    const lines = content.split('\n');

    return {
      fileSize: metadata.size,
      lineCount: lines.length,
      lastModified: metadata.modifiedAt
    };
  }
}

class ConfigWriterService {
  // Only depends on IFileWriter - doesn't need read/delete/compress
  constructor(private writer: IFileWriter) {}

  async saveConfig(path: string, config: Record): Promise {
    const json = JSON.stringify(config, null, 2);
    await this.writer.write(path, json);
  }

  async appendToLog(path: string, entry: string): Promise {
    const timestamp = new Date().toISOString();
    await this.writer.append(path, `[${timestamp}] ${entry}\n`);
  }
}

class FileBackupService {
  // Uses multiple small interfaces - only what it needs
  constructor(
    private reader: IFileReader,
    private writer: IFileWriter,
    private manager: IFileManager,
    private compressor?: ICompressionHandler
  ) {}

  async backup(source: string, destination: string): Promise {
    // Check if source exists
    if (!await this.reader.exists(source)) {
      throw new Error(`Source file does not exist: ${source}`);
    }

    // Read source
    const data = await this.reader.readBinary(source);

    // Compress if compressor is available
    if (this.compressor) {
      await this.compressor.compress(source, destination + '.zip');
    } else {
      // Otherwise just copy
      await this.writer.writeBinary(destination, data);
    }
  }
}

// Advanced: Read-write interface (combines Reader + Writer for convenience)
interface IFileReadWriter extends IFileReader, IFileWriter {
  // Inherits all methods from both interfaces
}

class FileSystemReadWriter implements IFileReadWriter {
  private reader: IFileReader;
  private writer: IFileWriter;

  constructor() {
    this.reader = new FileSystemReader();
    this.writer = new FileSystemWriter();
  }

  // Delegate to reader
  async read(path: string): Promise {
    return this.reader.read(path);
  }

  async readBinary(path: string): Promise {
    return this.reader.readBinary(path);
  }

  async exists(path: string): Promise {
    return this.reader.exists(path);
  }

  async getMetadata(path: string): Promise {
    return this.reader.getMetadata(path);
  }

  // Delegate to writer
  async write(path: string, data: string): Promise {
    return this.writer.write(path, data);
  }

  async writeBinary(path: string, data: Buffer): Promise {
    return this.writer.writeBinary(path, data);
  }

  async append(path: string, data: string): Promise {
    return this.writer.append(path, data);
  }
}

interface FileMetadata {
  size: number;
  createdAt: Date;
  modifiedAt: Date;
  isDirectory: boolean;
  isFile: boolean;
}

interface LogStats {
  fileSize: number;
  lineCount: number;
  lastModified: Date;
}

// Usage
const logReader = new LogReaderService(new FileSystemReader());
const configWriter = new ConfigWriterService(new FileSystemWriter());
const backupService = new FileBackupService(
  new FileSystemReader(),
  new FileSystemWriter(),
  new FileSystemManager()
);

// === NestJS ===
import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class LogReaderService {
  constructor(@Inject('FILE_READER') private reader: IFileReader) {}
  // Same implementation
}

@Injectable()
export class ConfigWriterService {
  constructor(@Inject('FILE_WRITER') private writer: IFileWriter) {}
  // Same implementation
}

const FILE_READER_PROVIDER = {
  provide: 'FILE_READER',
  useClass: FileSystemReader
};

const FILE_WRITER_PROVIDER = {
  provide: 'FILE_WRITER',
  useClass: FileSystemWriter
};

@Module({
  providers: [
    FILE_READER_PROVIDER,
    FILE_WRITER_PROVIDER,
    LogReaderService,
    ConfigWriterService
  ],
  exports: [LogReaderService, ConfigWriterService]
})
export class FileOperationsModule {}

5) Dependency Inversion (DIP)

Depend on abstractions, not concretions.

Inject interfacesSwap implementations
  • Signs of violation: High-level modules construct concrete dependencies directly
  • Do: Depend on interfaces; inject concrete implementations at composition root
Example
// === Node.js TypeScript ===
// ❌ VIOLATION: High-level module depends on concrete implementations
class BadNotificationService {
  private smtpMailer: any;
  private logger: any;
  private emailValidator: any;

  constructor() {
    // Directly creating concrete dependencies - tight coupling!
    this.smtpMailer = require('nodemailer').createTransport({
      host: process.env.SMTP_HOST,
      port: parseInt(process.env.SMTP_PORT || '587'),
      auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS
      }
    });

    this.logger = {
      info: (msg: string) => console.log(msg),
      error: (msg: string) => console.error(msg)
    };

    this.emailValidator = {
      isValid: (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
    };
  }

  async notifyUser(email: string, message: string): Promise {
    // Hard to test - can't mock dependencies
    // Hard to change - tightly coupled to specific implementations
    if (!this.emailValidator.isValid(email)) {
      this.logger.error('Invalid email');
      throw new Error('Invalid email');
    }

    await this.smtpMailer.sendMail({
      to: email,
      subject: 'Notification',
      text: message
    });

    this.logger.info(`Email sent to ${email}`);
  }
}

// ✅ GOOD: Depend on abstractions, inject concrete implementations

// Abstractions (interfaces)
interface IEmailService {
  send(to: string, subject: string, body: string, options?: EmailOptions): Promise;
  sendBulk(recipients: string[], subject: string, body: string): Promise;
  validateEmail(email: string): boolean;
}

interface ILogger {
  info(message: string, meta?: Record): void;
  error(message: string, error?: Error, meta?: Record): void;
  warn(message: string, meta?: Record): void;
  debug(message: string, meta?: Record): void;
}

interface IEmailTemplateEngine {
  render(templateName: string, data: Record): Promise;
}

interface IEventEmitter {
  emit(event: string, payload: any): void;
  on(event: string, handler: (payload: any) => void): void;
}

interface EmailOptions {
  cc?: string[];
  bcc?: string[];
  attachments?: Array<{ filename: string; content: Buffer }>;
  priority?: 'high' | 'normal' | 'low';
}

interface EmailResult {
  success: boolean;
  messageId?: string;
  error?: string;
}

// Concrete implementations
class SmtpEmailService implements IEmailService {
  private transporter: any;

  constructor(
    private config: {
      host: string;
      port: number;
      user: string;
      password: string;
      secure: boolean;
    },
    private logger: ILogger
  ) {
    const nodemailer = require('nodemailer');
    this.transporter = nodemailer.createTransport({
      host: config.host,
      port: config.port,
      secure: config.secure,
      auth: {
        user: config.user,
        pass: config.password
      }
    });
  }

  validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  async send(to: string, subject: string, body: string, options?: EmailOptions): Promise {
    try {
      if (!this.validateEmail(to)) {
        return {
          success: false,
          error: 'Invalid email address'
        };
      }

      const mailOptions: any = {
        from: this.config.user,
        to,
        subject,
        html: body
      };

      if (options) {
        if (options.cc) mailOptions.cc = options.cc;
        if (options.bcc) mailOptions.bcc = options.bcc;
        if (options.attachments) mailOptions.attachments = options.attachments;
      }

      const info = await this.transporter.sendMail(mailOptions);

      this.logger.info('Email sent successfully', {
        to,
        messageId: info.messageId
      });

      return {
        success: true,
        messageId: info.messageId
      };
    } catch (error) {
      this.logger.error('Failed to send email', error as Error, { to });
      return {
        success: false,
        error: (error as Error).message
      };
    }
  }

  async sendBulk(recipients: string[], subject: string, body: string): Promise {
    const results: EmailResult[] = [];
    
    for (const recipient of recipients) {
      const result = await this.send(recipient, subject, body);
      results.push(result);
    }

    return results;
  }
}

class SendGridEmailService implements IEmailService {
  private sgMail: any;

  constructor(
    private apiKey: string,
    private logger: ILogger
  ) {
    const sgMail = require('@sendgrid/mail');
    sgMail.setApiKey(apiKey);
    this.sgMail = sgMail;
  }

  validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  async send(to: string, subject: string, body: string, options?: EmailOptions): Promise {
    try {
      const msg: any = {
        to,
        from: process.env.FROM_EMAIL || 'noreply@example.com',
        subject,
        html: body
      };

      if (options) {
        if (options.cc) msg.cc = options.cc;
        if (options.bcc) msg.bcc = options.bcc;
      }

      await this.sgMail.send(msg);

      return {
        success: true,
        messageId: `sg_${Date.now()}`
      };
    } catch (error) {
      this.logger.error('SendGrid email failed', error as Error);
      return {
        success: false,
        error: (error as Error).message
      };
    }
  }

  async sendBulk(recipients: string[], subject: string, body: string): Promise {
    // SendGrid supports batch sending
    try {
      const messages = recipients.map(to => ({
        to,
        from: process.env.FROM_EMAIL || 'noreply@example.com',
        subject,
        html: body
      }));

      await this.sgMail.send(messages);
      
      return recipients.map(() => ({
        success: true,
        messageId: `sg_${Date.now()}`
      }));
    } catch (error) {
      this.logger.error('SendGrid bulk email failed', error as Error);
      return recipients.map(() => ({
        success: false,
        error: (error as Error).message
      }));
    }
  }
}

class ConsoleEmailService implements IEmailService {
  validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  async send(to: string, subject: string, body: string, options?: EmailOptions): Promise {
    console.log('='.repeat(50));
    console.log('EMAIL (Console - Development/Testing)');
    console.log('='.repeat(50));
    console.log(`To: ${to}`);
    console.log(`Subject: ${subject}`);
    console.log(`Body: ${body}`);
    if (options) {
      if (options.cc) console.log(`CC: ${options.cc.join(', ')}`);
      if (options.bcc) console.log(`BCC: ${options.bcc.join(', ')}`);
    }
    console.log('='.repeat(50));

    return {
      success: true,
      messageId: `console_${Date.now()}`
    };
  }

  async sendBulk(recipients: string[], subject: string, body: string): Promise {
    return Promise.all(recipients.map(recipient => this.send(recipient, subject, body)));
  }
}

class Logger implements ILogger {
  info(message: string, meta?: Record): void {
    console.log(`[INFO] ${message}`, meta || '');
  }

  error(message: string, error?: Error, meta?: Record): void {
    console.error(`[ERROR] ${message}`, {
      error: error?.message,
      stack: error?.stack,
      ...meta
    });
  }

  warn(message: string, meta?: Record): void {
    console.warn(`[WARN] ${message}`, meta || '');
  }

  debug(message: string, meta?: Record): void {
    console.debug(`[DEBUG] ${message}`, meta || '');
  }
}

class HandlebarsTemplateEngine implements IEmailTemplateEngine {
  private handlebars: any;

  constructor() {
    this.handlebars = require('handlebars');
  }

  async render(templateName: string, data: Record): Promise {
    // In real app, load template from file system
    const template = this.handlebars.compile(`
      

{{title}}

{{message}}

Best regards,
{{senderName}}

`); return template(data); } } // High-level module - depends on abstractions only class NotificationService { constructor( private emailService: IEmailService, private logger: ILogger, private templateEngine?: IEmailTemplateEngine, private eventEmitter?: IEventEmitter ) {} async notifyUser(email: string, notificationType: string, data: Record): Promise { try { // Validate email if (!this.emailService.validateEmail(email)) { this.logger.warn('Invalid email address', { email }); return { success: false, error: 'Invalid email address' }; } // Render template if available let body: string; let subject: string; if (this.templateEngine) { body = await this.templateEngine.render(`${notificationType}_email`, data); subject = data.subject || 'Notification'; } else { subject = data.subject || 'Notification'; body = data.message || 'You have a new notification'; } // Send email const result = await this.emailService.send(email, subject, body, { priority: data.priority || 'normal' }); // Emit event if available if (this.eventEmitter) { this.eventEmitter.emit('notification.sent', { email, notificationType, result }); } this.logger.info('Notification sent', { email, notificationType, success: result.success }); return result; } catch (error) { this.logger.error('Failed to send notification', error as Error, { email, notificationType }); throw error; } } async sendWelcomeEmail(email: string, userName: string): Promise { return this.notifyUser(email, 'welcome', { subject: 'Welcome to our platform!', message: `Hello ${userName}, welcome!`, userName, senderName: 'Support Team' }); } async sendPasswordResetEmail(email: string, resetToken: string): Promise { return this.notifyUser(email, 'password_reset', { subject: 'Password Reset Request', message: `Click here to reset your password: ${resetToken}`, resetToken, senderName: 'Security Team', priority: 'high' as const }); } } // Composition Root - choose implementations here function createNotificationService(env: 'development' | 'production' | 'test'): NotificationService { const logger = new Logger(); let emailService: IEmailService; switch (env) { case 'production': emailService = new SendGridEmailService( process.env.SENDGRID_API_KEY || '', logger ); break; case 'test': emailService = new ConsoleEmailService(); break; case 'development': default: emailService = new ConsoleEmailService(); break; } const templateEngine = new HandlebarsTemplateEngine(); // const eventEmitter = new EventEmitter(); // If needed return new NotificationService( emailService, logger, templateEngine // eventEmitter ); } // Usage const notificationService = createNotificationService( (process.env.NODE_ENV as any) || 'development' ); // Service works the same regardless of which email implementation is used await notificationService.sendWelcomeEmail('user@example.com', 'John Doe'); await notificationService.sendPasswordResetEmail('user@example.com', 'reset_token_123'); // === NestJS === import { Injectable, Inject, Module } from '@nestjs/common'; @Injectable() export class NotificationService { constructor( @Inject('EMAIL_SERVICE') private emailService: IEmailService, @Inject('LOGGER') private logger: ILogger, @Inject('TEMPLATE_ENGINE') private templateEngine: IEmailTemplateEngine ) {} // Same implementation } // Factory provider - decides which implementation to use const EMAIL_SERVICE_PROVIDER = { provide: 'EMAIL_SERVICE', useFactory: (logger: ILogger): IEmailService => { const env = process.env.NODE_ENV; if (env === 'production') { return new SendGridEmailService(process.env.SENDGRID_API_KEY || '', logger); } if (env === 'test') { return new ConsoleEmailService(); } return new SmtpEmailService({ host: process.env.SMTP_HOST || 'localhost', port: parseInt(process.env.SMTP_PORT || '587'), user: process.env.SMTP_USER || '', password: process.env.SMTP_PASS || '', secure: process.env.SMTP_SECURE === 'true' }, logger); }, inject: ['LOGGER'] }; const LOGGER_PROVIDER = { provide: 'LOGGER', useClass: Logger }; const TEMPLATE_ENGINE_PROVIDER = { provide: 'TEMPLATE_ENGINE', useClass: HandlebarsTemplateEngine }; @Module({ providers: [ LOGGER_PROVIDER, TEMPLATE_ENGINE_PROVIDER, EMAIL_SERVICE_PROVIDER, NotificationService ], exports: [NotificationService] }) export class NotificationModule {}

Checklist