Learning Objectives
- Understand the Singleton pattern concept
- Implement Singleton with classes and modules
- Use Singleton for configuration and state management
- Recognize when to use (and avoid) Singletons
- Apply best practices and avoid pitfalls
What is the Singleton Pattern?
A Singleton restricts instantiation of a class to a single object. No matter how many times you try to create an instance, you always get the same one.
Basic Implementation
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = null;
Database.instance = this;
}
connect(url) {
if (!this.connection) {
this.connection = { url, connected: true };
console.log(`Connected to ${url}`);
}
}
query(sql) {
if (!this.connection) {
throw new Error('Not connected');
}
return `Executing: ${sql}`;
}
}
// Always returns the same instance
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true
db1.connect('mongodb://localhost');
console.log(db2.connection); // Same connection!
ES6 Module Singleton
The simplest way - export a single instance:
// database.js
class Database {
constructor() {
this.connection = null;
}
connect(url) {
this.connection = { url, connected: true };
}
query(sql) {
return `Executing: ${sql}`;
}
}
export default new Database();
// app.js
import db from './database.js';
db.connect('mongodb://localhost');
const result = db.query('SELECT * FROM users');
Every import gets the same instance!
Real-World Examples
Configuration Singleton
class Config {
constructor() {
if (Config.instance) {
return Config.instance;
}
this.settings = {
apiUrl: process.env.API_URL || 'https://api.example.com',
timeout: 5000,
debug: process.env.NODE_ENV === 'development'
};
Config.instance = this;
}
get(key) {
return this.settings[key];
}
set(key, value) {
this.settings[key] = value;
}
getAll() {
return { ...this.settings };
}
}
const config = new Config();
export default config;
Logger Singleton
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
this.logs = [];
Logger.instance = this;
}
log(message) {
const entry = {
message,
timestamp: new Date(),
level: 'INFO'
};
this.logs.push(entry);
console.log(`[${entry.timestamp.toISOString()}] ${message}`);
}
error(message) {
const entry = {
message,
timestamp: new Date(),
level: 'ERROR'
};
this.logs.push(entry);
console.error(`[${entry.timestamp.toISOString()}] ERROR: ${message}`);
}
warn(message) {
const entry = {
message,
timestamp: new Date(),
level: 'WARN'
};
this.logs.push(entry);
console.warn(`[${entry.timestamp.toISOString()}] WARN: ${message}`);
}
getLogs(level = null) {
if (level) {
return this.logs.filter(log => log.level === level);
}
return [...this.logs];
}
clear() {
this.logs = [];
}
}
export default new Logger();
State Management Singleton
class Store {
constructor() {
if (Store.instance) {
return Store.instance;
}
this.state = {};
this.listeners = [];
Store.instance = this;
}
getState() {
return { ...this.state };
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.notify();
}
subscribe(listener) {
this.listeners.push(listener);
// Return unsubscribe function
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notify() {
this.listeners.forEach(listener => listener(this.state));
}
}
const store = new Store();
export default store;
// Usage
import store from './store.js';
// Subscribe to state changes
const unsubscribe = store.subscribe(state => {
console.log('State changed:', state);
});
// Update state
store.setState({ user: { name: 'John' } });
store.setState({ count: 0 });
// Unsubscribe
unsubscribe();
Lazy Initialization
Create instance only when needed:
class HeavyResource {
constructor() {
console.log('Initializing heavy resource...');
this.data = this.loadHeavyData();
}
loadHeavyData() {
// Expensive operation
const data = new Array(1000000).fill(0).map((_, i) => ({
id: i,
value: Math.random()
}));
return data;
}
static getInstance() {
if (!HeavyResource.instance) {
HeavyResource.instance = new HeavyResource();
}
return HeavyResource.instance;
}
getData() {
return this.data;
}
}
// Not created until first use
console.log('App started');
// ... other code ...
const resource = HeavyResource.getInstance(); // Created here
When to Use Singletons
Good use cases:
- Configuration management
- Logging systems
- Database connections
- Caching mechanisms
- Thread pools
- Device drivers
Bad use cases:
- When you need multiple instances
- Testing scenarios (hard to mock)
- When state should be isolated
- When it creates tight coupling
Pros and Cons
Pros
- Controlled access to single instance
- Reduced memory footprint
- Global access point
- Lazy initialization possible
- Can be subclassed
Cons
- Global state (can cause issues)
- Hard to test (tight coupling)
- Violates Single Responsibility Principle
- Can hide dependencies
- Difficult to extend
Testing Singletons
class TestableDatabase {
constructor() {
if (TestableDatabase.instance && !TestableDatabase.testing) {
return TestableDatabase.instance;
}
this.connection = null;
if (!TestableDatabase.testing) {
TestableDatabase.instance = this;
}
}
static reset() {
TestableDatabase.instance = null;
}
static enableTesting() {
TestableDatabase.testing = true;
}
static disableTesting() {
TestableDatabase.testing = false;
}
}
// In tests
describe('Database', () => {
beforeEach(() => {
TestableDatabase.enableTesting();
TestableDatabase.reset();
});
afterEach(() => {
TestableDatabase.disableTesting();
});
it('should create new instance in test mode', () => {
const db1 = new TestableDatabase();
const db2 = new TestableDatabase();
expect(db1).not.toBe(db2);
});
});
Alternatives to Singleton
Dependency Injection
class UserService {
constructor(database, logger) {
this.db = database;
this.logger = logger;
}
async getUser(id) {
this.logger.log(`Fetching user ${id}`);
return await this.db.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
// Inject dependencies
const db = new Database();
const logger = new Logger();
const userService = new UserService(db, logger);
Factory Pattern
class DatabaseFactory {
static instances = new Map();
static create(name) {
if (!this.instances.has(name)) {
this.instances.set(name, new Database());
}
return this.instances.get(name);
}
}
const mainDb = DatabaseFactory.create('main');
const cacheDb = DatabaseFactory.create('cache');
Best Practices
1. Use ES6 modules for simple singletons
// config.js
export default {
apiUrl: 'https://api.example.com',
timeout: 5000
};
2. Make it clear it's a singleton
class DatabaseSingleton {
// Clear naming convention
}
3. Provide a way to reset for testing
class Logger {
static reset() {
Logger.instance = null;
}
}
4. Consider lazy initialization
static getInstance() {
if (!this.instance) {
this.instance = new this();
}
return this.instance;
}
5. Document singleton behavior
/**
* Database connection manager (Singleton)
* Only one instance exists throughout the application
*/
class Database {
// ...
}
Key Takeaways
- Singleton ensures only one instance exists
- Use ES6 modules for simple singletons
- Good for configuration, logging, caching
- Can make testing difficult
- Consider alternatives like dependency injection
- Provide reset mechanism for tests
- Use lazy initialization when appropriate
- Document singleton behavior clearly
- Be aware of global state issues