TechLead
Lesson 18 of 27
7 min read
Software Architecture

Creational Design Patterns

Master Factory, Builder, Singleton, and Prototype patterns with practical TypeScript implementations

Creational Design Patterns

Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. They abstract the instantiation process and help make a system independent of how its objects are created, composed, and represented.

Factory Method Pattern

The Factory Method defines an interface for creating an object but lets subclasses decide which class to instantiate. It provides a way to delegate instantiation logic to child classes.

// Factory Method: Create different notification channels
interface Notification {
  send(to: string, message: string): Promise;
}

class EmailNotification implements Notification {
  constructor(private readonly smtpClient: SmtpClient) {}
  async send(to: string, message: string): Promise {
    await this.smtpClient.send({ to, subject: "Notification", body: message });
  }
}

class SmsNotification implements Notification {
  constructor(private readonly smsClient: SmsClient) {}
  async send(to: string, message: string): Promise {
    await this.smsClient.send({ phoneNumber: to, text: message });
  }
}

class PushNotification implements Notification {
  constructor(private readonly pushClient: PushClient) {}
  async send(to: string, message: string): Promise {
    await this.pushClient.send({ deviceToken: to, body: message });
  }
}

// Factory
class NotificationFactory {
  static create(channel: "email" | "sms" | "push"): Notification {
    switch (channel) {
      case "email": return new EmailNotification(new SmtpClient());
      case "sms": return new SmsNotification(new SmsClient());
      case "push": return new PushNotification(new PushClient());
      default: throw new Error(`Unknown channel: ${channel}`);
    }
  }
}

// Usage
const notification = NotificationFactory.create("email");
await notification.send("user@example.com", "Your order has shipped!");

Abstract Factory Pattern

// Abstract Factory: Create families of related objects
interface UIFactory {
  createButton(): Button;
  createInput(): Input;
  createModal(): Modal;
}

interface Button {
  render(): string;
  onClick(handler: () => void): void;
}

interface Input {
  render(): string;
  getValue(): string;
}

// Material Design family
class MaterialUIFactory implements UIFactory {
  createButton(): Button { return new MaterialButton(); }
  createInput(): Input { return new MaterialInput(); }
  createModal(): Modal { return new MaterialModal(); }
}

// Ant Design family
class AntUIFactory implements UIFactory {
  createButton(): Button { return new AntButton(); }
  createInput(): Input { return new AntInput(); }
  createModal(): Modal { return new AntModal(); }
}

// The app works with any UI family
class FormRenderer {
  constructor(private readonly factory: UIFactory) {}

  renderLoginForm(): string {
    const emailInput = this.factory.createInput();
    const passwordInput = this.factory.createInput();
    const submitButton = this.factory.createButton();
    return `${emailInput.render()}${passwordInput.render()}${submitButton.render()}`;
  }
}

// Switch entire UI framework by changing the factory
const materialForm = new FormRenderer(new MaterialUIFactory());
const antForm = new FormRenderer(new AntUIFactory());

Builder Pattern

The Builder pattern separates the construction of a complex object from its representation. It is especially useful when an object has many optional parameters or when the construction process involves multiple steps.

// Builder: Construct complex query objects
class QueryBuilder {
  private _table: string = "";
  private _selects: string[] = [];
  private _wheres: Array<{ column: string; operator: string; value: unknown }> = [];
  private _orderBy: Array<{ column: string; direction: "ASC" | "DESC" }> = [];
  private _limit?: number;
  private _offset?: number;
  private _joins: string[] = [];

  from(table: string): this {
    this._table = table;
    return this;
  }

  select(...columns: string[]): this {
    this._selects.push(...columns);
    return this;
  }

  where(column: string, operator: string, value: unknown): this {
    this._wheres.push({ column, operator, value });
    return this;
  }

  orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
    this._orderBy.push({ column, direction });
    return this;
  }

  limit(count: number): this {
    this._limit = count;
    return this;
  }

  offset(count: number): this {
    this._offset = count;
    return this;
  }

  join(table: string, on: string): this {
    this._joins.push(`JOIN ${table} ON ${on}`);
    return this;
  }

  build(): { sql: string; params: unknown[] } {
    const params: unknown[] = [];
    let sql = `SELECT ${this._selects.length ? this._selects.join(", ") : "*"} FROM ${this._table}`;

    if (this._joins.length) {
      sql += " " + this._joins.join(" ");
    }

    if (this._wheres.length) {
      const conditions = this._wheres.map((w, i) => {
        params.push(w.value);
        return `${w.column} ${w.operator} $${i + 1}`;
      });
      sql += " WHERE " + conditions.join(" AND ");
    }

    if (this._orderBy.length) {
      sql += " ORDER BY " + this._orderBy.map(o => `${o.column} ${o.direction}`).join(", ");
    }

    if (this._limit) sql += ` LIMIT ${this._limit}`;
    if (this._offset) sql += ` OFFSET ${this._offset}`;

    return { sql, params };
  }
}

// Fluent API usage
const query = new QueryBuilder()
  .from("orders")
  .select("id", "customer_id", "total", "status")
  .where("status", "=", "active")
  .where("total", ">", 100)
  .orderBy("created_at", "DESC")
  .limit(20)
  .offset(40)
  .build();

// Also great for configuration objects
class ServerConfigBuilder {
  private config: Partial = {};

  port(port: number): this { this.config.port = port; return this; }
  host(host: string): this { this.config.host = host; return this; }
  cors(origins: string[]): this { this.config.corsOrigins = origins; return this; }
  rateLimit(max: number, windowMs: number): this {
    this.config.rateLimit = { max, windowMs };
    return this;
  }
  tls(cert: string, key: string): this {
    this.config.tls = { cert, key };
    return this;
  }

  build(): ServerConfig {
    if (!this.config.port) throw new Error("Port is required");
    return this.config as ServerConfig;
  }
}

const config = new ServerConfigBuilder()
  .port(3000)
  .host("0.0.0.0")
  .cors(["https://example.com"])
  .rateLimit(100, 60000)
  .build();

Singleton Pattern

// Singleton: Ensure only one instance exists
class DatabasePool {
  private static instance: DatabasePool | null = null;
  private pool: Pool;

  private constructor(connectionString: string) {
    this.pool = new Pool({ connectionString, max: 20 });
  }

  static getInstance(): DatabasePool {
    if (!DatabasePool.instance) {
      DatabasePool.instance = new DatabasePool(process.env.DATABASE_URL!);
    }
    return DatabasePool.instance;
  }

  async query(sql: string, params?: unknown[]): Promise {
    const result = await this.pool.query(sql, params);
    return result.rows;
  }

  // For testing: allow resetting the singleton
  static resetInstance(): void {
    DatabasePool.instance = null;
  }
}

// Modern alternative: Module-scoped singleton (preferred in TypeScript)
// database.ts
let pool: Pool | null = null;

export function getPool(): Pool {
  if (!pool) {
    pool = new Pool({ connectionString: process.env.DATABASE_URL!, max: 20 });
  }
  return pool;
}

// ES modules are evaluated once — this is a natural singleton

Prototype Pattern

// Prototype: Clone objects to avoid expensive creation
interface Cloneable {
  clone(): T;
}

class DocumentTemplate implements Cloneable {
  constructor(
    public title: string,
    public content: string,
    public styles: Record,
    public metadata: Record
  ) {}

  clone(): DocumentTemplate {
    return new DocumentTemplate(
      this.title,
      this.content,
      { ...this.styles },           // Shallow copy
      JSON.parse(JSON.stringify(this.metadata)) // Deep copy
    );
  }
}

// Template registry
class TemplateRegistry {
  private templates = new Map();

  register(name: string, template: DocumentTemplate): void {
    this.templates.set(name, template);
  }

  create(templateName: string): DocumentTemplate {
    const template = this.templates.get(templateName);
    if (!template) throw new Error(`Template not found: ${templateName}`);
    return template.clone(); // Clone instead of creating from scratch
  }
}

const registry = new TemplateRegistry();
registry.register("invoice", new DocumentTemplate(
  "Invoice", "

Invoice

", { fontSize: "12px" }, { version: 1 } )); const myInvoice = registry.create("invoice"); myInvoice.title = "Invoice #1234"; // Modify the clone freely

When to Use Each Pattern

  • Factory: When the exact type of object to create is determined at runtime
  • Abstract Factory: When you need to create families of related objects consistently
  • Builder: When constructing complex objects with many optional parameters
  • Singleton: When exactly one instance is needed (database pools, loggers, config)
  • Prototype: When creating objects by cloning is more efficient than constructing from scratch

Choosing Between Patterns

Creational patterns solve the same fundamental problem — how to create objects — but each fits different constraints. Use this decision framework to pick the right one:

  • Start with the simplest thing: a plain constructor call. Only add a pattern when you feel the pain it solves — premature abstraction is a real cost.
  • Factory Method when you have a base class that can't know which concrete class to instantiate, but subclasses can decide. Common in frameworks and plugin systems.
  • Abstract Factory when your code needs to work with families of related products — UI toolkits, database drivers, cloud providers — and you need to swap the entire family at once.
  • Builder when construction of an object involves more than 3-4 parameters, especially optional ones. Telescoping constructors and long parameter lists are the code smell that points here.
  • Singleton for shared infrastructure resources: loggers, connection pools, config. In modern TypeScript, prefer module-level singletons over class singletons — ES modules are evaluated once naturally.
  • Prototype when initializing a new object is expensive and you frequently need copies with small variations — document editors, game objects, template systems.

⚠️ Common Mistakes

  • Over-engineering: Applying Factory when a constructor is sufficient adds indirection with no benefit.
  • Singleton abuse: Global singletons make testing hard and introduce hidden dependencies — pass dependencies explicitly when possible.
  • Shallow clones: Prototype pattern must deep-clone nested objects, not just copy references, or clones will share mutable state.

Continue Learning