Urgent.News

What's breaking now, across thousands of outlets.

Tech

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,…

When developing backend applications in Node.js, managing dependencies between various services, databases, external HTTP clients, and configuration providers presents a significant challenge. Developers often turn to runtime Dependency Injection (DI) frameworks like InversifyJS or NestJS's built-in DI module to handle this complexity.

However, DI containers can be considered optional tools rather than necessary components in Node.js. This article explores how to achieve a clean architecture, decoupling, and efficient testability using the Composition Root pattern without relying on any DI framework.

A Composition Root serves as the unique starting point of an application, where all dependencies are instantiated, wired up, and bootstrapped. In Node.js, this is typically done in the main.ts or index.ts file. Instead of dynamically importing modules at runtime or directly pulling dependencies from global state, dependencies are passed strictly through constructors.

The dependencies must be resolved sequentially in topological order, starting from low-level utilities like configurations and database clients, followed by repositories, services, and finally HTTP servers or controller layers.

While DI containers promise seamless dependency management in JavaScript and TypeScript projects, they introduce considerable complexity. The lack of native reflection in TypeScript, which differs from C# or Java, necessitates the use of non-standard, experimental TS compiler options such as experimentalDecorators and emitDecoratorMetadata, along with runtime polyfills like reflect-metadata.

Moreover, containers resolve dependencies using runtime tokens (strings or Symbols) instead of relying on the compiler, making it possible to miss bindings that only cause runtime errors when a service returns null. This tight coupling via annotations like @Injectable() can also lead to vendor lock-in, where business logic becomes dependent on a specific DI container library.

To maintain complete isolation of business logic, it is recommended to inject interfaces rather than concrete classes, adhering to the Dependency Inversion Principle (the D in SOLID). In TypeScript, interfaces can define contract specifications, while standard constructor functions can be used to manually compose dependencies. Let's examine a simple, clean, and functional backend architecture by starting with the types and classes.

First, we define the types in the 'types.ts' file:

- User: Represents a user with an id and name.

- IUserRepository: Defines the contract for retrieving a user by their id.

Next, we implement the DatabaseClient class in 'database.ts', which manages database connections and provides methods for executing queries. Note that this example includes placeholder implementations for logging connections and query executions.

The UserRepository class, located in 'userRepository.ts', imports the DatabaseClient and IUserRepository interfaces. It implements the IUserRepository contract, demonstrating how to query the database for a user by id using the query method provided by DatabaseClient.

Finally, in 'userService.ts', we import the IUserRepository interface and construct a UserService. This service depends on the IUserRepository interface rather than a concrete class, exemplifying the principle of injecting interfaces instead of concrete implementations. The async getUserDetails method retrieves a user by id, throwing an error if the user is not found.

The Composition Root, as described in this article, encompasses both the construction and lifecycle management of the application. It is responsible for orchestrating the instantiation of classes in the correct topological order, initializing asynchronous tasks such as establishing a database connection, and handling graceful shutdowns upon receiving termination signals. This approach ensures that the business logic remains decoupled, testable, and maintainable without the overhead of a DI framework.

Written by urgent.news from Dev.to's reporting — not their text. Machine-written — may contain errors; check the original before relying on it.

Read the original at dev.to →

More in Tech

More from Monday 5 October →