The Singleton Pattern: Guaranteeing a Single Instance #
Introduction #
The Singleton is one of the most well-known (and sometimes controversial) design patterns from the Gang of Four. Its objective is simple: ensure that a class has only one instance throughout the entire application and provide a global point of access to that instance.
In this article, we will explore the Singleton pattern through a concrete example: a Coordinator class that can only exist in a single exemplar in the system.
What is the Singleton? #
Definition #
The Singleton pattern ensures that a class:
- Has only one instance during the entire application lifetime
- Provides a global point of access to that instance
- Controls its own instantiation through a private constructor
UML Structure #
classDiagram
class Singleton {
-static instance: Singleton
-constructor()
+static getInstance() Singleton
+businessMethod()
}
note for Singleton "Private constructor\nStatic instance\nStatic getter"
| Characteristic | Description |
|---|---|
| Private constructor | Prevents direct instantiation with new |
| Static instance | Stores the unique instance of the class |
| Static method | getInstance() returns the unique instance |
| Lazy initialization | Instance is created on first demand |
Use Case: The Unique Coordinator #
Context #
Imagine a team management system where:
- A single coordinator supervises the entire team
- This coordinator must be accessible from anywhere in the application
- There must never be two coordinators simultaneously
Problem without Singleton #
// PROBLEM: Multiple instances possible
const coord1 = new Coordinateur();
const coord2 = new Coordinateur();
// coord1 and coord2 are two different instances
console.log(coord1 === coord2); // false
Consequences:
- Data inconsistency
- Confusion about “who is the real coordinator”
- Resource waste
- Hard to trace bugs
TypeScript Implementation #
Complete Architecture #
classDiagram
class Person {
<<abstract>>
#name: string
#firstname: string
#email: string
#phone: string
+birthdate: Date
+gender: string
-strategy: NameStrategy
+getName() string
+setName(name: string) void
+getFirstname() string
+setFirstname(firstname: string) void
+getEmail() string
+setEmail(email: string) void
+getPhone() string
+setPhone(phone: string) void
+getDisplayName() string
+setStrategy(strategy: NameStrategy) void
+sayHello()* void
}
class Coordinateur {
-static instance: Coordinateur
-static instanciations: number
-constructor()
+static getInstance() Coordinateur
+sayHello() void
}
class NameStrategy {
<<interface>>
+transform(person: Person) string
}
class NameFirstStrategy {
+transform(person: Person) string
}
Person <|-- Coordinateur : inherits
Person --> NameStrategy : uses
NameStrategy <|.. NameFirstStrategy : implements
note for Coordinateur "Singleton Pattern\nOnly one instance possible"
Abstract Person Class #
import { NameFirstStrategy } from "./strategies/name-first-strategy";
import type { NameStrategy } from "./strategies/name-strategy";
export abstract class Person {
// Protected attributes (accessible to subclasses)
protected name: string = '';
protected firstname: string = '';
protected email: string = '';
protected phone: string = '';
// Public attributes
public birthdate: Date = new Date();
public gender: string = '';
// Strategy pattern for name formatting
private strategy: NameStrategy = new NameFirstStrategy()
constructor() {}
// Getters and Setters with validation
public getName(): string {
return this.name;
}
public setName(name: string): void {
// Prevents modification if already set
if (this.name === '') {
this.name = name;
}
}
public getFirstname(): string {
return this.firstname;
}
public setFirstname(firstname: string): void {
if (this.firstname === '') {
this.firstname = firstname;
}
}
public getEmail(): string {
return this.email;
}
public setEmail(email: string): void {
if (this.email === '') {
this.email = email;
}
}
public getPhone(): string {
return this.phone;
}
public setPhone(phone: string): void {
if (this.phone === '') {
this.phone = phone;
}
}
// Using the Strategy pattern
public getDisplayName(): string {
return this.strategy.transform(this)
}
public setStrategy(strategy: NameStrategy): void {
this.strategy = strategy
}
// Abstract method (must be implemented by subclasses)
public abstract sayHello(): void;
}
Key points:
- Abstract class: cannot be instantiated directly
- Encapsulation: protected attributes with getters/setters
- Validation: prevents data modification once set
- Strategy Pattern: configurable name formatting
Singleton Coordinator Class #
import { Person } from "./person"
export class Coordinateur extends Person {
// Private static instance (the heart of the Singleton)
private static instance: Coordinateur | undefined = undefined
// Optional counter to trace instantiation attempts
private static instanciations: number = 0
// Private constructor: forbids `new Coordinateur()`
private constructor() {
super()
Coordinateur.instanciations++
}
// Static method: only way to get the instance
public static getInstance(): Coordinateur {
// Lazy initialization: creation on first demand
if (Coordinateur.instance === undefined) {
Coordinateur.instance = new Coordinateur()
}
return Coordinateur.instance
}
// Implementation of abstract method
public sayHello(): void {
console.log(`Hello, I am ${this.getDisplayName()}, the unique coordinator!`);
}
// Utility method for debugging
public static getInstanciationCount(): number {
return Coordinateur.instanciations
}
}
Name Formatting Strategy (Strategy Pattern) #
// Strategy Interface
export interface NameStrategy {
transform(person: Person): string;
}
// Implementation: First name then Last name
export class NameFirstStrategy implements NameStrategy {
transform(person: Person): string {
return `${person.getFirstname()} ${person.getName()}`;
}
}
// Alternative implementation: Last name then First name
export class LastNameFirstStrategy implements NameStrategy {
transform(person: Person): string {
return `${person.getName()} ${person.getFirstname()}`;
}
}
Code Analysis #
Sequence Diagram: Getting the Instance #
sequenceDiagram
participant Client1
participant Client2
participant Coordinateur
Note over Client1: First request
Client1->>Coordinateur: getInstance()
alt Instance does not exist
Coordinateur->>Coordinateur: new Coordinateur()
Note over Coordinateur: instance created
end
Coordinateur-->>Client1: returns instance
Note over Client2: Second request
Client2->>Coordinateur: getInstance()
alt Instance already exists
Note over Coordinateur: Returns existing instance
end
Coordinateur-->>Client2: returns SAME instance
Note over Client1,Client2: client1 === client2
The Private Constructor: The Guardian #
// Private constructor
private constructor() {
super()
Coordinateur.instanciations++
}
Why private?
- Prevents:
new Coordinateur()(compilation error) - Forces use of
getInstance() - Total control over instantiation
Attempted direct instantiation:
// ERROR: Constructor of class 'Coordinateur' is private
const coord = new Coordinateur();
getInstance(): The Single Point of Access #
public static getInstance(): Coordinateur {
// Check for existence
if (Coordinateur.instance === undefined) {
// Lazy creation (on first demand)
Coordinateur.instance = new Coordinateur()
}
// Always returns the same instance
return Coordinateur.instance
}
Characteristics:
- Thread-safe in JavaScript (single-threaded)
- Lazy initialization: deferred creation
- Single and controlled point of access
The Instantiation Counter: Debugging #
private static instanciations: number = 0
public static getInstanciationCount(): number {
return Coordinateur.instanciations
}
Utility:
- Verify that the constructor is called only once
- Trace instantiation attempts
- Unit tests: validate Singleton behavior
Practical Usage #
Complete Example #
// main.ts
import { Coordinateur } from './coordinateur';
import { LastNameFirstStrategy } from './strategies/last-name-first-strategy';
// Get the unique instance
const coord1 = Coordinateur.getInstance();
// Configure the coordinator
coord1.setFirstname('John');
coord1.setName('Doe');
coord1.setEmail('john.doe@example.com');
coord1.birthdate = new Date('1980-05-15');
coord1.gender = 'M';
// Say hello
coord1.sayHello();
// Output: "Hello, I am John Doe, the unique coordinator!"
// Get the instance again (same object)
const coord2 = Coordinateur.getInstance();
console.log(coord1 === coord2); // true
console.log(coord2.getEmail()); // "john.doe@example.com"
// Change the formatting strategy
coord2.setStrategy(new LastNameFirstStrategy());
console.log(coord2.getDisplayName()); // "Doe John"
// Check the number of instantiations
console.log(Coordinateur.getInstanciationCount()); // 1
Unit Tests #
// coordinateur.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Coordinateur } from './coordinateur';
describe('Coordinateur Singleton', () => {
it('should return the same instance', () => {
const coord1 = Coordinateur.getInstance();
const coord2 = Coordinateur.getInstance();
expect(coord1).toBe(coord2);
});
it('should instantiate only once', () => {
const coord = Coordinateur.getInstance();
expect(Coordinateur.getInstanciationCount()).toBe(1);
});
it('should share state between references', () => {
const coord1 = Coordinateur.getInstance();
coord1.setFirstname('Alice');
const coord2 = Coordinateur.getInstance();
expect(coord2.getFirstname()).toBe('Alice');
});
it('should not allow new Coordinateur()', () => {
// TypeScript compile-time check
// @ts-expect-error - Constructor is private
expect(() => new Coordinateur()).toThrow();
});
});
Advantages and Disadvantages #
Advantages #
| Advantage | Description | Example |
|---|---|---|
| Guaranteed unique instance | Impossible to create multiple instances | Single coordinator |
| Global access | Accessible from anywhere | Coordinateur.getInstance() |
| Strict control | Controlled initialization (Lazy loading) | |
| Resource economy | Only one instance in memory | Memory optimization |
| Shared state | All parts share the same data | Global configuration |
Disadvantages #
| Disadvantage | Description | Impact |
|---|---|---|
| Global state | Disguised global variable | Strong coupling |
| Difficult tests | Persistent state between tests | Need for reset |
| SRP violation | Manages instantiation + business logic | Multiple responsibilities |
| Concurrency | Issues in multi-threading | Not applicable in JS |
| Hidden dependencies | Difficult to trace dependencies | Complex maintenance |
When to Use the Singleton? #
Use the Singleton for:
// Global configuration
class AppConfig {
private static instance: AppConfig;
private config: Record<string, any> = {};
private constructor() {
// Load config from file
}
public static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}
}
// Global logger
class Logger {
private static instance: Logger;
private constructor() {}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(message: string): void {
console.log(`[${new Date().toISOString()}] ${message}`);
}
}
// Single database connection
class DatabaseConnection {
private static instance: DatabaseConnection;
private connection: any;
private constructor() {
// Establish connection
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
}
Do NOT use the Singleton for:
// Simple business objects
class User {
// No need for Singleton
}
// Services with frequently modified state
class ShoppingCart {
// Each user has their own cart
}
// Objects easily mockable for tests
class ApiClient {
// Better to inject it as dependency
}
Variants and Alternatives #
Eager Singleton (Immediate Loading) #
export class EagerCoordinateur extends Person {
// Instance created when class is loaded
private static instance: EagerCoordinateur = new EagerCoordinateur();
private constructor() {
super();
}
public static getInstance(): EagerCoordinateur {
return EagerCoordinateur.instance;
}
public sayHello(): void {
console.log("Eager Coordinator loaded!");
}
}
Differences:
- Simpler (no verification)
- Immediate loading (even if never used)
- Thread-safe by default (if applicable)
Singleton with Reset Method (Tests) #
export class TestableCoordinateur extends Person {
private static instance: TestableCoordinateur | undefined;
private constructor() {
super();
}
public static getInstance(): TestableCoordinateur {
if (!TestableCoordinateur.instance) {
TestableCoordinateur.instance = new TestableCoordinateur();
}
return TestableCoordinateur.instance;
}
// Use ONLY in tests
public static resetInstance(): void {
TestableCoordinateur.instance = undefined;
}
public sayHello(): void {
console.log("Testable Coordinator!");
}
}
// In tests
afterEach(() => {
TestableCoordinateur.resetInstance();
});
Module Singleton (ES6 Pattern) #
In JavaScript/TypeScript, modules are already singletons:
// coordinateur-module.ts
class CoordinateurClass extends Person {
constructor() {
super();
}
public sayHello(): void {
console.log("Coordinator via module!");
}
}
// Export of unique instance
export const coordinateur = new CoordinateurClass();
// Usage
import { coordinateur } from './coordinateur-module';
coordinateur.setFirstname('Marie');
coordinateur.sayHello();
Advantages:
- Simpler (no getInstance())
- Idiomatic in JavaScript
- Easy to mock (named export)
Dependency Injection (Modern Alternative) #
// IoC Container (e.g.: InversifyJS, TSyringe)
import { injectable, singleton } from 'tsyringe';
@singleton()
@injectable()
export class CoordinateurService extends Person {
constructor() {
super();
}
public sayHello(): void {
console.log("Coordinator via DI!");
}
}
// Usage
import { container } from 'tsyringe';
const coord1 = container.resolve(CoordinateurService);
const coord2 = container.resolve(CoordinateurService);
console.log(coord1 === coord2); // true
Advantages:
- Testable (injection of mocks)
- Complete decoupling
- Lifecycle management
Best Practices #
Avoid Excessive Global State #
// BAD: Too much business logic in Singleton
class BadCoordinateur {
private static instance: BadCoordinateur;
public tasks: Task[] = [];
public team: Member[] = [];
public projects: Project[] = [];
// ... 50 properties
// Thousands of lines of business logic
}
// GOOD: Lightweight Singleton that delegates
class GoodCoordinateur {
private static instance: GoodCoordinateur;
constructor(
private taskManager: TaskManager,
private teamManager: TeamManager,
private projectManager: ProjectManager
) {}
}
Make Singleton Testable #
// Interface for coordinator
export interface ICoordinateur {
sayHello(): void;
getDisplayName(): string;
}
// Singleton implementation
export class Coordinateur extends Person implements ICoordinateur {
private static instance: Coordinateur | undefined;
private constructor() {
super();
}
public static getInstance(): ICoordinateur {
if (!Coordinateur.instance) {
Coordinateur.instance = new Coordinateur();
}
return Coordinateur.instance;
}
public sayHello(): void {
console.log("Hello from real Coordinateur");
}
}
// Mock for tests
export class MockCoordinateur implements ICoordinateur {
public sayHello(): void {
console.log("Hello from mock");
}
public getDisplayName(): string {
return "Mock Coordinateur";
}
}
// In tests
it('should work with mock', () => {
const mock = new MockCoordinateur();
const service = new SomeService(mock); // Injection
expect(service.doSomething()).toBe('expected');
});
Clear Documentation #
/**
* Coordinator - Singleton Pattern
*
* Represents the unique coordinator of the team.
* Only one instance can exist throughout the entire application.
*
* @example
* ```typescript
* const coord = Coordinateur.getInstance();
* coord.setFirstname('Alice');
* coord.sayHello();
* ```
*
* @remarks
* Never use `new Coordinateur()` (private constructor)
* Instance is shared globally
*
* @see Person - Base class
* @see NameStrategy - Name formatting strategy
*/
export class Coordinateur extends Person {
// ...
}
Logging and Monitoring #
export class Coordinateur extends Person {
private static instance: Coordinateur | undefined;
private static instanciations: number = 0;
private static accessCount: number = 0;
private constructor() {
super();
Coordinateur.instanciations++;
console.log(`[Coordinateur] Instance created (count: ${Coordinateur.instanciations})`);
}
public static getInstance(): Coordinateur {
Coordinateur.accessCount++;
if (!Coordinateur.instance) {
console.log('[Coordinateur] Lazy initialization...');
Coordinateur.instance = new Coordinateur();
}
if (Coordinateur.accessCount % 100 === 0) {
console.log(`[Coordinateur] Accessed ${Coordinateur.accessCount} times`);
}
return Coordinateur.instance;
}
public static getStats(): { instanciations: number; accesses: number } {
return {
instanciations: Coordinateur.instanciations,
accesses: Coordinateur.accessCount
};
}
public sayHello(): void {
console.log(`Hello, I am ${this.getDisplayName()}`);
}
}
Conclusion #
Summary #
The Singleton is a powerful pattern but should be used with caution:
| Advantages | Disadvantages |
|---|---|
| Guaranteed unique instance | Global state |
| Simplified global access | Difficult tests |
| Strict control | Strong coupling |
| Resource economy | SRP violation |
When to Use It? #
Use the Singleton for:
- Global application configuration
- Centralized logger
- Single connection pool
- Shared cache
- Unique resource manager (like our Coordinator)
Avoid the Singleton for:
- Standard business objects
- Easily testable services
- Frequently modified state
- Objects with multiple contexts
The Singleton is like seasoning: used sparingly, it enhances the dish. Used excessively, it ruins it.
The Singleton pattern has its place in your toolbox, but should not be your first reflex. In our Coordinator example, it is justified because:
- It represents a unique resource by nature
- Its global access is necessary
- It simplifies architecture without compromising it
But remain vigilant: a poorly used Singleton can turn your code into a maintenance nightmare.