Singleton
Intermediate · 12 min read · ▶ live playground · ✦ checkpoint
The checkout logs at info, while inventory logs the same request at debug. Both teams configured “the logger,” but each service quietly constructed its own copy, so production now tells two versions of one story.
The Singleton pattern gives a class exactly one instance and a global access point to it. The class controls construction, stores its sole instance, and returns that instance whenever a caller asks.
That guarantee can protect a process-wide resource such as a connection pool. It also creates global coupling: any code can reach the object, and the object's lifetime becomes difficult to control. Singleton trades explicit ownership for convenient shared access.
Before: every caller creates its own “shared” object
BillingService and InventoryService both own a Logger. The class names make the loggers sound equivalent, but nothing connects their state.
class diagram
Before: two services, two logger configurations
- 1Logger holds configuration, but its public constructor permits any number of copies.
- 2BillingService constructs and owns one logger configured for info messages.
- 3InventoryService constructs another logger and chooses a different level.
- 4The two objects share a type, not an identity, so their configuration can drift.
The duplication is easy to miss because each field is private and each service behaves correctly in isolation:
type LogLevel = "info" | "debug";
class Logger {
private minimumLevel: LogLevel;
public constructor(minimumLevel: LogLevel) {
this.minimumLevel = minimumLevel;
}
public level(): LogLevel {
return this.minimumLevel;
}
}
class BillingService {
private logger = new Logger("info");
public logLevel(): LogLevel {
return this.logger.level();
}
}
class InventoryService {
private logger = new Logger("debug");
public logLevel(): LogLevel {
return this.logger.level();
}
}
console.log(new BillingService().logLevel());
console.log(new InventoryService().logLevel());info debug
The bug appears at the boundary between the services. Changing one logger does not update the other, and constructing a third service can create a third configuration.
After: let the class control construction
A private constructor closes ordinary new Logger() calls. A static field belongs to the class rather than to an instance, and the static accessor creates the logger only on its first request.
class diagram
After: one Logger behind a static access point
- 1Logger stores its sole instance and hides construction behind getInstance().
- 2BillingService reaches the same class-level access point instead of calling new.
- 3InventoryService receives the exact object previously returned to BillingService.
- 4One identity keeps in-process configuration aligned, at the cost of global coupling.
Lazy initialization means delaying construction until the first request. getInstance() performs that check and then keeps returning the stored object:
type LogLevel = "info" | "debug";
class Logger {
private static instance: Logger | undefined;
private minimumLevel: LogLevel = "info";
private constructor() {}
public static getInstance(): Logger {
if (Logger.instance === undefined) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public setMinimumLevel(level: LogLevel): void {
this.minimumLevel = level;
}
public level(): LogLevel {
return this.minimumLevel;
}
}
const first = Logger.getInstance();
const second = Logger.getInstance();
first.setMinimumLevel("debug");
console.log(first === second);
console.log(second.level());true debug
The identity check is true, and a change through first is visible through second. That is the useful guarantee and the danger: every caller shares the same mutable state.
The scope is smaller than it first sounds. In a server using several Node.js processes or workers, each runtime can load its own module and create its own instance. Singleton usually means “one per runtime or module realm,” not “one across the entire distributed system.” A database or coordination service must enforce a genuinely system-wide uniqueness rule.
Thread-safety
Thread-safety means code remains correct when multiple threads execute it at the same time. In a multi-threaded runtime, two threads can both observe instance as empty before either assigns it. Each then constructs an object, breaking the one-instance promise.
Double-checked locking checks for an instance, acquires a lock only when none exists, and checks again inside that lock before constructing. The second check handles the thread that was waiting while another thread created the instance. Eager initialization creates the instance when the class or module loads, before competing callers can enter a lazy accessor.
Ordinary JavaScript and TypeScript code runs one synchronous call stack at a time, so the classic two-threads-inside-this-null-check race does not occur in one runtime. Adding locks to this synchronous getter would imitate a problem the runtime does not have. Workers and separate server processes do not silently share the class field; they usually receive separate instances instead.
The idiomatic TypeScript form is often a module singleton, one module-scope object exported for all importers of that module:
class AppLogger {
private minimumLevel: "info" | "debug" = "info";
public setMinimumLevel(level: "info" | "debug"): void {
this.minimumLevel = level;
}
public level(): "info" | "debug" {
return this.minimumLevel;
}
}
const appLogger = new AppLogger();
export { appLogger };The module loader supplies the shared access point, so a private constructor and getInstance() add little in typical TypeScript code. The design still carries Singleton's testing and dependency costs.
Why singletons hurt tests
Global mutable state is changeable data reachable from anywhere in the program. If one test changes a singleton's level or cached values, the next test can inherit that state. Test order begins to matter unless every test knows how to reset the global.
A direct Logger.getInstance() call is also a hidden dependency, a collaborator a class reaches for instead of declaring in its inputs. The constructor does not reveal that checkout needs logging, and a test cannot substitute a fake without changing global state or adding a special hook.
That points back to the Dependency Inversion Principle. Most clients are clearer when they receive the small logging contract they need:
interface LogSink {
write(message: string): void;
}
class Checkout {
private log: LogSink;
public constructor(log: LogSink) {
this.log = log;
}
public complete(orderId: string): void {
this.log.write("Completed " + orderId);
}
}
class FakeLog implements LogSink {
public messages: string[] = [];
public write(message: string): void {
this.messages.push(message);
}
}Application setup can still create one real logger and pass that same instance to every client. You keep shared identity without letting each class secretly fetch a global.
When NOT to use it
Most singletons are global variables wearing a class name. Avoid the pattern when the object holds request-specific data, tests need isolated state, different callers need different configurations, or the lifetime should be controlled by application setup.
Prefer constructing one shared instance at the composition boundary and passing it through constructors. Reach for Singleton only when in-process uniqueness is a real invariant and every caller should share the same lifetime. Even then, document whether “one” means per module, worker, process, or deployed system.
Checkpoint
Answer all three to mark this lesson complete
Singleton controls how many objects exist. Next, Factory Method moves a different creation choice into subclasses so stable workflow code does not grow another switch.