The Runtime Theory
Software Architecture

Dependency Injection and Composition Roots: Why Containers Exist

Dependency injection moves construction out of classes and into a composition root — how constructor injection, lifetimes, and DI containers actually work, and why service locator is a trap.

The Runtime Theory Team3 min read#dependency-injection#composition-root#containers#lifetimes
On this page

Dependency injection (DI) has an unfortunate reputation: a "pattern" that ships with a framework, configuration files, and magical annotations. Strip the frameworks away and it is a two-part idea with real mechanics: (1) a class never constructs its own dependencies — they are passed in; (2) the entire object graph is built in exactly one place, the composition root, at startup. Everything else — containers, lifetimes, scopes — is tooling around those two facts.

The mechanism: constructor injection

The problem DI solves is structural. Code that constructs its own dependencies is coupled to their concrete types and cannot run without their real infrastructure:

typescript
// coupled: impossible to test without Postgres
export class OrderService {
  private repo = new PostgresOrderRepository();
 
  async fulfill(orderId: string): Promise<void> {
    const order = await this.repo.find(orderId);
    order.markFulfilled();
    await this.repo.save(order);
  }
}

Constructor injection flips it: the class declares what it needs, and receives it:

typescript
// decoupled: depends on a port, receives any implementation
export class OrderService {
  constructor(private readonly repo: OrderRepository) {}
 
  async fulfill(orderId: string): Promise<void> {
    const order = await this.repo.find(orderId);
    order.markFulfilled();
    await this.repo.save(order);
  }
}

The test is now one line: new OrderService(new InMemoryOrderRepository()). No database, no mock framework — the port is the seam (see our article on hexagonal architecture). This is the entire value of DI: testability and replaceability are structural consequences of where construction happens.

The composition root

If every class receives its dependencies, someone must construct them. The rule is that it happens in one place: the composition root, the entry point of the application.

typescript
// main.ts — the only place construction happens
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const repo = new PostgresOrderRepository(pool);
const bus = new KafkaEventBus();
const service = new OrderService(repo, bus);
const app = new HttpApp(service);
app.listen(8080);

The composition root is a one-way street: everything below it is a dependency, nothing above it constructs anything. The rule is checkable in review: a production class with a new for anything but a value type is a leak — construction escaped the root, and coupling came with it.

Why DI containers exist

Given the composition root, a container is an optional tool that automates three things the hand-rolled root does manually:

  1. Registration and wiring. You register interfaces against implementations once; the container resolves the graph — OrderService needs an OrderRepository, so resolve PostgresOrderRepository, which needs a Pool, so construct it. The hand-rolled root does the same work; the container removes the repetition when the graph is large.
  2. Lifetime management. The container remembers what it built:
    • Transient — a new instance per resolution. Cheap, no sharing, state is isolated.
    • Scoped — one instance per request/unit of work, shared within it. This is where per-request state (a database transaction, a user context) belongs.
    • Singleton — one instance for the process lifetime. For stateless services and connection pools; the highest reuse, the longest-lived state.
  3. Resolution-time composition. The graph is assembled at startup, so missing registrations fail fast at boot, not at first use.

The container is worth its weight when the graph is large enough that hand-wiring becomes a second application. It is not required for DI: the composition root above is complete, idiomatic DI with zero dependencies. Frameworks are convenience, not the pattern.

The trap: captive dependencies

Lifetime mistakes are the container's classic failure mode. The rule: a dependency cannot outlive the scope it depends on. A singleton that depends on a scoped dependency captures request-scoped state in a process-lifetime object:

typescript
// registration error: singleton capturing scoped state
container.registerSingleton(TenantContext);  // wrong
container.registerScoped(TenantContext);
 
// the singleton TenantAwareService now holds the first request's
// tenant forever — every later request reads the wrong tenant

This bug is silent: no error, wrong data, and it only appears under real traffic. The fix is structural — singletons may only depend on singletons — and container validation at startup usually catches it if enabled. Meet this bug in a test, not in production.

Service locator: the trap with a good name

The anti-pattern is not the container; it is how you reach it. Service locator means classes ask a global registry for their dependencies at use time:

typescript
// service locator: dependencies are invisible
export class OrderService {
  fulfill(orderId: string) {
    const repo = Container.get<OrderRepository>("orderRepo");
    ...
  }
}

The problems are structural: the class's dependencies are no longer visible in its signature, so tests lie about what the class needs; the graph can only be validated at runtime; and every class quietly depends on the global. Compare with constructor injection, where the constructor is the documentation and the test is new OrderService(fakeRepo). A DI container used from a composition root is not service locator — the two differ exactly in whether classes reach out or receive.

The honest tradeoff

Hand-rolled composition roots are verbose but greppable — new is explicit, the graph is visible in one file, and there is no magic. Containers compress the wiring but add configuration files, reflection-based resolution, and lifetime rules that must be learned. The right default: start with a hand-rolled root; introduce a container only when the graph's size makes the root unreadable. In both cases the architecture is the same — construction in one place, dependencies passed down, lifetimes chosen deliberately.