Skip to main content

Criticism of Singleton Design Pattern

While the Singleton pattern is useful in many cases, there are several caveats to its usage that developers should be aware of

Global State

The Singleton pattern is essentially a globally shared instance, leading to a state that's shared across the entire application. This can make code harder to reason about and can increase the coupling between classes, leading to less modular code.

Example of Global State

Let's consider an example where we have an Application class that uses the Logger singleton we created earlier.

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 {
const timestamp = new Date();
console.log(`[${timestamp.toLocaleString()}] - ${message}`);
}
}

class Application {
private logger: Logger;

constructor() {
this.logger = Logger.getInstance();
}

run(): void {
this.logger.log("Application is starting");
// other logic
this.logger.log("Application is shutting down");
}
}

// Usage
const app = new Application();
app.run();

In this example, the Application class is tightly coupled to the Logger class. Here's why:

  1. The Application class directly references the Logger class. This makes it difficult to replace the Logger with a different logger, such as a ConsoleLogger or FileLogger, without changing the Application code.

  2. If we want to test the Application class independently of the Logger, we would have to modify the Logger class, perhaps by adding a method to change the instance variable, which violates the principle of the singleton.

  3. If we change the interface of the Logger class (for example, by renaming log to write), we would also have to change the Application class.

This tight coupling is a common drawback when using singletons and global state in general. One way to mitigate this is to use dependency injection, where the Logger instance would be passed into the Application as a parameter, allowing us to easily substitute it with a different implementation or a mock object for testing. However, this would mean giving up on using the Logger as a singleton, so there is a trade-off to consider.

Testing Difficulty

Because the singleton object maintains its state throughout the lifetime of the program, it can create problems when writing tests, as the state is preserved between tests, possibly causing unexpected results. The Singleton pattern can make unit testing much harder as it introduces global state into an application. It should be used judiciously and handled with care in a testing environment.

The difficulty in testing Singleton classes, such as our Logger, arises from the global state they introduce. A singleton instance is global, it maintains its state across the entire application lifecycle, and this state is preserved between different tests. This global state can lead to tests affecting each other and causing unexpected results. This is a problem because tests should be isolated and independent.

Example of Testing Difficulty

Let's imagine we have a simple test suite for the Logger class where we want to test the log method:

describe("Logger", () => {
it("should log messages", () => {
const logger = Logger.getInstance();
const spy = jest.spyOn(console, "log");
logger.log("Test message");
expect(spy).toHaveBeenCalledWith("[<timestamp>] - Test message");
});

it("should log different messages", () => {
const logger = Logger.getInstance();
const spy = jest.spyOn(console, "log");
logger.log("Another test message");
expect(spy).toHaveBeenCalledWith("[<timestamp>] - Another test message");
});
});

Here, <timestamp> should be replaced with the actual timestamp string.

In this example, we use Jest, a popular JavaScript testing framework. We create a spy on the console.log method to check if it's called with the expected arguments.

The problem here is that the Logger instance is shared between tests. If one test modifies the Logger (for example, if Logger had a method to change the log level or format), it could affect the other tests. This goes against the principle that each test should be isolated and independent.

In real-world scenarios, the problems can be more complex. For example, if our Logger was logging messages to a file or a database, it could leave some data behind that affects the next test. Cleaning up (resetting the state) after each test can be complicated and error-prone.

Furthermore, it becomes challenging to test the behavior of your code under different conditions. For example, if you wanted to test how your code behaves when the logger fails or behaves unexpectedly, it's difficult to replace the singleton instance with a mock or a faulty implementation for a single test.

These are some of the reasons why global state, such as that introduced by a singleton, can make testing more difficult.

Concurrency Issues

In a multi-threaded environment, special care must be taken to prevent multiple threads from creating multiple instances simultaneously. The getInstance() method needs to be made thread-safe, typically with locking/synchronization mechanisms.

JavaScript, the language on which TypeScript is based, is single-threaded due to its event-driven, non-blocking I/O model. Therefore, concurrency issues, in the classic sense of thread synchronization, are not a problem when using the Singleton pattern in TypeScript.

However, it's important to note that while JavaScript and TypeScript are inherently single-threaded, they still deal with asynchronicity due to their event-driven nature. This means that while you don't have to worry about multiple threads accessing your Singleton instance at the same time, you still need to be careful when dealing with asynchronous code. It's possible to have race conditions with asynchronous operations, so your Singleton instance should be prepared to handle this if it deals with asynchronous tasks.

Example of Concurrency Issues

Here's a basic sequence diagram using Mermaid markdown to illustrate a scenario where asynchronous code could cause potential problems with a Singleton pattern:

While JavaScript (and thus TypeScript) is inherently single-threaded, it supports concurrency through an event-driven, non-blocking I/O model. This is where we get concepts like callbacks, promises, and async/await in JavaScript. So, while we don't have to worry about multiple threads executing code at the exact same time (as we would in a multi-threaded language), we do have to think about multiple operations happening concurrently and potentially causing unexpected behavior.

Let's take our Logger singleton as an example. Suppose we modify the getInstance() method to be asynchronous:

class Logger {
private static instance: Logger;

private constructor() {}

public static async getInstance(): Promise<Logger> {
if (!Logger.instance) {
// Simulating a delay with a Promise that resolves after 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
Logger.instance = new Logger();
}
return Logger.instance;
}

public log(message: string): void {
const timestamp = new Date();
console.log(`[${timestamp.toLocaleString()}] - ${message}`);
}
}

In this modified Logger, getInstance() now includes an asynchronous operation (a delay). Suppose two different parts of our code try to get an instance of Logger at the same time:

async function main() {
const [logger1, logger2] = await Promise.all([
Logger.getInstance(),
Logger.getInstance(),
]);
console.log(logger1 === logger2); // Will print 'false'
}

main();

In this case, because the getInstance() function is asynchronous and includes a delay before the instance is created, both calls to getInstance() will see that Logger.instance is not yet defined, and they will both create a new instance. So we end up with two separate instances of our supposed Singleton.

This scenario is an example of a race condition, a type of bug that occurs when the behavior of a program depends on the relative timing of events. While JavaScript's single-threaded nature protects us from many types of race conditions that can occur in multi-threaded environments, we still have to be careful when dealing with asynchronous operations.

While TypeScript's single-threaded model mitigates some traditional concerns with the Singleton pattern, it's still important to be cautious with its use and ensure it's the right solution for the specific requirements of your application.

Subclassing

Singleton classes are difficult to subclass because this would require a static method to call a non-static method. Additionally, the semantics of inheritance don't really make sense with a singleton class because the notion of instances doesn't apply in the same way.

Example of Subclassing

Let's consider a scenario where we want to create a FileLogger class that extends our Logger singleton to log messages to a file instead of the console.

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 {
const timestamp = new Date();
console.log(`[${timestamp.toLocaleString()}] - ${message}`);
}
}

class FileLogger extends Logger {
public log(message: string): void {
const timestamp = new Date();
// hypothetical method to write to a file
this.writeToFile(`[${timestamp.toLocaleString()}] - ${message}`);
}

private writeToFile(message: string): void {
// logic to write message to a file
}
}

// Trying to get a FileLogger instance
const logger = FileLogger.getInstance();
logger.log("Test message");

In this case, TypeScript will throw an error at compile time, because FileLogger.getInstance() is trying to return an instance of Logger, not FileLogger. This is an inherent issue with the Singleton pattern: because the getInstance method is tied to the specific class (in this case, Logger), you can't use it to create instances of a subclass.

The singleton pattern fundamentally doesn't play well with inheritance. It's a pattern designed to ensure that there's only one instance of a specific class, which goes against the idea of creating a hierarchy of classes and instances.

There are ways around this - for example, we could add a method to set the singleton instance, and call it with a FileLogger instance before calling getInstance. However, this is awkward and breaks the encapsulation of the Singleton pattern, and it could lead to bugs if getInstance is called before setting the instance. It's generally better to use different patterns if you need to support subclassing.

Overuse and Misuse

Singleton is often overused by developers. Not every situation where having a single instance can be beneficial, warrants a singleton pattern. Singleton should not be used to replace global variables just because globals are considered bad. Misuse of Singleton can lead to problems related to tight coupling and difficulties in testing.

Memory Management

Once an instance of a singleton class is created, it remains in memory until the application is shut down. This could potentially be an issue if the singleton instance uses a lot of resources.

Regarding memory management, TypeScript and JavaScript automatically manage memory using garbage collection, which means that unused objects are automatically deallocated. However, because a singleton is intended to live for the duration of the application, its memory is not freed until the program ends. This is typically not a problem, as long as the Singleton doesn't use a disproportionate amount of resources, but it's still something to be aware of.

If the Singleton does hold onto a large amount of data, it could potentially cause memory-related issues. It's up to the developer to ensure that the Singleton does not unnecessarily consume resources while it is alive.

Example Of Large Memory Consumption

Here is a simple flowchart illustrating the basic operation of garbage collection and the potential memory management issue with singletons using Mermaid.

In this diagram:

  • The flow starts with the Start of Program node A and ends with End of Program node E.
  • When the program starts, the Singleton instance is created as indicated by node B.
  • This Singleton instance lives in the global space (node C), which means it's accessible from anywhere in the program.
  • Node D represents the Garbage Collector, a mechanism in JavaScript that automatically frees up memory that is no longer being used.
  • However, since the Singleton instance lives for the entire lifecycle of the application, the Garbage Collector cannot free its memory until the program ends, as indicated by the arrow from node D to node E.
  • Node F represents a Singleton with large properties. If the Singleton holds a large amount of data, it can potentially use a disproportionate amount of memory (node G). It's up to the developer to ensure that the Singleton does not unnecessarily consume resources while it is alive.

Being aware of these caveats, developers should use the Singleton pattern judiciously and when the advantages outweigh these potential downsides. They should also aim to address these caveats as much as possible in their implementations.

TypeScript Course Instructor Image
TypeScript Course Instructor Image

Time To Transition From JavaScript To TypeScript

Level Up Your TypeScript And Object Oriented Programming Skills. The only complete TypeScript course on the marketplace you building TypeScript apps like a PRO.

SEE COURSE DETAILS

What Can You Do Next 🙏😊

If you liked the article, consider subscribing to Cloudaffle, my YouTube Channel, where I keep posting in-depth tutorials and all edutainment stuff for ssoftware developers.

YouTube @cloudaffle