Back to all articles
Design PatternsCreational PatternsSystem DesignOOPEngineeringLLDSingleton Design PatternConfiguration Manager

System Design Question — Configuration Manager (Singleton) — Solution

Solution: Implement a Singleton Configuration Manager in TypeScript — problem statement, implementation, usage, and notes.

System Design Question — Configuration Manager (Singleton) — Solution

🧱 Configuration Manager — Singleton (System Design Question — Solution)

This document is a solution write-up for the system design question: implement a Configuration Manager where only one instance manages application settings. It presents the problem, a practical TypeScript implementation using the Singleton pattern, usage examples, and notes on production considerations.


Problem — Configuration Manager (Singleton use-case)

Many applications need a central place to hold configuration settings: theme, feature flags, connection strings, and user preferences. The requirements for a robust configuration manager are:

  • Only a single instance must exist during the application lifecycle.
  • It should provide methods to:
    • Load configuration from a file or source
    • Get configuration values by key
    • Update configuration values in memory
    • Save configuration changes back to the source
  • All parts of the app should access the same configuration instance so state is consistent across modules.

Why does this map well to the Singleton pattern?

  • Configuration is global-ish state — multiple instances would allow divergence.
  • A single instance simplifies caching, write coordination, and lifecycle management.
  • It gives a convenient global access point without relying on global variables everywhere.

Example Inputs (for the tutorial)

A simple key:value file settings.txt that looks like:

THEME: DARK
BRIGHTNESS: 64%
VOLUME: 90%
HOTSPOT: OFF
WIFI: ON

A small TypeScript type to hold settings:

export type TSettings = { [key: string]: string };

(You can find these in the example settings.txt and utils.ts in the exercise folder.)


Solution — TypeScript Singleton Configuration Manager

We'll implement a class ConfigManager with the following contract:

  • Inputs: path to a configuration file (string) when calling load or init.
  • Outputs: methods to get/set values, and to persist changes.
  • Error modes: file-not-found, parse errors, write failures — all surfaced via thrown errors or returned promises.

Key Design Choices

  • Private constructor and a static getInstance() to enforce single instantiation.
  • Lazily load configuration (explicit load() call) to keep control over I/O.
  • Use fs.promises for asynchronous file operations.
  • Use a simple key:value parsing for the example; swap in JSON or YAML for production.

🧩 Implementation (Concise, Annotated)

// ConfigManager.ts
import path from "path";
import { promises as fs } from "fs";
import type { TSettings } from "./utils";

export class ConfigManager {
  private settings: TSettings = {};
  private sourcePath = "";
  private static instance: ConfigManager | null = null;

  // Private ctor prevents `new ConfigManager()` from outside
  private constructor() {}

  // Global access point
  public static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager();
    }
    return ConfigManager.instance;
  }

  // Load configuration from a simple key:value file (or switch to JSON/YAML)
  public async load(filePath: string): Promise<void> {
    this.sourcePath = path.resolve(filePath);
    const raw = await fs.readFile(this.sourcePath, "utf8");
    this.settings = this.parseKeyValue(raw);
  }

  public get(key: string, fallback?: string): string | undefined {
    return this.settings[key] ?? fallback;
  }

  public getAll(): TSettings {
    // return a shallow copy to avoid accidental external mutation
    return { ...this.settings };
  }

  public set(key: string, value: string): void {
    this.settings[key] = value;
  }

  public async save(): Promise<void> {
    if (!this.sourcePath) throw new Error("No source path configured. Call load(path) first.");
    const data = Object.entries(this.settings)
      .map(([k, v]) => `${k}: ${v}`)
      .join("\n");
    await fs.writeFile(this.sourcePath, data, "utf8");
  }

  private parseKeyValue(raw: string): TSettings {
    const out: TSettings = {};
    raw.split(/\r?\n/).forEach((line) => {
      const trimmed = line.trim();
      if (!trimmed) return;
      const parts = trimmed.split(/:\s*/);
      if (parts.length >= 2) {
        const key = parts.shift()!.trim();
        const value = parts.join(":").trim();
        out[key] = value;
      }
    });
    return out;
  }
}

🧠 Usage Example

import { ConfigManager } from "./ConfigManager";

async function main() {
  const cfg = ConfigManager.getInstance();
  await cfg.load("./settings.txt");

  console.log("Theme:", cfg.get("THEME")); // DARK

  cfg.set("BRIGHTNESS", "70%");
  console.log("Brightness (mem):", cfg.get("BRIGHTNESS"));

  await cfg.save(); // writes change back to settings.txt
}

main().catch(console.error);

Notes, Edge-Cases & Alternatives

  • Thread-safety: Node/JS uses a single-threaded event loop, so classic locking is rarely needed.
    If your app uses worker threads or multiple processes, coordinate access (e.g., via IPC, a central service, locks, or a database).
  • Initialization order: Prefer explicit await cfg.load(path) early in app startup.
    Avoid lazy implicit loads sprinkled across modules.
  • Serialization format: For complex settings prefer JSON, YAML, or TOML rather than ad-hoc key:value lines.
  • Testing: Because access is global, tests should reset the singleton state between tests.
    You can add a non-exported reset method behind a test-only flag, or design ConfigManager to accept an in-memory source for tests.
  • Persistence and atomicity: When saving, consider writing to a temp file then renaming for atomic updates.

✅ Conclusion

The Singleton pattern is a great fit when you need a single, centralized instance that manages state across an application — like a configuration manager.
The TypeScript example above demonstrates a simple, clear implementation you can adapt for production by switching to JSON/YAML, adding error handling and tests, and addressing multi-process concerns.