Dependency Injection
Dependency injection (DI) is the practice of passing a module its collaborators instead of letting it create or import them itself. In Express apps this turns hard-wired require() calls into explicit parameters, so a controller receives its service, and a service receives its repository, from the outside. The payoff is decoupling and testability: any dependency can be swapped for a stub in a test or a different implementation in production without editing the consumer.
The problem with hard-wired imports
When a service reaches out and require()s its own database client, the two become permanently bound. There is no seam to substitute a fake, and the service quietly controls which implementation it uses — the opposite of inversion of control.
// services/order.service.js — tightly coupled
const db = require("../db"); // fixed dependency
const mailer = require("../mailer"); // fixed dependency
async function placeOrder(order) {
const saved = await db.orders.insert(order);
await mailer.send(order.email, "Order confirmed");
return saved;
}
To unit-test placeOrder you must mock the module system itself. Inverting control means the service stops choosing its collaborators and instead declares what it needs.
Manual constructor injection
The simplest, dependency-free approach is to wrap collaborators in a factory or class that takes them as arguments. No library required — just plain JavaScript closures or constructors.
// services/order.service.js
class OrderService {
constructor({ orderRepo, mailer }) {
this.orderRepo = orderRepo;
this.mailer = mailer;
}
async placeOrder(order) {
const saved = await this.orderRepo.insert(order);
await this.mailer.send(order.email, "Order confirmed");
return saved;
}
}
module.exports = OrderService;
The controller is built the same way — it accepts the service rather than importing it:
// controllers/order.controller.js
class OrderController {
constructor({ orderService }) {
this.orderService = orderService;
}
create = async (req, res, next) => {
try {
const order = await this.orderService.placeOrder(req.body);
res.status(201).json(order);
} catch (err) {
next(err);
}
};
}
module.exports = OrderController;
A single composition root — typically app.js — is the one place that knows concrete classes. It builds the graph once at startup and binds controllers to routes.
// app.js
const express = require("express");
const orderRepo = require("./repositories/order.repository");
const mailer = require("./mailer");
const OrderService = require("./services/order.service");
const OrderController = require("./controllers/order.controller");
const orderService = new OrderService({ orderRepo, mailer });
const orderController = new OrderController({ orderService });
const app = express();
app.use(express.json());
app.post("/orders", orderController.create);
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Now a test constructs the service with fakes and never touches the real database:
// order.service.test.js
const OrderService = require("../services/order.service");
test("placeOrder saves and sends confirmation", async () => {
const orderRepo = { insert: jest.fn().mockResolvedValue({ id: 1 }) };
const mailer = { send: jest.fn().mockResolvedValue(true) };
const service = new OrderService({ orderRepo, mailer });
const result = await service.placeOrder({ email: "[email protected]" });
expect(result).toEqual({ id: 1 });
expect(mailer.send).toHaveBeenCalledWith("[email protected]", "Order confirmed");
});
Manual injection scales fine for small and mid-sized apps. Reach for a container only when wiring the graph by hand in the composition root becomes tedious or repetitive.
Using a DI container: awilix
As graphs grow, a container automates construction and resolves dependencies by name. Awilix is a popular, framework-agnostic choice. You register each module once; it injects matching names automatically, supports lifetimes (singleton, scoped, transient), and can create a per-request scope.
npm install awilix awilix-express
// container.js
const { createContainer, asClass, asValue, InjectionMode } = require("awilix");
const orderRepo = require("./repositories/order.repository");
const mailer = require("./mailer");
const OrderService = require("./services/order.service");
const OrderController = require("./controllers/order.controller");
const container = createContainer({ injectionMode: InjectionMode.PROXY });
container.register({
orderRepo: asValue(orderRepo),
mailer: asValue(mailer),
orderService: asClass(OrderService).singleton(),
orderController: asClass(OrderController).scoped(),
});
module.exports = container;
Because we used InjectionMode.PROXY, each class receives a single object whose properties are resolved on access — which is exactly the { orderService } destructuring the classes already expect, so no code changes are needed.
// app.js
const express = require("express");
const { scopePerRequest, makeInvoker } = require("awilix-express");
const container = require("./container");
const app = express();
app.use(express.json());
app.use(scopePerRequest(container));
const order = makeInvoker((c) => c.orderController);
app.post("/orders", order("create"));
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
A POST /orders resolves a request-scoped controller, runs create, and returns:
Output:
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": 1, "email": "[email protected]" }
TypeScript: tsyringe
In TypeScript projects, tsyringe uses decorators and type metadata so dependencies are resolved by their class type rather than by string name.
import "reflect-metadata";
import { container, injectable, inject } from "tsyringe";
@injectable()
export class OrderService {
constructor(@inject("OrderRepo") private repo: OrderRepo) {}
placeOrder(order: Order) {
return this.repo.insert(order);
}
}
container.register("OrderRepo", { useClass: OrderRepoImpl });
const service = container.resolve(OrderService);
It requires experimentalDecorators and emitDecoratorMetadata enabled in tsconfig.json, plus a reflect-metadata import at the entry point.
Choosing an approach
| Approach | Dependency | Wiring | Best for |
|---|---|---|---|
| Manual injection | None | Composition root, by hand | Small / mid apps, max clarity |
| awilix | awilix | By name, auto-resolved | Larger JS apps, per-request scopes |
| tsyringe | tsyringe + decorators | By type, decorator-driven | TypeScript codebases |
Keep the container itself out of your services. A service should receive plain collaborators, never the container — otherwise it can request anything and you reintroduce hidden coupling (the “service locator” anti-pattern).
Best Practices
- Inject dependencies as constructor or factory arguments; never
require()collaborators inside business logic. - Keep one composition root (or container registration file) as the single place that knows concrete implementations.
- Depend on the shape (interface) you need, not on a concrete module, so implementations stay swappable.
- Pass collaborators into services, not the container — avoid the service-locator anti-pattern.
- Use request-scoped lifetimes for anything carrying per-request state (current user, transaction) and singletons for stateless services.
- Let DI drive your tests: construct units with lightweight fakes instead of mocking the module loader.
- Start with manual injection and adopt a container only when manual wiring becomes a maintenance burden.