Dependency injection in TypeScript
DI wires abstractions to implementations. The coupling to kill is new ConcreteThing() inside a class. Use constructor injection, assemble the graph at a composition root, and keep the core pure and testable with fakes — reach for a container only when manual wiring hurts.
The Dependency Inversion Principle tells you what to depend on: an abstraction, not a concrete class. But it does not tell you how the abstraction ever meets a real implementation. Something, somewhere, still has to type new PostgresUserRepo(). The question DIP leaves open is where that happens — and the wrong answer quietly undoes the whole principle.
Put new PostgresUserRepo() inside the class that uses it and you’ve inverted nothing: the high-level policy is welded to a low-level detail, and you cannot test it without a database. Dependency injection is the plumbing that keeps the wiring out of the core. It is not a framework you install; it is a discipline about who calls new, and where.
After this lesson you can remove new ConcreteThing() from a class by injecting the dependency through its constructor; explain why a composition root — one place at the edge of the app that assembles the object graph — is what makes the core pure and unit-testable with fakes; wire a small graph by hand and judge when a DI container earns its keep versus when it’s overkill; and recognise the service-locator anti-pattern, where a class’s constructor lies about what it really needs.
The coupling to remove is new inside a class — constructor injection takes it out. When a class constructs its own collaborators, it depends on their concrete types and their construction details (connection strings, retry config, clock). It cannot run without them, and it cannot be told to use a different one. The fix is to stop constructing and start receiving: declare what you need as a constructor parameter, typed by an interface.
// BEFORE — the class reaches out and builds the world
class OrderService {
private readonly repo = new PostgresOrderRepo(process.env.DB_URL!);
private readonly mailer = new SmtpMailer(process.env.SMTP!);
place(order: Order) { /* uses this.repo, this.mailer */ }
}
// AFTER — the class declares what it needs and receives it
interface OrderRepo { save(order: Order): Promise<void>; }
interface Mailer { send(to: string, body: string): Promise<void>; }
class OrderService {
constructor(
private readonly repo: OrderRepo,
private readonly mailer: Mailer,
) {}
place(order: Order) { /* uses this.repo, this.mailer */ }
}OrderService now depends only on the OrderRepo and Mailer interfaces. It has no idea Postgres or SMTP exist. That ignorance is the point: the high-level policy (placing an order) no longer knows about low-level mechanism.
Someone still has to call new — push it to a composition root at the edge. Injection moves the construction up, not away. The buck stops at the composition root: a single place, as close as possible to the program’s entry point (main, the HTTP server bootstrap, the worker startup), where every concrete class is constructed and wired together. The core never sees it.
// main.ts — the composition root: the ONLY place that knows the concretes
function buildApp() {
const repo = new PostgresOrderRepo(process.env.DB_URL!);
const mailer = new SmtpMailer(process.env.SMTP!);
const orders = new OrderService(repo, mailer); // wire the graph
return new HttpServer(orders);
}
buildApp().listen(3000);Now the dependency arrow points the right way: details (PostgresOrderRepo) depend on abstractions (OrderRepo) defined near the policy, and only main.ts depends on the details. Everything below it is pure wiring-free logic. This is what “keep the core unaware of how it’s constructed” means in practice — construction is a leaf concern at the boundary, not a responsibility smeared through the domain.
This is precisely what makes the core unit-testable with fakes. Because OrderService receives its collaborators, a test can hand it trivial in-memory stand-ins instead of a real database or SMTP server. No network, no container, no mocking framework rewriting modules — just pass a different object that satisfies the interface.
test("place() emails the customer after saving", async () => {
const saved: Order[] = [];
const sent: string[] = [];
const fakeRepo: OrderRepo = { save: async (o) => { saved.push(o); } };
const fakeMailer: Mailer = { send: async (to) => { sent.push(to); } };
const service = new OrderService(fakeRepo, fakeMailer);
await service.place({ id: "1", email: "a@b.com" /* … */ });
expect(saved).toHaveLength(1);
expect(sent).toEqual(["a@b.com"]);
});The fake is a few lines because the seam is an interface, not a concrete class you have to subclass or monkey-patch. If you find yourself reaching for heavyweight module-mocking to test a unit, that is usually a smell that the dependency is hardcoded inside instead of injected. DI is what turns “untestable without infrastructure” into “testable with a plain object”.
Manual DI vs a container — and why the container is usually overkill at first. For a small graph, the composition root is a few new calls in dependency order. That’s “poor man’s DI”, and it is genuinely fine: it’s explicit, type-checked, has zero dependencies, and a stack trace points straight at the wiring. A DI container automates this — you register OrderRepo → PostgresOrderRepo once and ask the container to resolve OrderService, and it constructs the transitive graph for you.
// manual: explicit, no magic, type-checked by the compiler
const orders = new OrderService(new PostgresOrderRepo(url), new SmtpMailer(smtp));
// container (e.g. tsyringe): a registry resolves the graph for you
container.register<OrderRepo>("OrderRepo", { useClass: PostgresOrderRepo });
const orders = container.resolve(OrderService); // graph built by reflectionA container starts to pay off when the graph is large, lifetimes matter (singletons vs per-request scopes), and wiring the same dozens of services by hand becomes its own maintenance burden — the kind of thing you hit in a sizeable NestJS or Spring app. Below that threshold a container adds a dependency, decorator/reflection setup, and a layer of indirection that obscures the graph instead of clarifying it. When NOT to apply: don’t reach for a framework container in a small or early app just because “real apps use DI containers” — manual wiring at the composition root already gives you DIP. The container is an optimisation for graph scale, not a prerequisite for injection.
Take the new out and watch the graph reassemble at the edge. A notifications module that builds its own world:
// BEFORE — every dependency hardcoded inside; untestable without Redis + Twilio
class NotificationService {
private readonly queue = new RedisQueue(process.env.REDIS_URL!);
private readonly sms = new TwilioClient(process.env.TWILIO_KEY!);
private readonly clock = Date; // implicit time dependency
async remind(userId: string) {
const at = this.clock.now();
await this.queue.push({ userId, at });
await this.sms.send(userId, "reminder");
}
}You cannot test remind without a live Redis, a Twilio account, and a non-deterministic clock. Now inject the seams:
interface Queue { push(job: Job): Promise<void>; }
interface Sms { send(userId: string, body: string): Promise<void>; }
interface Clock { now(): number; }
class NotificationService {
constructor(
private readonly queue: Queue,
private readonly sms: Sms,
private readonly clock: Clock,
) {}
async remind(userId: string) {
const at = this.clock.now();
await this.queue.push({ userId, at });
await this.sms.send(userId, "reminder");
}
}
// composition root (main.ts) — the concretes live here, and only here
const notifications = new NotificationService(
new RedisQueue(process.env.REDIS_URL!),
new TwilioClient(process.env.TWILIO_KEY!),
{ now: () => Date.now() },
);
// a test — three plain objects, deterministic, no infrastructure
const jobs: Job[] = [];
const svc = new NotificationService(
{ push: async (j) => { jobs.push(j); } },
{ send: async () => {} },
{ now: () => 1_700_000_000_000 }, // frozen time
);
await svc.remind("u1");
// jobs[0].at === 1_700_000_000_000 — exactly, every runNothing about the logic changed. We only moved new from inside the class to the composition root and named each collaborator as an interface. The payoff: the class is now a pure function of its inputs, the clock is deterministic in tests, and prod wiring is one obvious block at the edge.
▸Why this works
Why must construction live at one root rather than wherever it’s convenient? Because the value of DI is that the core is unaware of concretes — and that property only holds if no part of the core constructs them. The moment a service somewhere calls new PostgresRepo(), that branch of the graph is welded shut: it can’t be swapped for a fake, its lifetime can’t be controlled centrally, and config bleeds into the domain. Concentrating all new at the composition root keeps the “knows about concretes” surface to a single, replaceable file — so the app can be reconfigured (test doubles, a different DB, an in-memory mode for local dev) by editing the root alone, never the core.
▸Common mistake
The seductive failure mode is the service locator / global container: instead of declaring dependencies in the constructor, code reaches into a global container.get("Mailer") wherever it needs something. It looks like DI — there’s a container! — but it inverts nothing useful. The constructor now lies: new OrderService() takes no arguments, yet the class secretly pulls a mailer, a repo, and a clock out of a global at runtime. You cannot see a class’s real dependencies from its signature, tests must populate a global before constructing anything, and a missing registration fails at runtime instead of compile time. The cure is to keep dependencies explicit in the constructor — even if a container resolves them, it should do so by reading constructor parameters, not by being queried from inside the class. A constructor that tells the truth about what it needs is the whole point.
A teammate replaces hardcoded `new`s with a global `container.get('Mailer')` called inside each service, and calls it 'dependency injection'. Why does this fail to deliver what DI is for?
Dependency injection is the plumbing that makes the Dependency Inversion Principle real: it removes new ConcreteThing() from your classes by having them receive collaborators (typed as interfaces) through their constructor, and pushes all construction up to a single composition root at the app’s edge. That keeps the core pure and unaware of how it’s built — which is exactly what makes it unit-testable by passing plain fakes at the interface seam, no infrastructure required. Start with manual wiring (a few new calls in the root); it’s explicit and compiler-checked. Reach for a container only when graph scale and lifetime management make hand-wiring a real burden. And avoid the service-locator trap: a class that pulls its dependencies out of a global container has a constructor that lies about what it needs — keep dependencies explicit in the signature, where both the compiler and the next reader can see them.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.