Netspective Logo

Design Patterns

Common design patterns for building maintainable, scalable software

Design patterns are reusable solutions to common problems in software design. They represent best practices evolved over time and provide a shared vocabulary for discussing design decisions.

Pattern Categories

Design Pattern Categories


Creational Patterns

Factory Pattern

Create objects without specifying the exact class:

// Abstract factory for database connections
interface DatabaseConnection {
  connect(): Promise<void>;
  query(sql: string): Promise<any>;
  disconnect(): Promise<void>;
}

class PostgresConnection implements DatabaseConnection {
  async connect() { /* ... */ }
  async query(sql: string) { /* ... */ }
  async disconnect() { /* ... */ }
}

class MySQLConnection implements DatabaseConnection {
  async connect() { /* ... */ }
  async query(sql: string) { /* ... */ }
  async disconnect() { /* ... */ }
}

// Factory
class DatabaseFactory {
  static create(type: 'postgres' | 'mysql'): DatabaseConnection {
    switch (type) {
      case 'postgres':
        return new PostgresConnection();
      case 'mysql':
        return new MySQLConnection();
      default:
        throw new Error(`Unknown database type: ${type}`);
    }
  }
}

// Usage
const db = DatabaseFactory.create('postgres');
await db.connect();

When to use:

  • Object creation logic should be separate from business logic
  • System needs to be independent of how objects are created
  • Multiple implementations of an interface exist

Builder Pattern

Construct complex objects step by step:

class QueryBuilder {
  private query: string = '';
  private params: any[] = [];

  select(columns: string[]): this {
    this.query = `SELECT ${columns.join(', ')}`;
    return this;
  }

  from(table: string): this {
    this.query += ` FROM ${table}`;
    return this;
  }

  where(condition: string, value: any): this {
    this.query += ` WHERE ${condition}`;
    this.params.push(value);
    return this;
  }

  orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.query += ` ORDER BY ${column} ${direction}`;
    return this;
  }

  build(): { query: string; params: any[] } {
    return { query: this.query, params: this.params };
  }
}

// Usage
const { query, params } = new QueryBuilder()
  .select(['id', 'name', 'email'])
  .from('users')
  .where('status = $1', 'active')
  .orderBy('created_at', 'DESC')
  .build();

When to use:

  • Object construction requires many steps
  • Need to create different representations of an object
  • Want to make construction code more readable

Singleton Pattern

Ensure a class has only one instance:

class ConfigurationManager {
  private static instance: ConfigurationManager;
  private config: Map<string, any> = new Map();

  private constructor() {
    // Private constructor prevents direct instantiation
  }

  static getInstance(): ConfigurationManager {
    if (!ConfigurationManager.instance) {
      ConfigurationManager.instance = new ConfigurationManager();
    }
    return ConfigurationManager.instance;
  }

  get(key: string): any {
    return this.config.get(key);
  }

  set(key: string, value: any): void {
    this.config.set(key, value);
  }
}

// Usage
const config = ConfigurationManager.getInstance();
config.set('apiUrl', 'https://api.example.com');

Caution: Singletons can make testing difficult. Consider dependency injection instead.


Structural Patterns

Adapter Pattern

Convert an interface to one clients expect:

// Legacy payment system
class LegacyPaymentProcessor {
  processPayment(amount: number, cardNumber: string): boolean {
    // Legacy implementation
    return true;
  }
}

// New interface
interface PaymentGateway {
  charge(payment: {
    amount: number;
    currency: string;
    card: { number: string; expiry: string; cvv: string };
  }): Promise<{ success: boolean; transactionId: string }>;
}

// Adapter
class LegacyPaymentAdapter implements PaymentGateway {
  private legacy: LegacyPaymentProcessor;

  constructor(legacy: LegacyPaymentProcessor) {
    this.legacy = legacy;
  }

  async charge(payment: {
    amount: number;
    currency: string;
    card: { number: string; expiry: string; cvv: string };
  }): Promise<{ success: boolean; transactionId: string }> {
    const success = this.legacy.processPayment(
      payment.amount,
      payment.card.number
    );
    return {
      success,
      transactionId: success ? `legacy-${Date.now()}` : '',
    };
  }
}

When to use:

  • Integrating with legacy or third-party code
  • Creating reusable classes that work with unrelated interfaces
  • Using existing classes with incompatible interfaces

Decorator Pattern

Add responsibilities to objects dynamically:

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}

// Decorator base
abstract class LoggerDecorator implements Logger {
  protected logger: Logger;

  constructor(logger: Logger) {
    this.logger = logger;
  }

  abstract log(message: string): void;
}

// Timestamp decorator
class TimestampLogger extends LoggerDecorator {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.logger.log(`[${timestamp}] ${message}`);
  }
}

// JSON decorator
class JsonLogger extends LoggerDecorator {
  log(message: string): void {
    this.logger.log(JSON.stringify({ message, timestamp: Date.now() }));
  }
}

// Usage - decorators can be stacked
let logger: Logger = new ConsoleLogger();
logger = new TimestampLogger(logger);
logger.log('User logged in'); // [2024-01-15T10:30:00.000Z] User logged in

When to use:

  • Add responsibilities without modifying existing code
  • Responsibilities can be added or removed at runtime
  • Extension by subclassing is impractical

Facade Pattern

Provide a simplified interface to a complex subsystem:

// Complex subsystems
class UserService {
  async getUser(id: string) { /* ... */ }
}

class OrderService {
  async getOrders(userId: string) { /* ... */ }
}

class NotificationService {
  async send(userId: string, message: string) { /* ... */ }
}

class PaymentService {
  async getBalance(userId: string) { /* ... */ }
}

// Facade
class CustomerDashboardFacade {
  private userService: UserService;
  private orderService: OrderService;
  private notificationService: NotificationService;
  private paymentService: PaymentService;

  constructor() {
    this.userService = new UserService();
    this.orderService = new OrderService();
    this.notificationService = new NotificationService();
    this.paymentService = new PaymentService();
  }

  async getDashboardData(userId: string) {
    const [user, orders, balance] = await Promise.all([
      this.userService.getUser(userId),
      this.orderService.getOrders(userId),
      this.paymentService.getBalance(userId),
    ]);

    return { user, orders, balance };
  }
}

When to use:

  • Simplify access to complex subsystems
  • Reduce dependencies between clients and subsystems
  • Layer your subsystems

Behavioral Patterns

Strategy Pattern

Define a family of interchangeable algorithms:

interface ValidationStrategy {
  validate(value: string): boolean;
}

class EmailValidation implements ValidationStrategy {
  validate(value: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  }
}

class PhoneValidation implements ValidationStrategy {
  validate(value: string): boolean {
    return /^\+?[\d\s-]{10,}$/.test(value);
  }
}

class ZipCodeValidation implements ValidationStrategy {
  validate(value: string): boolean {
    return /^\d{5}(-\d{4})?$/.test(value);
  }
}

// Context
class FormValidator {
  private strategy: ValidationStrategy;

  setStrategy(strategy: ValidationStrategy): void {
    this.strategy = strategy;
  }

  validate(value: string): boolean {
    return this.strategy.validate(value);
  }
}

// Usage
const validator = new FormValidator();
validator.setStrategy(new EmailValidation());
console.log(validator.validate('test@example.com')); // true

When to use:

  • Many related classes differ only in behavior
  • Need different variants of an algorithm
  • Want to avoid exposing complex algorithm structures

Observer Pattern

Define a subscription mechanism for state changes:

interface Observer {
  update(data: any): void;
}

interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void;
}

class EventEmitter implements Subject {
  private observers: Map<string, Observer[]> = new Map();
  private state: any;

  attach(observer: Observer, event: string = 'default'): void {
    if (!this.observers.has(event)) {
      this.observers.set(event, []);
    }
    this.observers.get(event)!.push(observer);
  }

  detach(observer: Observer, event: string = 'default'): void {
    const observers = this.observers.get(event);
    if (observers) {
      const index = observers.indexOf(observer);
      if (index > -1) {
        observers.splice(index, 1);
      }
    }
  }

  notify(event: string = 'default'): void {
    const observers = this.observers.get(event);
    if (observers) {
      observers.forEach(observer => observer.update(this.state));
    }
  }

  setState(state: any, event: string = 'default'): void {
    this.state = state;
    this.notify(event);
  }
}

When to use:

  • Changes in one object require changing others
  • An object should notify others without knowing who they are
  • Implementing event handling systems

Repository Pattern

Abstract data access behind a collection-like interface:

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(entity: Omit<T, 'id'>): Promise<T>;
  update(id: string, entity: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

interface User {
  id: string;
  email: string;
  name: string;
}

class UserRepository implements Repository<User> {
  private db: Database;

  constructor(db: Database) {
    this.db = db;
  }

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

  async findAll(): Promise<User[]> {
    return this.db.query('SELECT * FROM users');
  }

  async create(user: Omit<User, 'id'>): Promise<User> {
    const result = await this.db.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
      [user.email, user.name]
    );
    return result;
  }

  async update(id: string, user: Partial<User>): Promise<User> {
    // Implementation
  }

  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM users WHERE id = $1', [id]);
  }
}

When to use:

  • Decouple business logic from data access
  • Enable unit testing with mock repositories
  • Support multiple data sources

Architecture Patterns

Layered Architecture

Layered Architecture

Hexagonal Architecture (Ports & Adapters)

Hexagonal Architecture


Pattern Selection Guide

ScenarioRecommended Pattern
Creating objects without specifying classFactory
Complex object constructionBuilder
Single shared instanceSingleton (use sparingly)
Incompatible interfacesAdapter
Add behavior dynamicallyDecorator
Simplify complex subsystemFacade
Interchangeable algorithmsStrategy
Event notificationObserver
Data access abstractionRepository

Best Practices

Do

  • Use patterns to solve specific problems
  • Document pattern usage in ADRs
  • Start simple, add patterns when needed
  • Ensure team understands patterns used
  • Consider testability when choosing patterns

Don't

  • Force patterns where they don't fit
  • Over-engineer with unnecessary patterns
  • Use patterns just because they're "best practice"
  • Ignore simpler solutions
  • Create unnecessary abstractions


Compliance

This section fulfills ISO 13485 requirements for design outputs (7.3.4) and control of production (7.5.1), and ISO 27001 requirements for secure architecture (A.8.27) and secure development lifecycle (A.8.25).

View full compliance matrix

How is this guide?

Last updated on

On this page