Notes: Understanding application wiring with dependency injection containers
Some modern Node.js frameworks use dependency injection containers to wire an application.
In this article, we shall explore what it means.
Hard-coded dependency
Node.js module system provides an in-built system for wiring applications.
A lot of large-scale applications in production are built on this.
As shown in the code snippet below; modules encapsulate functionality. Module dependencies are imported.
// database.js
const uri = process.env.DB_URL;
module.exports = db.connect(uri);
// user-service.js
const dbConnection = require('./database');
const users = dbConnection('users');
const userService = () => {
return {
create: async(credentials) => {
return await users.create(credentials);
}
}
}
module.exports = userService;
// user-controller.js
const userService = require('./user-service');
const userController = () => {
return {
create: async(req, res) => {
return await userService().create(req.body);
}
}
}
module.exports = userController;
From the code snippet above the user-service.js module consumes the database.js module.
The module consuming the dependency is responsible for wiring the module. The client module imports the module dependency and instantiates it for use.
This type of wiring means the instance of the module dependency is hard-coded to the client module. Doing this makes the client module less reusable(tight coupling).
We can see that the database connection is tightly coupled to the user-service module. The module is aware of the connection state of the database. Testing functionality with mocks will be difficult.
It is important to note that the database module is stateful. It exports a singleton. The database module transfers this statefulness to modules that depend on it directly and indirectly.
A more reusable module should be agnostic about the state of the dependency. It should be stateless.
The components at the bottom of our application architecture are responsible for wiring modules (instantiating dependencies for use).
Dependency Injection
Dependency injection defines a module’s dependencies as inputs. Example inputs include function parameters, class constructor parameters, etc.
Below is basic dependency injection in JavaScript. The user-service module exports a factory function that accepts database connection as its parameter. The database connection is injected into the module via function parameters.
// user-service.js
const userService = (dbConnection) => {
const users = dbConnection('users');
return {
create: async(credentials) => {
return await users.create(credentials);
}
}
}
module.exports = userService;
The function’s parameter is an interface for consuming modules to implement.
The client module is not responsible for instantiating the module dependency. The responsibility is higher up the dependency hierarchy.
We could inject the database module into the user-service module from the user-controller module. Doing this means that the user-controller module is stateful and less reusable.
A stateful controller is normally not an issue as the most reusable components are the services. Services are responsible for the application's business logic.
Testing the user-service module with mocks becomes a lot easier.
Modern frameworks (such as Nestjs, Loopback) use decorators to inject dependencies into module classes.
// Nestjs user-service.ts
@Injectable()
export class UsersService {
constructor(
@InjectModel(Users.name) private readonly userModel: Model<User>,
) {}
public async create(
createUserDto: CreateUserDto,
): Promise<ICustomer> {
const newCustomer = await this.customerModel.create(createCustomerDto);
return newCustomer;
}
}
The @InjectModel decorator marks a constructor parameter as a target for Dependency Injection (DI).
The @Injectable marks a class as injectable into other marked classes.
This code snippet is similar to the one we had earlier even though the syntax is different.
Services are called providers in Nestjs. Loopback calls them artifacts.
Frameworks such as Loopback and Nestjs have containers as clients responsible for instantiating all application modules with their injected properties.
Dependency Injection with containers
Services are modules with defined functionalities within the application.
Services are not responsible for instantiating their dependencies. Instances of their dependencies are props.
Services are injectable. Meaning they are provided as dependencies to other services via dependency injection.
While Nestjs uses the @Injectable decorator to register services in the container, Loopback has the bind method. Registered services(artifacts) in Loopback are called bindings.
A container tracks all services in an application.
A container is responsible for instantiating all services within the application. It also resolves the dependencies of each service at runtime. The concept is called inversion of control (IOC). IOC means that the responsibility of instantiating dependencies lies with some entity at runtime and not during coding.
When the application starts, all module dependencies are resolved and instantiated for you. Nestjs calls this Bootstrapping, Loopback calls it Booting an application
Containers identify each service using a token (Loopback calls them binding key).
In Nestjs these tokens can be types (class names), strings, or symbols.
Services are injectable into other services. Their dependencies are injectable inputs.