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:
The
Application
class directly references theLogger
class. This makes it difficult to replace theLogger
with a different logger, such as aConsoleLogger
orFileLogger
, without changing theApplication
code.If we want to test the
Application
class independently of theLogger
, we would have to modify theLogger
class, perhaps by adding a method to change theinstance
variable, which violates the principle of the singleton.If we change the interface of the
Logger
class (for example, by renaminglog
towrite
), we would also have to change theApplication
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
nodeA
and ends withEnd of Program
nodeE
. - 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 nodeE
. - 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 (nodeG
). 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.
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 DETAILSWhat 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.