Skip to content
Express.js ex patterns 5 min read

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

ApproachDependencyWiringBest for
Manual injectionNoneComposition root, by handSmall / mid apps, max clarity
awilixawilixBy name, auto-resolvedLarger JS apps, per-request scopes
tsyringetsyringe + decoratorsBy type, decorator-drivenTypeScript 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.
Last updated June 14, 2026
Was this helpful?