← Back to Blog

Dependency Injection in TypeScript: Patterns Beyond Singletons

2026-06-27·3 min read
TypeScriptDesign PatternsDependency InjectionTestingSoftware Architecture

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.

1. The problem, concretely

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.

2. Constructor injection

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."

3. When manual wiring is fine

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.

4. tsyringe for container-based injection

When 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-metadata
import "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.

5. Interfaces are the actual point

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.

Quick reference

SituationApproach
Class reaches out and grabs a singletonMove it to a constructor parameter
Small app, shallow dependency graphManual wiring in one composition-root file
Deep dependency graph, repetitive wiringA DI container (tsyringe, InversifyJS)
Want to unit test without a real DB/networkInject an interface, pass a fake in tests
Still using ClassName.getInstance() inside a classYou 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.