The Everyday Backend Engineer: Step 1 - The Singleton Pattern
The Problem: Database Overload
Imagine you are building an Express.js API, and every time a user hits a route to fetch data, you spin up a brand new database connection inside the controller or service file:
// โ Bad Practice: Creating a new connection on every request
app.get('/users', async (req, res) => {
const db = new DatabaseConnection();
const users = await db.query("SELECT * FROM users");
res.json(users);
});
What happens under heavy traffic? If 1,000 users hit this endpoint at the exact same second, your application will attempt to open 1,000 independent, concurrent connections to your database. Databases have strict structural limits on how many concurrent connections they can handle (e.g., a max limit of 100). Once you breach that limit, your database will reject new incoming requests, throwing a fatal "Database Connection Limit Exceeded" error and crashing your entire backend.
The Solution: A Single Global Instance
We don't need a fresh database connection instance for every file or request. We just need one permanent, shared instance that initializes when the server starts and stays alive until the process shuts down. Every module across our app should share this exact same instance. This is the core philosophy of the Singleton Pattern: Ensure a class has only one instance, and provide a global point of access to it.
The Real-World Analogy: "The Shared TV"
Think of a house with five family members (modules). Instead of buying five separate televisions and stuffing them into five different bedrooms (wasting money, space, and electricity), the family places one single TV in the living room. Everyone walks to the living room and shares that exact same television instance.
The Clean Node.js Implementation
In traditional object-oriented languages like Java or C#, implementing a Singleton requires private constructors and static methods. In Node.js, we can leverage the native behavior of the CommonJS/ES Module Caching System. When you use require() or import on a file for the first time, Node.js executes the code, caches the exported result in memory, and returns that exact cached result for every subsequent call.
Here is how to set up a database pool instance as a Singleton:
// config/database.js
const { Pool } = require('pg');
class DatabaseConfig {
constructor() {
// Create a connection pool that manages a maximum of 10 concurrent connections
this.pool = new Pool({
host: 'localhost',
user: 'db_user',
max: 10 // Max 10 parallel independent tasks allowed
});
console.log("๐ Database connection pool initialized for the FIRST time!");
}
// A simple wrapper method to execute queries
query(text, params) {
return this.pool.query(text, params);
}
}
// Step 1: Create the SINGLE instance right here inside the module
const databaseInstance = new DatabaseConfig();
// Step 2: Freeze the object to prevent external code from modifying or adding properties
Object.freeze(databaseInstance);
// Step 3: Export the frozen instance, NOT the class
module.exports = databaseInstance;
How to use it cleanly across your application
Now, no matter how many different files import your database configuration, the constructor will only run once, and they will all share the exact same instance.
// controllers/userController.js
const db = require('../config/database'); // Returns the cached global instance
async function getAllUsers(req, res) {
// This safely borrows a connection from the existing pool of 10
const result = await db.query("SELECT * FROM users");
res.json(result.rows);
}
// controllers/productController.js
const db = require('../config/database'); // Returns the EXACT SAME cached instance as above
async function getAllProducts(req, res) {
const result = await db.query("SELECT * FROM products");
res.json(result.rows);
}
Code Smell Test: When should you use a Singleton?
Keep an eye out for this indicator during code reviews:
- The Smell: You see multiple files initializing the exact same resource manager using the
newkeyword (e.g.,new RedisClient(),new ConfigManager()). - The Fix: Move the instantiation inside the config file, freeze the object instance using
Object.freeze(), and export it directly.
That wraps up Step 1! Your database is now safely protected behind a memory-efficient Singleton instance. Up next in our blog series is Step 2: The Factory Pattern, where we will look at how to cleanly instantiate different objects without making our core business logic file messy with nested if/else blocks.
Have you used singletons in Node.js using module exports, or do you prefer class-based structural controls? Let me know in the comments below!
Comments
No comments yet. Start the discussion.