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.
-
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
experimentalDecoratorsandemitDecoratorMetadata, alongside runtime polyfills likereflect-metadata. -
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. -
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;
}
}
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);
});
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 };
}
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);
});
});
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)