I wrote before about singletons and their impact on testability. The short version is that a shared instance is convenient but couples every caller to one concrete implementation. Dependency injection is the pattern that actually decouples them: instead of a class reaching out and grabbing what it needs, its dependencies are handed to it from outside.
class OrderService {
private db = Database.getInstance(); // reaches out and grabs a singleton
async placeOrder(order: Order) {
return this.db.insert("orders", order);
}
}To unit test placeOrder, you now need a real (or heavily mocked-at-the-module-level) Database.getInstance(). There's no way to hand OrderService a fake database, it decided for itself where its dependency comes from.
The fix: pass the dependency in, typed against an interface instead of a concrete class.
interface DB {
insert(table: string, row: unknown): Promise<void>;
}
class OrderService {
constructor(private db: DB) {}
async placeOrder(order: Order) {
return this.db.insert("orders", order);
}
}
// production
const service = new OrderService(new PostgresDB(connectionString));
// test, no real database anywhere
const fakeDb: DB = { insert: jest.fn() };
const testService = new OrderService(fakeDb);OrderService no longer knows or cares whether it's talking to Postgres, an in-memory store, or a mock. That's the entire pattern. Everything else below is variations on "how do I avoid writing new X(new Y(new Z())) by hand everywhere."
For small apps, manual constructor wiring in one composition-root file is enough, no framework needed:
// composition-root.ts: the one file that knows about concrete implementations
const db = new PostgresDB(connectionString);
const logger = new ConsoleLogger();
const orderService = new OrderService(db, logger);
const orderController = new OrderController(orderService);This is the YAGNI answer: if your dependency graph is a dozen classes, wire it by hand. A DI container earns its cost when the graph gets deep enough that manual wiring becomes its own maintenance burden.
tsyringe for container-based injectionWhen manual wiring gets unwieldy, a lightweight container removes the boilerplate. tsyringe (from Microsoft) uses decorators and reflects on constructor parameter types:
npm install tsyringe reflect-metadataimport "reflect-metadata";
import { injectable, inject, container } from "tsyringe";
interface Logger {
log(msg: string): void;
}
@injectable()
class ConsoleLogger implements Logger {
log(msg: string) {
console.log(msg);
}
}
@injectable()
class OrderService {
constructor(@inject("Logger") private logger: Logger) {}
placeOrder(order: Order) {
this.logger.log(`Order placed: ${order.id}`);
}
}
container.register("Logger", { useClass: ConsoleLogger });
const service = container.resolve(OrderService);The container resolves the whole dependency tree for you. Swapping ConsoleLogger for a FileLogger in tests is one container.register call, not a rewrite of every class that uses it.
None of this works without depending on an interface (DB, Logger) instead of a concrete class. That's the dependency inversion piece: high-level code (OrderService) depends on an abstraction it defines the shape of, and low-level code (PostgresDB) implements that abstraction, not the other way around. Skip the interface and inject a concrete PostgresDB directly, and you've just moved the coupling from inside the class to the constructor call. You haven't removed it.
| Situation | Approach |
|---|---|
| Class reaches out and grabs a singleton | Move it to a constructor parameter |
| Small app, shallow dependency graph | Manual wiring in one composition-root file |
| Deep dependency graph, repetitive wiring | A DI container (tsyringe, InversifyJS) |
| Want to unit test without a real DB/network | Inject an interface, pass a fake in tests |
Still using ClassName.getInstance() inside a class | You haven't actually decoupled anything |
The singleton pattern answers "how many instances." Dependency injection answers "how testable and swappable is this class." They're solving different problems, and DI is usually the one that matters more once a codebase grows past a handful of files.