CAUTION: Your JavaScript / Node Module Might Be A "Singleton" (Anti-Pattern)
In the world of programming, the Singleton design pattern is often criticized as an anti-pattern: it is not flexible, it makes testing harder, dependency management harder, and violates the single-responsibility principle. Which is why you should try to avoid using the Singleton pattern in most cases. That said, I suspect that a lot of JavaScript programmers are using the Singleton pattern without even thinking about it by co-opting their JavaScript modules as initialization vectors.
Before we look at what I mean, it's important to understand that there is a critical — albeit subtle — difference between a "Single Instance" and a "Singleton". Many applications instantiate and cache components for the duration of an application's life-cycle. In fact, for many storage libraries and API client libraries, this is the author's recommended approach: create, cache, and then share a thread-safe instance.
These thread-safe, cached instances are Single instances of a class or component - not Singletons. Typically, only one of them is created during the application lifespan as a means to reduce performance and memory overhead. However, this is tactical decision, not a technical one. Meaning, a developer could instantiate multiple versions of the same class if the need ever arose. For example, multiple instances of a Database client could be created in order to read from different datasource names.
Singletons, on the other hand, have a technical limitation: only one instance can ever be created within the application, no matter what needs arise.
In the JavaScript / NodeJS ecosystems, I often see people accidentally falling into the Singleton anti-pattern by commingling two different responsibilities within their modules:
- Class definition.
- Class instantiation.
What I mean by this is that the module that defines a class also takes care of instantiating and exporting that class. Consider this trite database client module:
var instanceID = 0;
var username = process.env.DB_USERNAME;
var password = process.env.DB_PASSWORD;
var datasource = process.env.DB_DATASOURCE;
class DatabaseClient {
constructor( username, password, datasource ) {
this.username = username;
this.password = password;
this.datasource = datasource;
this.uid = ++instanceID;
}
}
// Initialize and export the database client.
module.exports.client = new DatabaseClient( username, password, datasource );
As you can see here, this module both defines and instantiates the database client. Which means, when the calling code runs, it can import
the already created and cached instance of the DatabaseClient
class:
var client = require( "./database-client" ).client;
// This is the ALREADY INSTANTIATED client.
console.log( client );
Now, in Node (and I believe in JavaScript as well), a module is only ever evaluated once per unique import
/ require
path. Which means, if I had another file that also ran this code within the same application life-span:
var client = require( "./database-client" ).client
... the runtime would simply use the cached module evaluation at that the given path and would therefore return the already instantiated client
variable.
In this application, there is no way for me to create multiple instances of that DatabaseClient
class. No matter how many times I try to import the client
, it's always the same, cached instance. This is a Singleton.
In fact, not only is this a Singleton, but it's also suffering from another anti-pattern: it is tightly coupled the process.env
concept. Notice that when I instantiate the DatabaseClient
class within my module, I'm pulling the username, password, and datasource from the environment. So, in all actuality, this module is commingling three different responsibilities:
- Class definition.
- Class instantiation.
- Secrets management.
To fix both of these anti-patterns, we need to separate the responsibility of definition from the responsibility of instantiation. This is often accomplished by having some sort of a "main" bootstrapping module that imports, instantiates, and caches classes that should only be instantiated once (as a tactical choice, not a technical constraint).
So, instead of our database client module instantiating the database client, it simple exports the class definition:
var instanceID = 0;
module.exports = class DatabaseClient {
constructor( username, password, datasource ) {
this.username = username;
this.password = password;
this.datasource = datasource;
this.uid = ++instanceID;
}
};
There's no more coupling to the instantiation logic and no more coupling to the process.env
secrets management. And now, we just need some sort of main bootstrapping process to wire the whole application together:
var DatabaseClient = require( "./clean-database-client" );
// Now that the DatabaseClient module is void of any instantiation logic, we can draw a
// clean boundary around the logic that is responsible for wiring the application
// components together. Notice that we've also removed the tight-coupling to the ENV.
var client = new DatabaseClient(
process.env.DB_USERNAME,
process.env.DB_PASSWORD,
process.env.DB_DATASOURCE
);
Right now, there's only a "single instance" of the DatabaseClient
class. But, this is not a singleton. Should we need to instantiate multiple instances of the class in order to read from different datasource names, we can easily do so by new
ing up another instance!
After we've instantiated our database client, our bootstrapping process would then provide it to other classes through Inversion of Control (IoC). In the same way that our bootstrapping process provided the username, password, and datasource to the DatabaseClient
constructor, so to would it provide the cached client
instance as a constructor argument to any other module that needs it.
Bottom line, if you're depending on the fact that a module is only ever evaluated once; and you're leaning on that technical detail in order to instantiate and cache JavaScript modules; then, you've likely fallen into the Singleton anti-pattern. The good news is, you can get yourself out of that hole by refactoring the class instantiation logic out and into a centralized location.
class
Problem
Epilogue: It's a State Problem, Not a To be clear, I am using an ES6 class
here in order to demonstrate the problem because new
ing an object is such a tangible action. But, don't limit your understanding of the Singleton anti-pattern to contexts that use class
. The core problem here is one of state initialization. If you have a JavaScript module that initializes state as part of its definition, it's the same thing: your module is mixing responsibilities and has become a Singleton.
If it helps to have a litmus test, if you call require()
in order to get state (or something that exposes state), it's likely that you've created a Singleton.
Sometimes, it's not even so clean-cut. For example, look at this Angular code that I wrote the other week: it's both exporting a class
and pulling from the environment
:
// Import application modules.
import { environment } from "~/environments/environment";
@Injectable({
providedIn: "root"
})
export class ApiClient {
private apiDomain: string;
private httpClient: HttpClient;
/**
* I initialize the API client.
*/
constructor( httpClient: HttpClient ) {
this.httpClient = httpClient;
// TODO: Should this be provided as an injectable? It seems sloppy for a runtime
// component to be pulling directly from the environment. This creates tight-
// coupling to the application bootstrapping process.
this.apiDomain = environment.apiDomain;
}
// .... truncated ....
}
At first glance, it might look like I'm doing everything "right". But, even though I'm doing my best to separate concerns, I still fall into the Singleton trap by leaving the apiDomain
state initialization in the module. Yes, I can create multiple instances of the exported ApiClient
class; but, they will all only ever point to the same apiDomain
. Really, what I need to do is provide the apiDomain
as a constructor argument in order to fully move state initialization out into the bootstrapping process.
That's the biggest problem with the Singleton anti-pattern: it's so easy to use. But, the moment you do, things become tightly coupled and harder to evolve and maintain.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →