jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Design Patterns in TypeScript · Part 1 — Singleton & the Module Pattern

When (and when not) to share a single instance: the Singleton pattern, why ESM modules are already singletons, lazy init, the testability trap, and typed implementations — with exercises.

Đây là Phần 1 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần. Pattern không phải kiến thức hàn lâm để khoe: chúng là từ vựng chung cho những vấn đề bạn vẫn giải mỗi ngày.

Ta bắt đầu với pattern nổi tiếng nhất — và bị lạm dụng nhất: Singleton.


Ý đồ

Singleton đảm bảo một class có đúng một thực thể và cung cấp điểm truy cập toàn cục tới nó. Bạn dùng nó khi một thứ dùng chung phải đứng sau cả app: một object config, một logger, một connection pool DB, một cache in-memory, một client feature-flag.

Module A Module B Module C Singleton one shared instance · getInstance()
Many modules ask for the instance; all of them receive the exact same object

Hiểm họa cũng nằm ngay trong định nghĩa đó: ""điểm truy cập toàn cục” chỉ là “biến toàn cục” mặc vest. Ta sẽ dùng nó có chủ đích, không phải theo phản xạ.


Biến global ngây thơ vs Singleton

Bản ngây thơ là một biến global ở module có thể ghi đè:

// ❌ anyone can reassign it, no guarantees, no lazy init
export let config = { apiUrl: '' };

Việc của Singleton là biến “đúng một, tạo một lần” thành đảm bảo, không phải quy ước.


Singleton dạng class kinh điển

Dạng sách giáo khoa: constructor private (không ai new được) và một accessor static tạo thực thể duy nhất một cách lười:

class AppConfig {
  // The single instance, created on first access.
  static #instance: AppConfig | null = null;

  readonly apiUrl: string;
  readonly env: 'dev' | 'prod';

  // Private: callers cannot do `new AppConfig()`.
  private constructor() {
    this.apiUrl = process.env.API_URL ?? 'http://localhost:3000';
    this.env = process.env.NODE_ENV === 'production' ? 'prod' : 'dev';
  }

  static get(): AppConfig {
    // Lazy init: build it once, reuse forever.
    this.#instance ??= new AppConfig();
    return this.#instance;
  }
}

const a = AppConfig.get();
const b = AppConfig.get();
console.log(a === b); // true — same instance

Các mảnh ghép then chốt: trường static private #instance giữ bản duy nhất; constructor private chặn new; và ??= cho khởi tạo lười — không tạo gì cho tới lần dùng đầu.


Module vốn đã là singleton

Đây là phần đa số tutorial bỏ qua: trong ESM (và CommonJS), một module được đánh giá một lần và export của nó được cache. Mọi lần import cùng một module nhận cùng binding. Vì vậy singleton idiomatic trong TypeScript thường chỉ là một module:

// config.ts — evaluated once, shared everywhere it's imported
function createConfig() {
  return {
    apiUrl: process.env.API_URL ?? 'http://localhost:3000',
    env: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
  } as const;
}

export const config = createConfig();
// a.ts and b.ts both:
import { config } from './config';
// → identical object, created exactly once

Cách này đơn giản hơn dạng class, không cần nghi thức getInstance(), và vẫn được hệ module đảm bảo như nhau. Hãy ưu tiên nó cho giá trị dùng chung read-only hoặc không trạng thái.

Chỉ dùng dạng class khi bạn cần khởi tạo thật sự lười (đắt để dựng, có thể không bao giờ dùng) hoặc một vòng đời rõ ràng (connect() / dispose()).


Use case web thực tế

  • một nguồn sự thật, đọc khắp nơi.
  • một instance đã cấu hình, dùng chung transport.
  • một wrapper fetch duy nhất giữ base URL, interceptor auth, chính sách retry.
  • một cache in-memory để hai component không fetch trùng.
  • bạn muốn một pool, không phải mỗi request một cái.

Bẫy khó test

Singleton là trạng thái toàn cục ẩn, và trạng thái toàn cục là kẻ thù của test. Hai vấn đề xuất hiện: trạng thái rò rỉ giữa các test; và bạn không thể thay một bản giả khi unit test.

Cách sửa là dependency injection: phụ thuộc vào kiểu, truyền instance vào. Giữ singleton ở composition root, nhưng để code cần test nhận nó qua tham số (ta dành hẳn Phần 10 cho việc này):

interface Clock {
  now(): number;
}

// The real singleton lives at the edge of the app...
export const systemClock: Clock = { now: () => Date.now() };

// ...but logic depends on the interface, so tests pass a fake.
export function isExpired(token: { exp: number }, clock: Clock): boolean {
  return token.exp < clock.now();
}

// test: isExpired(t, { now: () => 1_000 }) — no global, fully deterministic

Anti-pattern cần tránh: dùng singleton làm bãi rác cho state global không liên quan; singleton có thể ghi, dùng chung giữa các request trên server (dữ liệu user này rò sang user khác); và “singleton ở mọi nơi” thay vì truyền dependency.


Bảng tra nhanh

// Module singleton — preferred for shared read-only values
export const config = createConfig();

// Class singleton — when you need lazy build or a lifecycle
class Pool {
  static #i: Pool | null = null;
  private constructor() {}
  static get() { return (this.#i ??= new Pool()); }
}

// Testable: depend on an interface, inject the instance
function useIt(dep: Service) { /* ... */ }

Quyết định: không trạng thái & dùng chung → module; lười/vòng đời → class; cần test → inject interface.


Bài tập / Exercises

1. Cài một singleton Logger (dạng class) có level và phương thức log(msg), và chứng minh Logger.get() === Logger.get().

Lời giải
class Logger {
  static #instance: Logger | null = null;
  level: 'debug' | 'info' = 'info';
  private constructor() {}
  static get(): Logger {
    return (this.#instance ??= new Logger());
  }
  log(msg: string): void {
    console.log(`[${this.level}] ${msg}`);
  }
}

console.log(Logger.get() === Logger.get()); // true

2. Viết lại logger đó thành module singleton (không class). Bản nào ngắn hơn, và bạn đánh đổi điều gì?

Lời giải
// logger.ts
const state = { level: 'info' as 'debug' | 'info' };
export const logger = {
  setLevel(l: 'debug' | 'info') { state.level = l; },
  log(msg: string) { console.log(`[${state.level}] ${msg}`); },
};

Ngắn hơn, không nghi thức getInstance(). Bạn đánh đổi khởi tạo thật lười — module chạy code top-level ngay lần import đầu.

3. Hàm getUserAge(birthYear) đọc thẳng new Date().getFullYear(), nên test của nó hỏng mỗi mùng 1 tháng 1. Refactor để nó tất định bằng dependency injection.

Lời giải
interface Clock { now(): Date; }
export const systemClock: Clock = { now: () => new Date() };

export function getUserAge(birthYear: number, clock: Clock = systemClock): number {
  return clock.now().getFullYear() - birthYear;
}

// test — fully deterministic, no real date:
getUserAge(1990, { now: () => new Date('2025-06-01') }); // 35

Nâng cao:tạo một Counter module singleton, import từ hai file, tăng ở file này và đọc ở file kia — xác nhận chúng dùng chung state. Rồi giải thích vì sao điều này nguy hiểm trên server xử lý request đồng thời.


Điểm chính

  • Singleton = một instance + truy cập toàn cục; mạnh nhưng dễ lạm dụng.
  • Trong TS/ESM, module vốn đã là singleton — ưu tiên nó cho giá trị dùng chung read-only.
  • Dùng dạng class chỉ cho khởi tạo lười hoặc vòng đời rõ ràng.
  • Singleton là state toàn cục ẩn — inject interface để code vẫn test được.

Tiếp theo

Phần 2 — Factory & Abstract Factory: ngừng rải newswitch khắp code; gom việc tạo object sau các factory function có kiểu và discriminated union.