Documents injecute 1.x · Migrating from 0.x
Skip to content

Express ​

Express has no place for services. Apps import module-level singletons, which tests can only replace with module mocks, or hang them on app.locals and res.locals, which are untyped. With injecute:

  • Typed services in handlers. A router's factory receives the services it needs, and its handlers close over them: no app.locals casts, no container on req.
  • Wiring in one place. The composition root lists every service and what it depends on. A missing service is a compile error, not a TypeError on the first request that needs it.
  • Modules bring their own routers. Feature modules contribute routers to an extension point; the HTTP module doesn't import them.
  • Shutdown in the right order. The server stops accepting requests and finishes the open ones before the database pool closes. Express has no shutdown of its own.
  • Tests without module mocks. An isolated fork replaces the database for the whole graph, and supertest calls the same Express app.
  • Services without Express. OrderService doesn't import Express, so a queue worker or a CLI uses it as it is.

The HTTP module ​

The HTTP module owns the Express app and the server. It declares an extension point for routers, and starts the server in a lifecycle hook:

ts
// http.ts
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import express, { type Router } from 'express';
import { collect, createTag, lifecycle, type ServiceRegistry } from 'injecute';
import { storage } from './context.ts';

/** A router a module mounts under a path. */
export interface Route {
  readonly path: string;
  readonly router: Router;
}
/** The extension point: every module's routers. */
export const route = createTag('route').of<Route>();

export const addHttp = (c: ServiceRegistry<{ port: number }>) =>
  c
    .addSingleton(
      'http',
      (routes) => {
        const http = express();
        http.use(express.json());
        // after the body parsers: the handlers run in this context
        http.use((req, _res, next) =>
          storage.run(
            {
              traceId: req.get('x-request-id') ?? randomUUID(),
              tenantId: req.get('x-tenant-id'),
            },
            next,
          ),
        );
        for (const { path, router } of routes) http.use(path, router);
        return http;
      },
      [collect(route)],
    )
    .addSingleton(
      lifecycle.start('server'),
      async (http, port) => {
        const server = http.listen(port);
        await once(server, 'listening'); // rejects when the port is taken
        // undo: stop accepting connections and wait for open requests
        return () =>
          new Promise<void>((resolve, reject) =>
            server.close((error) => (error ? reject(error) : resolve())),
          );
      },
      ['http', 'port'],
    );
  • collect(route) receives the routers of every module, also from namespaces, in registration order.
  • Each request runs in storage.run(), so services read the trace id and tenant through the context accessor, without the request being passed to them.
  • The server is a hook, so running.stop() closes it before dispose() releases the connections. To force open connections closed after a timeout, see Timeouts and cancellation.

A feature module ​

A module registers its services and contributes a router. The handlers close over orders:

ts
// orders.ts
import { Router } from 'express';
import type { ServiceRegistry } from 'injecute';
import { route, type Route } from './http.ts';

export const addOrders = (c: ServiceRegistry<{ db: Database }>) =>
  c.addSingleton('orders', OrderService, ['db']).addSingleton(
    route('orders'),
    (orders): Route => {
      const router = Router();
      router.get('/', async (_req, res) => {
        res.json(await orders.list());
      });
      router.post('/', async (req, res) => {
        res.status(201).json(await orders.place(req.body.item));
      });
      return { path: '/orders', router };
    },
    ['orders'],
  );

The router is created once, with the app. Express 5 passes a rejected promise from an async handler to your error middleware; an InjecuteError there has a stable code.

The composition root ​

The host keeps the request context in AsyncLocalStorage. RequestContext and ContextAccessor are the application's types, as in Request context:

ts
// context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export const storage = new AsyncLocalStorage<RequestContext>();
export const context: ContextAccessor<RequestContext> = {
  current: () => storage.getStore(),
};
ts
// app.ts
import { DIContainer } from 'injecute';
import { context } from './context.ts';
import { addHttp } from './http.ts';
import { addOrders } from './orders.ts';

export const createApp = () =>
  new DIContainer()
    .addInstance('config', loadConfig())
    .addInstance('context', context)
    .addSingleton('port', (config) => config.port, ['config'])
    .addSingleton('db', (config) => new Database(config.databaseUrl), {
      dependencies: ['config'],
      dispose: (db) => db.close(),
    })
    .extend(addHttp)
    .namespace('Orders', addOrders)
    .seal();
ts
// main.ts
import { startLifecycle } from 'injecute';
import { createApp } from './app.ts';

const running = await startLifecycle(createApp());
process.once('SIGTERM', () =>
  running.stop().catch((error) => console.error(error)),
);

running.stop() closes the server, waits for open requests, then closes the database. For a complete entry point with a grace period and a second signal, see Graceful shutdown in Node.js.

Tests ​

Build the app, replace the database in an isolated fork, and send requests to its Express app with supertest:

ts
// orders.test.ts
import request from 'supertest';
import { expect, it } from 'vitest';
import { createApp } from './app.ts';

it('places an order', async () => {
  await using test = createApp()
    .fork({ isolated: true })
    .addInstance('db', new FakeDatabase(), { replace: true });

  const response = await request(test.get('http'))
    .post('/orders')
    .send({ item: 'book' });

  expect(response.status).toBe(201);
});

The test doesn't start the lifecycle: supertest calls the Express app without the server hook. The fork's OrderService and router are built with the fake database, createApp()'s are untouched, and await using disposes what the test created. See Testing.

Released under the MIT License.