DEV Community

Cover image for Composition Root in Node.js: Dependency Injection Without a DI Framework
Emrullah Bozkurt
Emrullah Bozkurt

Posted on

Composition Root in Node.js: Dependency Injection Without a DI Framework

Introduction: The Quest for Clean Architecture in Node.js

When building backend applications in Node.js, managing dependencies between services, database adapters, external HTTP clients, and configuration providers is one of the most critical structural challenges. Many developers coming from Java or .NET immediately reach for runtime Dependency Injection (DI) frameworks like InversifyJS, TypeDI, or NestJS's built-in DI module.

However, Dependency Injection is simply a structural design pattern, whereas DI Containers are optional framework tools. In Node.js, these containers are often overkill. This article explores how to achieve clean architecture, decoupling, and effortless testability using the Composition Root pattern without any DI framework whatsoever.

What is a Composition Root?

A Composition Root is the unique entry point of an application where the entire dependency graph is instantiated, wired up, and bootstrap-configured. Typically, this is your main.ts or index.ts file.

Instead of modules importing their dependencies dynamically at runtime or pulling them directly from global state, dependencies are passed strictly down through constructors. At the very edge of your application—the entry point—you instantiate everything in sequence.

{{DIAGRAM_1}}

As shown above, the dependencies must be resolved sequentially in topological order. The low-level utilities (like configurations and database clients) are instantiated first, followed by repositories, services, and finally HTTP servers or controller layers.

The Pitfalls of DI Containers in JavaScript and TypeScript

While DI containers promise seamless dependency management, they introduce significant complexity to JavaScript and TypeScript projects.

  1. Lack of Native Reflection: TypeScript does not exist at runtime. Unlike C# or Java, there is no built-in reflection API. To make DI containers work, frameworks must rely on non-standard, experimental TS compiler options like experimentalDecorators and emitDecoratorMetadata, alongside runtime polyfills like reflect-metadata.
  2. Implicit Dependency Matching: Containers resolve dependencies using runtime tokens (such as strings or Symbols). This bypasses the compiler, making it possible to miss a binding that only fails at runtime when a service returns undefined.
  3. Vendor Lock-in: Annotating business logic with framework-specific decorators like @Injectable() tightly couples your pure domain logic to a specific DI container library.

Implementing Explicit Factory Wiring with Interfaces

To ensure complete isolation of our business logic, we should inject interfaces rather than concrete classes. This adheres to the Dependency Inversion Principle (the 'D' in SOLID). In TypeScript, we can use an interface to define the contract, and standard constructor functions to compose the dependencies manually.

Let's write a simple, clean, working backend architecture starting with our types and classes.

// 1. types.ts
export interface User {
  id: string;
  name: string;
}

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
}

// 2. database.ts
export class DatabaseClient {
  constructor(private readonly connectionUri: string) {}

  async connect(): Promise<void> {
    console.log(`Connected to database at ${this.connectionUri}`);
  }

  async disconnect(): Promise<void> {
    console.log("Disconnected database client.");
  }

  async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
    console.log(`Executing: ${sql} with params:`, params);
    // Mock implementation
    return [{ id: "123", name: "Alice" }] as unknown as T[];
  }
}

// 3. userRepository.ts
import { DatabaseClient } from "./database";
import { IUserRepository, User } from "./types";

export class UserRepository implements IUserRepository {
  constructor(private readonly db: DatabaseClient) {}

  async findById(id: string): Promise<User | null> {
    const rows = await this.db.query<User>("SELECT * FROM users WHERE id = $1", [id]);
    return rows[0] || null;
  }
}

// 4. userService.ts
import { IUserRepository } from "./types";

export class UserService {
  // Depend on the interface, not the concrete class
  constructor(private readonly userRepo: IUserRepository) {}

  async getUserDetails(id: string) {
    const user = await this.userRepo.findById(id);
    if (!user) throw new Error("User not found");
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Composition Root with Lifecycle Management

Our Composition Root is responsible for both construction and lifecycle coordination. It instantiates the classes in topological order, invokes asynchronous startup methods (like establishing the database connection), and listens for termination signals to perform clean, graceful shutdowns.

// main.ts (The Composition Root)
import { DatabaseClient } from "./database";
import { UserRepository } from "./userRepository";
import { UserService } from "./userService";

async function bootstrap() {
  // 1. Resolve configuration environment
  const dbUri = process.env.DATABASE_URL ?? "postgres://localhost:5432/db";

  // 2. Instantiate dependencies in topological order
  const databaseClient = new DatabaseClient(dbUri);
  const userRepository = new UserRepository(databaseClient);
  const userService = new UserService(userRepository);

  // 3. Coordinate startup lifecycle
  await databaseClient.connect();
  console.log(`Initializing application components...`);

  const user = await userService.getUserDetails("123");
  console.log(`Successfully bootstrapped. Sample user:`, user);

  // 4. Handle graceful shutdown
  const gracefulShutdown = async (signal: string) => {
    console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
    await databaseClient.disconnect();
    process.exit(0);
  };

  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}

bootstrap().catch((err) => {
  console.error("Fatal error during bootstrap:", err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Scaling Pure DI Beyond Simple Files

Skeptics often worry that manual wiring becomes unmanageable as the application grows. If you have 150 services, your main.ts file will grow into a massive block of imperative code.

To prevent this, split your composition logic into modular domain sub-factories. Your central Composition Root simply coordinates these domain factories:

// userModule.ts (Sub-factory)
import { DatabaseClient } from "./database";
import { UserRepository } from "./userRepository";
import { UserService } from "./userService";

export function composeUserModule(databaseClient: DatabaseClient) {
  const userRepository = new UserRepository(databaseClient);
  const userService = new UserService(userRepository);
  return { userService, userRepository };
}
Enter fullscreen mode Exit fullscreen mode

This keeps your central main.ts highly readable, serving purely as a high-level router/orchestrator of modules.

Type-Safe Mocking Without Framework Hacks

Because we write constructors that accept explicit interfaces, testing is trivial and completely type-safe without relying on magic container overrides or casting tricks. The compiler guarantees that our mock complies with the contract.

// userService.test.ts
import { UserService } from "./userService";
import { IUserRepository, User } from "./types";

describe("UserService", () => {
  it("should fetch user details correctly via mocked repository", async () => {
    const mockUser: User = { id: "123", name: "Test User" };

    // Compiler-verified mock implementing IUserRepository
    const mockRepo: IUserRepository = {
      findById: async (id: string): Promise<User | null> => {
        return id === "123" ? mockUser : null;
      }
    };

    // Secure compile-time injection
    const userService = new UserService(mockRepo);
    const result = await userService.getUserDetails("123");

    expect(result).toEqual(mockUser);
  });
});
Enter fullscreen mode Exit fullscreen mode

Readability, Traceability, and Cold-Start Performance Benefits

Choosing explicit wiring over dynamic metadata DI containers offers immediate engineering advantages across three categories: readability, developer tooling, and execution performance.

1. Developer Tooling and Traceability

When navigating a container-wired codebase, finding where a class is registered can feel like detective work. In contrast, using pure DI preserves the effectiveness of your IDE's "Go to Definition" function.

{{DIAGRAM_2}}

2. Comparison Matrix: Pure DI vs. Container Frameworks

Feature Explicit Wiring (Pure DI) Container-based DI (e.g., Inversify, TypeDI)
Runtime Overhead Zero (Standard JS/TS objects) Medium (Metadata loading, container lookups)
IDE Navigation Native "Go to Definition" works out-of-the-box Broken or requires manual Symbol/String tracing
Type Safety 100% Compiler verified Partial (Relies on runtime decorator string/symbol tokens)
Boilerplate / Setup Minimal (One main.ts file setup) High (Requires configurations, decorators, reflection)
Cold-Start Performance Excellent (Instantaneous) Slower (Impacted by dynamic class decorator scanning)

3. Cold-Start Performance

Removing DI container frameworks entirely eliminates initialization overhead. In serverless deployment environments like AWS Lambda, Azure Functions, or Google Cloud Run, cold-start latency is critical. A pure DI application avoids class scanning, decorator metadata compilation, and container resolution cycles, booting instantly to process user requests.

Conclusion: Keeping Node.js Applications Simple and Explicit

You do not need heavy framework setups to build clean, maintainable, testable backend architectures. By utilizing a Composition Root combined with interface-based Pure Dependency Injection, your Node.js code remains fully type-safe, simple to test, traceably navigable, and highly performant.

Top comments (0)