Nguyen Le Phong

Foundations of Software ArchitecturePart 3 of 6

Dependency Injection & Inversion of Control, Without the Magic

DI containers feel like wizardry until you see the plain idea underneath: stop letting code create its own dependencies, and hand them in instead. A from-scratch guide, with real examples and the testing payoff.

At some point every developer hits the same wall. You want to write a test for a piece of code, but the code secretly calls new Database() inside itself. No matter what you do, a real database connection fires up. You want to swap out the email provider, but the email SDK is buried three layers deep. You want to run the same logic for both an HTTP request and a scheduled job, but the framework is bolted in so tightly that it's basically impossible.

These are symptoms of one root cause: code that creates its own dependencies. The fix has a formal name — Dependency Injection — but the idea underneath it is refreshingly simple. You stop letting code find its own collaborators, and you hand them in from the outside. That's it. Everything else — IoC containers, DI frameworks, injection tokens — is just machinery built on top of that one idea.

This guide unpacks it from scratch: the problem, the principle, the pattern, and the practical payoff in tests. TypeScript is used throughout because it reads clearly for engineers coming from any web or server background.

Hard-wired dependencies: when code calls new on its own

Here is a service that processes an order. It looks perfectly reasonable at first glance:

// order-service.ts — "before" version
class OrderService {
  async placeOrder(cart: Cart): Promise<Order> {
    const db     = new PostgresClient(process.env.DATABASE_URL!)  // hard-wired DB
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)        // hard-wired vendor
    const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)    // hard-wired vendor

    const order = Order.fromCart(cart)
    await stripe.charges.create({ amount: order.total, source: cart.token })
    await db.query('INSERT INTO orders …', [order])
    await mailer.send({ to: cart.email, template: 'order-confirmed' })
    return order
  }
}

Three problems crystallise the moment you try to work with this:

  • Untestable in isolation. You cannot run placeOrder in a unit test without real credentials, a live database, and a live Stripe sandbox. Every test is a slow, brittle integration test by default.
  • Rigidly coupled to specific implementations. If the business moves from Stripe to Adyen, you open this file and do surgery. If staging needs a dummy mailer, there's no clean way to plug one in.
  • Hidden dependencies. A reader of the class signature sees placeOrder(cart) and has no idea the method secretly needs three external services. The dependencies are invisible until you read every line.
The pattern to notice

Every time you see new SomeExternalThing() inside a method or constructor body — not at the composition root — that's a hard-wired dependency. The class is responsible both for doing its own work and for finding and building the tools it needs to do that work. Those are two jobs, and they pull in opposite directions.

Inversion of Control: who is in charge of creation?

Before solving the problem with code, it helps to name the principle involved. Inversion of Control (IoC) is the idea that a piece of code should not be responsible for obtaining its own dependencies. Instead, something external — a framework, a test harness, a composition root — takes charge of creating things and handing them in.

You might have heard the so-called Hollywood Principle: "Don't call us, we'll call you." In classic imperative code, your business logic calls its dependencies directly — it reaches out and grabs a database. With IoC, the dependency comes to you. The control of that relationship is inverted: you no longer decide when or how to fetch your collaborators; the outside world decides and delivers them.

IoC is a broad principle, not a specific technique. Frameworks implement it in a variety of ways:

  • Dependency Injection (DI) — pass collaborators as constructor arguments or method parameters. The most common and most explicit form.
  • Service Locator — a global registry from which code retrieves its dependencies by name. (More on why this is an anti-pattern later.)
  • Template Method — a base class calls hook methods that subclasses override, so the base controls the flow and subclasses fill in the blanks.
  • Event / Observer — components publish events; the framework routes them to subscribers. Neither side knows the other exists.

Of these, Dependency Injection is the most transparent and the most testable. It is also the one most often conflated with IoC itself, which is why the two terms are worth untangling: IoC is the principle, DI is one implementation of that principle.

Dependency Injection: passing dependencies in

The mechanics of DI are almost embarrassingly simple once you see them. Instead of constructing collaborators inside your class, you declare them as parameters and let the caller provide them.

There are three flavours — but one clear winner for most situations:

Constructor injection (preferred)

Dependencies are declared in the constructor. This is the standard choice: every dependency is visible in the class signature, required at construction time, and can be made readonly so nothing reassigns it later.

// order-service.ts — "after" version with constructor injection
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer          { send(to: string, template: string): Promise<void> }

class OrderService {
  constructor(
    private readonly payments: PaymentGateway,
    private readonly orders:   OrderRepository,
    private readonly mailer:   Mailer,
  ) {}

  async placeOrder(cart: Cart): Promise<Order> {
    const order = Order.fromCart(cart)                   // pure business rule
    await this.payments.charge(order.total, cart.token)
    await this.orders.save(order)
    await this.mailer.send(cart.email, 'order-confirmed')
    return order
  }
}

Read that constructor signature out loud: "To create an OrderService you need a payment gateway, an order repository, and a mailer." It's a contract, written in plain code, visible without reading a single method body.

Setter / method injection (occasional use)

Dependencies are set via a method after construction. Useful for optional collaborators or for frameworks that construct objects in two phases (e.g., some test frameworks, some IoC containers). Use sparingly — it allows an object to exist in a partially-configured state, which is a subtle source of bugs.

// setter injection — use only when construction-time wiring is impossible
class ReportGenerator {
  private formatter: ReportFormatter = new DefaultFormatter()

  setFormatter(f: ReportFormatter) { this.formatter = f }  // optional override

  generate(data: ReportData): string {
    return this.formatter.format(data)
  }
}
Simple rule of thumb

Reach for constructor injection by default. Every dependency the class always needs belongs in the constructor. Only reach for setter injection when a dependency is genuinely optional, or when a framework forces it. Never use setter injection as an escape hatch to avoid thinking about your dependency graph.

The composition root: one place to wire everything together

Once you inject dependencies through constructors, a new question arrives: who actually calls new and wires the pieces together? The answer is the composition root: a single place at the entry point of your application where all real objects are constructed and injected into each other.

Think of it as the only place in your whole codebase where new ConcreteImplementation() is allowed to appear freely. Everything else talks in terms of interfaces. The composition root is the seam between "the abstract system" and "the real world".

// composition-root.ts — the one place real things are wired up
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway }    from './adapters/stripe-payment-gateway'
import { SendGridMailer }          from './adapters/sendgrid-mailer'
import { OrderService }            from './domain/order-service'
import { OrderController }         from './http/order-controller'

export function buildApp() {
  // 1. Build leaf-level dependencies first (no deps of their own)
  const db      = new PostgresOrderRepository(process.env.DATABASE_URL!)
  const stripe  = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
  const mailer  = new SendGridMailer(process.env.SENDGRID_KEY!)

  // 2. Build services, injecting their dependencies
  const orderSvc = new OrderService(stripe, db, mailer)

  // 3. Build delivery layer, injecting the service
  const orderCtrl = new OrderController(orderSvc)

  return orderCtrl
}

Notice how the composition root reads almost like a recipe: "make these ingredients, combine them into services, put the services into controllers." There is one place in the whole codebase where you go to understand how everything connects. That's a tremendous asset when onboarding a new engineer, or when you need to swap an ingredient.

In a test, you write a test-specific composition root — a tiny equivalent that wires in fakes instead of real adapters:

// in a test file — same service, fake collaborators
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders   = new InMemoryOrderRepository()
const fakeMailer   = new NoOpMailer()

const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// runs in milliseconds; no network, no database

Manual DI vs a DI container — what changes and when it matters

Everything above is manual DI, also called Pure DI. You write the wiring code yourself. For many projects — especially small ones — this is the right choice: it's plain code, there's no magic, it's immediately debuggable, and a new developer can understand the entire wiring by reading one file.

As applications grow, the manual approach starts to chafe. A DI container (sometimes called an IoC container) automates the registration and resolution of dependencies, and adds lifetime management on top.

Control inversion diagram: before (class reaches out for its own deps) versus after (composition root injects deps inward). BEFORE — class reaches out OrderService calls new() internally PostgresClient Stripe SDK SendGrid SDK deps created inside the class — untestable AFTER — composition root injects in Composition Root calls new() — only here OrderService accepts interfaces only Postgres Stripe deps flow IN — easy to swap, easy to test
On the left, OrderService reaches outward to construct its own dependencies — each arrow is a coupling baked into the class body. On the right, a composition root constructs the concrete objects and injects them inward. The arrows reverse. The service knows only about interfaces; the concrete types are a detail at the edge.
QuestionManual / Pure DIDI Container (NestJS, InversifyJS, tsyringe…)
How are types registered? You write the wiring explicitly in a composition root. Decorators, tokens, or metadata — the container scans and registers automatically.
How are instances resolved? By calling new yourself, in the right order. Ask the container for a type; it builds the full graph recursively.
Lifetime / scope management You decide: one instance per module, per request, or transient — and you hold the reference. Declarative: @Singleton(), @RequestScoped(), etc. Container handles disposal.
Debuggability Everything is plain code — set a breakpoint anywhere, follow the call stack. Resolution happens inside the container; "why did I get this instance?" requires understanding the container's internals.
Boilerplate High for large graphs — wiring a 50-class app by hand is verbose. Low — the container walks the dependency tree and builds everything automatically.
Build-time safety TypeScript catches missing constructor args at compile time. Depends on the container; token-based systems may fail at runtime if a binding is missing.
Best for Small–medium apps, teams new to DI, microservices with a focused scope. Large apps with many services, teams that want convention-over-configuration, frameworks like NestJS where DI is central.

The threshold where a container earns its keep varies, but a rough guide: if your composition root feels like it's becoming its own maintenance burden — you're always scrolling it, forgetting to add a new binding, or fighting with object lifetimes — that's the signal to reach for a container. Until then, the plain version is easier to understand and debug.

The testing payoff: inject fakes, get fast tests

The reason DI is worth learning — more than any architectural elegance — is what it does to your test suite. When every dependency arrives through the constructor, writing a test means writing a tiny composition root with fakes instead of real adapters. No database, no network, no vendor sandbox. Tests run in milliseconds and never fail because the staging Stripe account has a rate limit.

// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
  receipts: Receipt[] = []
  async charge(amount: Money, token: string): Promise<Receipt> {
    const r = { id: 'fake-receipt', amount }
    this.receipts.push(r)
    return r
  }
}

class InMemoryOrderRepository implements OrderRepository {
  store: Order[] = []
  async save(order: Order) { this.store.push(order) }
}

class SpyMailer implements Mailer {
  sent: string[] = []
  async send(to: string) { this.sent.push(to) }
}

// The test itself is pure, fast, deterministic:
it('confirms an order and notifies the customer', async () => {
  const payments = new InMemoryPaymentGateway()
  const repo     = new InMemoryOrderRepository()
  const mailer   = new SpyMailer()

  const svc   = new OrderService(payments, repo, mailer)
  const order = await svc.placeOrder(testCart)

  expect(payments.receipts).toHaveLength(1)
  expect(repo.store).toContainEqual(order)
  expect(mailer.sent).toContain(testCart.email)
})

This is the same insight at the heart of Ports & Adapters — the in-memory fake is just the test-time adapter, and the real Stripe or Postgres implementation is the production adapter. DI is the mechanism that makes the swap possible; ports & adapters is the architectural pattern that makes the boundary intentional and explicit.

It's also worth pausing on SOLID here. The "D" stands for the Dependency Inversion Principle: high-level modules should not depend on low-level modules; both should depend on abstractions. The interfaces above (PaymentGateway, OrderRepository, Mailer) are those abstractions. OrderService is the high-level module. StripePaymentGateway and PostgresOrderRepository are the low-level modules. Neither knows directly about the other — they meet at the interface, and the composition root snaps them together.

Anti-patterns: the ways DI goes wrong

The idea is simple, but there are a few well-worn ways to undermine it.

The Service Locator

A Service Locator is a global registry that code calls to retrieve its dependencies at runtime:

// anti-pattern: Service Locator — looks like DI but isn't
class OrderService {
  async placeOrder(cart: Cart) {
    const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
    const repo     = ServiceLocator.get<OrderRepository>('OrderRepository')
    // … continues
  }
}

This feels cleaner than new Stripe() inline, but it's hiding the same problem. The dependencies are still invisible in the signature. Tests have to configure a global registry before they run. And the class can request anything at all at any point — you've lost the explicitness that makes DI valuable.

Over-injection (the 10-argument constructor)

When a constructor accumulates seven, eight, or ten parameters, it is usually a sign that the class is doing too much — not that DI is the wrong tool. The fix is to decompose the class into smaller, more focused collaborators, each with a short dependency list. Constructor length is a useful health metric.

Hidden global containers

Accessing the DI container directly from within a service — rather than only at the composition root — is the Service Locator pattern under a different name. The rule is strict: only the composition root talks to the container. Services talk only to their injected interfaces.

Magic you can't trace

Some frameworks wire dependencies through decorators and reflect metadata in ways that are genuinely hard to follow when something breaks. If you're spending more time reading container docs than reading your own business logic, that's a sign to reach for a simpler approach — or at minimum to add a well-documented composition root that spells out the major bindings explicitly.

Right-sizing DI: from solo project to enterprise

Like most architectural ideas, DI scales with the problem. The goal is always the same — testable, changeable code — but the right machinery to get there changes as the team and codebase grow.

Company stageRecommended approachTypical shape
Solo / early startup Manual DI with a single composition root file. One buildApp() function; maybe 10–20 lines. Fast to write, easy to read.
Small team (5–20 engineers) Still manual DI, possibly split into domain modules, each with its own wiring function. buildOrderModule(), buildBillingModule() — each self-contained, composed once at startup.
Growing scale-up (20–100 engineers) A lightweight container starts paying off. Consider tsyringe, InversifyJS, or the container baked into your framework. Decorators on classes, a container configured per module; request-scoped services for web apps.
Enterprise / large platform A full DI framework is almost necessary. NestJS, Spring Boot, or ASP.NET Core are built around this idea at scale. Convention-over-configuration, lifecycle hooks, module imports/exports, multi-tenant scoping.

A fintech startup might start with a 30-line composition root that wires up three services. Two years later, the same codebase has 60 services: manual wiring has become a maintenance task of its own, and the team reaches for NestJS or InversifyJS to handle the graph automatically. The switch is straightforward because the underlying pattern — constructor injection, interfaces, a clear composition root — stays the same. The container is a mechanical assistant, not a different idea.

Practical advice

Start with manual DI and a composition root. You can introduce a container any time the wiring file becomes a burden, and the migration is mechanical: you're replacing explicit new calls with registrations. Nothing in your domain, services, or tests has to change — only the composition root.

Key takeaways

  • The root cause of rigid, untestable code is a class that calls new on its own dependencies. The fix is to pass them in.
  • Inversion of Control is the principle (someone else is in charge of providing your dependencies). Dependency Injection is the most explicit and testable way to implement it.
  • Constructor injection is the default choice. Dependencies are visible in the signature, required at construction time, and easily swappable in tests.
  • The composition root is the one place in your codebase where real objects are wired together. Everything else talks through interfaces.
  • Manual DI is simpler and more debuggable for small codebases. A DI container earns its keep when the composition root itself becomes a maintenance burden.
  • The testing payoff is immediate: inject fakes in tests, skip the network and the database, and run thousands of cases in seconds.
  • This is also the Dependency Inversion Principle (the "D" in SOLID) in action: high-level modules and low-level modules both depend on abstractions, never on each other directly.
  • Avoid Service Locator (hidden global registry), over-injection (ten-argument constructors), and accessing the container outside the composition root.

With a solid understanding of how dependencies flow, the natural next question is how to organise the files themselves. In the next part of this series we look at exactly that: Structuring a Codebase: Feature Folders vs Layers — and why the answer depends on the shape of your team as much as the shape of your code.