DevDocsDev Docs
Design PatternsCreational Patterns

Singleton

Ensure a class has only one instance with a global access point

Singleton Pattern

Intent

Singleton is a creational design pattern that ensures a class has only one instance while providing a global access point to this instance.


Problem It Solves

The Singleton pattern solves two problems (violating Single Responsibility Principle):

1. Ensure Single Instance

Some resources should only have one instance:

  • Database connections
  • Configuration managers
  • Logging services
  • Thread pools

2. Global Access Point

Provide a way to access the instance from anywhere in the code.


Solution

All Singleton implementations share these steps:

  1. Private constructor - prevent direct instantiation
  2. Static creation method - acts as constructor, returns cached instance

Warning: Singleton is considered an anti-pattern by many developers because:

  • It introduces global state
  • Makes unit testing difficult
  • Violates Single Responsibility Principle
  • Hides dependencies

Prefer Dependency Injection when possible.


Implementation

// In TypeScript/JavaScript, modules are naturally singletons
// This is the simplest and most idiomatic approach

// logger.ts - The module itself is the singleton
interface LogEntry {
  timestamp: Date;
  level: "debug" | "info" | "warn" | "error";
  message: string;
  context?: Record<string, unknown>;
}

type LogLevel = "debug" | "info" | "warn" | "error";

const logLevels: Record<LogLevel, number> = {
  debug: 0,
  info: 1,
  warn: 2,
  error: 3,
};

// Private state (module-scoped)
let currentLevel: LogLevel = "info";
const logs: LogEntry[] = [];

// Private function
const shouldLog = (level: LogLevel): boolean => {
  return logLevels[level] >= logLevels[currentLevel];
};

const formatEntry = (entry: LogEntry): string => {
  const timestamp = entry.timestamp.toISOString();
  const context = entry.context ? ` ${JSON.stringify(entry.context)}` : "";
  return `[${timestamp}] [${entry.level.toUpperCase()}] ${entry.message}${context}`;
};

// Public API (exported)
export const logger = {
  setLevel(level: LogLevel) {
    currentLevel = level;
  },

  debug(message: string, context?: Record<string, unknown>) {
    if (shouldLog("debug")) {
      const entry: LogEntry = { timestamp: new Date(), level: "debug", message, context };
      logs.push(entry);
      console.debug(formatEntry(entry));
    }
  },

  info(message: string, context?: Record<string, unknown>) {
    if (shouldLog("info")) {
      const entry: LogEntry = { timestamp: new Date(), level: "info", message, context };
      logs.push(entry);
      console.info(formatEntry(entry));
    }
  },

  warn(message: string, context?: Record<string, unknown>) {
    if (shouldLog("warn")) {
      const entry: LogEntry = { timestamp: new Date(), level: "warn", message, context };
      logs.push(entry);
      console.warn(formatEntry(entry));
    }
  },

  error(message: string, context?: Record<string, unknown>) {
    if (shouldLog("error")) {
      const entry: LogEntry = { timestamp: new Date(), level: "error", message, context };
      logs.push(entry);
      console.error(formatEntry(entry));
    }
  },

  getLogs(): readonly LogEntry[] {
    return [...logs];
  },

  clear() {
    logs.length = 0;
  },
};

// Usage - import from anywhere, always same instance
// import { logger } from './logger';

logger.setLevel("debug");
logger.info("Application started", { version: "1.0.0" });
logger.debug("Debug information");
logger.error("Something went wrong", { code: "ERR_001" });

console.log("Log count:", logger.getLogs().length);
// Singleton with closure - more control over initialization

interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
  maxConnections: number;
}

interface DatabaseConnection {
  query: <T>(sql: string, params?: unknown[]) => Promise<T[]>;
  execute: (sql: string, params?: unknown[]) => Promise<void>;
  transaction: <T>(fn: () => Promise<T>) => Promise<T>;
  close: () => Promise<void>;
  isConnected: () => boolean;
  getStats: () => { activeConnections: number; totalQueries: number };
}

// Singleton factory with closure
const createDatabaseSingleton = () => {
  let instance: DatabaseConnection | null = null;
  let config: DatabaseConfig | null = null;
  let connected = false;
  let totalQueries = 0;

  const initialize = async (cfg: DatabaseConfig): Promise<DatabaseConnection> => {
    if (instance) {
      console.warn("Database already initialized, returning existing instance");
      return instance;
    }

    config = cfg;
    console.log(`Connecting to ${cfg.host}:${cfg.port}/${cfg.database}...`);
    
    // Simulate connection delay
    await new Promise(resolve => setTimeout(resolve, 100));
    connected = true;
    console.log("Database connected successfully");

    instance = {
      query: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {
        if (!connected) throw new Error("Database not connected");
        totalQueries++;
        console.log(`[Query #${totalQueries}] ${sql}`);
        return [] as T[];
      },

      execute: async (sql: string, params?: unknown[]) => {
        if (!connected) throw new Error("Database not connected");
        totalQueries++;
        console.log(`[Execute #${totalQueries}] ${sql}`);
      },

      transaction: async <T>(fn: () => Promise<T>): Promise<T> => {
        console.log("BEGIN TRANSACTION");
        try {
          const result = await fn();
          console.log("COMMIT");
          return result;
        } catch (error) {
          console.log("ROLLBACK");
          throw error;
        }
      },

      close: async () => {
        console.log("Closing database connection...");
        connected = false;
        instance = null;
      },

      isConnected: () => connected,

      getStats: () => ({
        activeConnections: connected ? 1 : 0,
        totalQueries,
      }),
    };

    return instance;
  };

  const getInstance = (): DatabaseConnection => {
    if (!instance) {
      throw new Error("Database not initialized. Call initialize() first.");
    }
    return instance;
  };

  return {
    initialize,
    getInstance,
    isInitialized: () => instance !== null,
  };
};

// Export singleton
const Database = createDatabaseSingleton();

// Usage
const main = async () => {
  // Initialize once at app start
  await Database.initialize({
    host: "localhost",
    port: 5432,
    database: "myapp",
    maxConnections: 10,
  });

  // Get instance anywhere in the app
  const db = Database.getInstance();
  
  await db.query("SELECT * FROM users");
  await db.execute("INSERT INTO logs (message) VALUES ($1)", ["User logged in"]);
  
  console.log("Stats:", db.getStats());
};

main();
/**
 * Cache entry with TTL support
 * @description Wraps cached values with expiration timestamp
 * @typeParam T - Type of the cached value
 */
interface CacheEntry<T> {
  /** The cached value */
  value: T;
  /** Unix timestamp when entry expires */
  expires: number;
}

/**
 * Cache interface for singleton cache manager
 * @description In-memory cache with TTL support and automatic cleanup
 */
interface Cache {
  /** Get a value by key, returns undefined if expired or missing */
  get: <T>(key: string) => T | undefined;
  /** Set a value with optional TTL in seconds (default: 1 hour) */
  set: <T>(key: string, value: T, ttlSeconds?: number) => void;
  /** Delete a specific key */
  delete: (key: string) => boolean;
  /** Clear all entries */
  clear: () => void;
  /** Check if key exists and is not expired */
  has: (key: string) => boolean;
  /** Get number of entries in cache */
  size: () => number;
  /** Remove expired entries, returns count removed */
  cleanup: () => number;
}

/**
 * Creates a lazy-initialized cache singleton
 * @description Cache is created on first getInstance() call
 * @returns Singleton accessor with getInstance method
 */
const createCacheSingleton = () => {
  let instance: Cache | null = null;

  const createCache = (): Cache => {
    const store = new Map<string, CacheEntry<unknown>>();
    let cleanupInterval: ReturnType<typeof setInterval> | null = null;

    const cache: Cache = {
      get<T>(key: string): T | undefined {
        const entry = store.get(key);
        if (!entry) return undefined;
        
        if (Date.now() > entry.expires) {
          store.delete(key);
          return undefined;
        }
        
        return entry.value as T;
      },

      set<T>(key: string, value: T, ttlSeconds = 3600) {
        store.set(key, {
          value,
          expires: Date.now() + ttlSeconds * 1000,
        });
      },

      delete(key: string): boolean {
        return store.delete(key);
      },

      clear() {
        store.clear();
      },

      has(key: string): boolean {
        const entry = store.get(key);
        if (!entry) return false;
        if (Date.now() > entry.expires) {
          store.delete(key);
          return false;
        }
        return true;
      },

      size(): number {
        return store.size;
      },

      cleanup(): number {
        const now = Date.now();
        let cleaned = 0;
        for (const [key, entry] of store) {
          if (now > entry.expires) {
            store.delete(key);
            cleaned++;
          }
        }
        return cleaned;
      },
    };

    // Start cleanup interval
    cleanupInterval = setInterval(() => {
      const cleaned = cache.cleanup();
      if (cleaned > 0) {
        console.log(`Cache cleanup: removed ${cleaned} expired entries`);
      }
    }, 60000); // Every minute

    return cache;
  };

  return {
    /** Get or create the singleton cache instance */
    getInstance(): Cache {
      // Lazy initialization - create on first access
      if (!instance) {
        console.log("Creating cache instance...");
        instance = createCache();
      }
      return instance;
    },
  };
};

// Export singleton accessor
const CacheManager = createCacheSingleton();

// Usage: Same instance everywhere
const cache = CacheManager.getInstance();

cache.set("user:123", { name: "John", email: "john@example.com" }, 300);
cache.set("settings", { theme: "dark" }, 3600);

const user = cache.get<{ name: string; email: string }>("user:123");
console.log(user);
//          ^?

console.log("Cache size:", cache.size());
console.log("Has user:123:", cache.has("user:123"));
// @noErrors
// BETTER APPROACH: Dependency Injection
// Instead of Singleton, create instances and inject them

interface Logger {
  info: (message: string) => void;
  error: (message: string) => void;
}

interface Database {
  query: <T>(sql: string) => Promise<T[]>;
}

interface Cache {
  get: <T>(key: string) => T | undefined;
  set: <T>(key: string, value: T) => void;
}

// Define dependencies container
interface AppDependencies {
  logger: Logger;
  database: Database;
  cache: Cache;
}

// Create dependencies once at app initialization
const createDependencies = (): AppDependencies => {
  // Create logger
  const logger: Logger = {
    info: (message) => console.log(`[INFO] ${message}`),
    error: (message) => console.error(`[ERROR] ${message}`),
  };

  // Create database
  const database: Database = {
    query: async (sql: string) => {
      logger.info(`Executing query: ${sql}`);
      return [];
    },
  };

  // Create cache
  const cacheStore = new Map<string, unknown>();
  const cache: Cache = {
    get: <T>(key: string) => cacheStore.get(key) as T | undefined,
    set: <T>(key: string, value: T) => {
      cacheStore.set(key, value);
    },
  };

  return { logger, database, cache };
};

// Services receive dependencies via parameters (Dependency Injection)
const createUserService = (deps: Pick<AppDependencies, "logger" | "database" | "cache">) => {
  const { logger, database, cache } = deps;

  return {
    async getUser(id: string) {
      // Try cache first
      const cached = cache.get<{ id: string; name: string }>(`user:${id}`);
      if (cached) {
        logger.info(`Cache hit for user ${id}`);
        return cached;
      }

      // Fetch from database
      logger.info(`Fetching user ${id} from database`);
      const [user] = await database.query<{ id: string; name: string }>(
        `SELECT * FROM users WHERE id = '${id}'`
      );

      // Cache the result
      if (user) {
        cache.set(`user:${id}`, user);
      }

      return user;
    },

    async createUser(name: string, email: string) {
      logger.info(`Creating user: ${name}`);
      await database.query(`INSERT INTO users (name, email) VALUES ('${name}', '${email}')`);
    },
  };
};

const createOrderService = (deps: Pick<AppDependencies, "logger" | "database">) => {
  const { logger, database } = deps;

  return {
    async createOrder(userId: string, items: string[]) {
      logger.info(`Creating order for user ${userId}`);
      await database.query(`INSERT INTO orders (user_id, items) VALUES ('${userId}', '${items}')`);
    },
  };
};

// Application setup
const initializeApp = () => {
  // Create dependencies once
  const deps = createDependencies();

  // Create services with injected dependencies
  const userService = createUserService(deps);
  const orderService = createOrderService(deps);

  return {
    userService,
    orderService,
    // Expose logger for global use if needed
    logger: deps.logger,
  };
};

// Benefits of Dependency Injection:
// 1. Easy to test - just pass mock dependencies
const createTestDependencies = (): AppDependencies => ({
  logger: { info: () => {}, error: () => {} },
  database: { query: async () => [{ id: "1", name: "Test User" }] },
  cache: { 
    get: () => undefined, 
    set: () => {} 
  },
});

const testUserService = createUserService(createTestDependencies());

// 2. Dependencies are explicit - no hidden global state
// 3. Easy to swap implementations
// 4. Better separation of concerns

When to Use


Singleton vs Dependency Injection

AspectSingletonDependency Injection
TestabilityHard to mockEasy to mock
DependenciesHiddenExplicit
CouplingHigh (global)Low (injected)
FlexibilityFixed instanceSwappable
Thread safetyMust handleContainer handles

Real-World Applications

Use CasePatternBetter Alternative
LoggingSingletonDI with single instance
ConfigurationModule exportEnvironment/config loading
Database PoolSingletonDI with connection factory
CacheSingletonDI with cache service
Event BusSingletonDI with event emitter

Summary

Key Takeaway: While Singleton ensures a single instance, it introduces global state and testing difficulties. In modern TypeScript, prefer module-level singletons or Dependency Injection. Use traditional Singleton only when absolutely necessary.

Pros

  • ✅ Ensures single instance
  • ✅ Global access point
  • ✅ Lazy initialization possible
  • ✅ Module pattern is natural in JS/TS

Cons

  • ❌ Violates Single Responsibility (creation + business logic)
  • ❌ Makes unit testing difficult
  • ❌ Hides dependencies
  • ❌ Global state can cause issues
  • ❌ Tight coupling throughout codebase

On this page