Singleton: When It's OK and When It's Not
The Singleton pattern is controversial. Learn when it's genuinely useful, when it's an anti-pattern, and better alternatives.
Singleton is the most debated design pattern. It ensures a class has only one instance and provides global access to it. Used correctly, it simplifies certain problems. Used incorrectly, it creates hidden dependencies, tight coupling, and testing nightmares.
Classic Singleton in TypeScript
class DatabasePool {
private static instance: DatabasePool;
private pool: Pool;
private constructor(config: PoolConfig) {
this.pool = new Pool(config);
}
static getInstance(config?: PoolConfig): DatabasePool {
if (!DatabasePool.instance) {
if (!config) throw new Error('Config required for first initialization');
DatabasePool.instance = new DatabasePool(config);
}
return DatabasePool.instance;
}
async query(sql: string, params?: unknown[]): Promise<QueryResult> {
return this.pool.query(sql, params);
}
}
The Module Pattern (Better for TypeScript)
In TypeScript/JavaScript, modules are already singletons. The module system caches the first import:
// db.ts — this IS a singleton by nature
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export async function query(sql: string, params?: unknown[]) {
return pool.query(sql, params);
}
export async function getClient() {
return pool.connect();
}
// Every file that imports from db.ts gets the SAME pool
This is simpler, cleaner, and more idiomatic than the class-based singleton.
When Singleton Is OK
- Database connection pools: You genuinely want one pool shared across the app
- Configuration: App-wide config loaded once at startup
- Loggers: A single logger instance that all modules share
- Hardware access: One interface to a physical device
// ✅ Logger — genuinely global, stateless, no testing issues
const logger = createLogger({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'production'
? new JSONTransport()
: new PrettyTransport(),
});
export default logger;
When Singleton Is an Anti-Pattern
1. Hidden Dependencies
// ❌ Who knows this class depends on a database?
class UserService {
async getUser(id: string) {
const db = DatabasePool.getInstance(); // Hidden dependency!
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
// ✅ Explicit dependency via constructor injection
class UserService {
constructor(private db: Database) {}
async getUser(id: string) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
2. Testing Difficulty
// ❌ Can't swap the singleton in tests
test('getUser returns user', async () => {
// How do you make DatabasePool.getInstance() return a mock?
// You'd need to modify the singleton's internal state... messy
});
// ✅ With dependency injection, testing is trivial
test('getUser returns user', async () => {
const mockDb = { query: vi.fn().mockResolvedValue({ rows: [testUser] }) };
const service = new UserService(mockDb);
const user = await service.getUser('123');
expect(user.rows[0]).toEqual(testUser);
});
3. Global Mutable State
// ❌ Singleton with mutable state = bugs waiting to happen
class AppState {
private static instance: AppState;
private state: Record<string, unknown> = {};
static getInstance(): AppState { /* ... */ }
set(key: string, value: unknown): void {
this.state[key] = value; // Any code anywhere can mutate this
}
get(key: string): unknown {
return this.state[key];
}
}
// Module A sets something, Module B reads it, Module C overwrites it
// Good luck debugging that
Better Alternatives
Dependency Injection Container
class Container {
private services = new Map<string, unknown>();
register<T>(key: string, factory: () => T): void {
this.services.set(key, factory());
}
resolve<T>(key: string): T {
const service = this.services.get(key);
if (!service) throw new Error(`Service not registered: ${key}`);
return service as T;
}
}
// Register once at startup
const container = new Container();
container.register('db', () => new Pool({ connectionString: DB_URL }));
container.register('logger', () => createLogger({ level: 'info' }));
container.register('userRepo', () => new UserRepository(container.resolve('db')));
// Resolve where needed — explicit, testable, replaceable
const userRepo = container.resolve<UserRepository>('userRepo');
The Rule of Thumb
Before reaching for a Singleton, ask:
- Is there truly only one instance needed? (Not “convenient” — needed)
- Is the instance stateless or read-only? (Loggers, configs)
- Could dependency injection solve this better? (Usually yes)
- Will this make testing harder? (If yes, don’t do it)
“Singletons are the global variables of the OOP world.” — Clean Code