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.
- S: Single Responsibility
- O: Open/Closed
- L: Liskov Substitution
- I: Interface Segregation
- D: Dependency Inversion
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
- Does each class have exactly one reason to change (SRP)?
- Can I add features by adding code, not modifying stable code (OCP)?
- Do all implementations truly substitute the interface (LSP)?
- Are interfaces small and client‑specific (ISP)?
- Do high‑level modules depend on abstractions (DIP)?