The tempting version
// db.ts
export const db = new Database(process.env.DATABASE_URL);
// users.ts
import { db } from './db';
export async function getUser(id: string) {
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}
Everything works. Until you try to test getUser without hitting a real database. You cannot substitute db because the import is resolved at module load, not at call time.
The injection version
// users.ts
export function createUserRepo(db: Database) {
return {
getUser: (id: string) => db.query('SELECT * FROM users WHERE id = $1', [id]),
};
}
At startup you wire it:
const db = new Database(process.env.DATABASE_URL);
const users = createUserRepo(db);
In tests you wire a fake:
const fakeDb = { query: async () => [{ id: '1', name: 'Alice' }] };
const users = createUserRepo(fakeDb as any);
Five extra lines of setup. Zero mocking libraries. Zero jest.mock incantations.
When singletons are actually fine
Singletons that never need to change behavior in tests are fine. A constant env object, a logger, a pure utility module — these can be imported directly. The test for "is this a singleton I regret" is: would I ever want to substitute it in a test?
dbclient: yes → injectlogger: usually no → fine as singleton, but pass a log level through envconfigobject: no → fine as singletonemailProvider: yes (integration tests) → injectcrypto.randomUUID: yes (deterministic tests) → inject or use a seam
The NestJS/Angular sweet spot
Frameworks like NestJS push DI everywhere via decorators. The container handles wiring; you just declare dependencies in the constructor. That gets the testability benefit without the manual wiring boilerplate. For small projects, manual wiring is fine. For anything with more than a few services, the container earns its weight.
The mistake I made three times
I used singletons plus jest.mock to substitute them in tests. It works, but mocks at the module level bleed across test files, reset order matters, and the mock and real module can drift without anyone noticing. Every time I regretted it.
Injecting dependencies makes the test substitution explicit and local. The test reads top-to-bottom: here is what I am giving this function, here is what I expect back. No hidden state.
The rule
Default to injection for anything that reaches outside the process — databases, queues, HTTP clients, time, randomness. Singleton is fine for configuration and pure utilities. If you are mocking it in tests, inject it instead.
