Express and HTTP servers
In a web server, “scoped” means “per request”: the request’s database transaction, the current user, a unit of work. Create a scope when the request arrives, resolve from it in handlers, and dispose it when the response is done. This is what ASP.NET Core does for you; in Node it is one middleware.
The services
Section titled “The services”export interface interface User
User { User.id: number
id: number; User.name: string
name: string;}
export const const DbUrl: InjectionToken<string>
DbUrl = createToken<string>(name: string): InjectionToken<string>
Creates a typed token.
createToken<string>('DbUrl');
export class class Db
Db { constructor(private readonly Db.conn: { query<T>(sql: string, args?: unknown[]): Promise<T[]>; close(): Promise<void>;}
conn: type Awaited<T> = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F, ...args: infer _): any;} ? F extends (value: infer V, ...args: infer _) => any ? Awaited<V> : never : T
Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to never. This emulates the behavior of await.
Awaited<type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function type
ReturnType<typeof function connect(url: string): Promise<{ query<T>(sql: string, args?: unknown[]): Promise<T[]>; close(): Promise<void>;}>
connect>>) {} Db.query<T>(sql: string, args?: unknown[]): Promise<T[]>
query<function (type parameter) T in Db.query<T>(sql: string, args?: unknown[]): Promise<T[]>
T>(sql: string
sql: string, args: unknown[] | undefined
args?: unknown[]) { return this.Db.conn: { query<T>(sql: string, args?: unknown[]): Promise<T[]>; close(): Promise<void>;}
conn.query<T>(sql: string, args?: unknown[]): Promise<T[]>
query<function (type parameter) T in Db.query<T>(sql: string, args?: unknown[]): Promise<T[]>
T>(sql: string
sql, args: unknown[] | undefined
args); } async Db.onDestroy(): Promise<void>
onDestroy() { await this.Db.conn: { query<T>(sql: string, args?: unknown[]): Promise<T[]>; close(): Promise<void>;}
conn.function close(): Promise<void>
close(); // runs when the provider is disposed at shutdown }}
export class class RequestContext
RequestContext { RequestContext.userId?: number | undefined
userId?: number;}
export class class UserRepository
UserRepository { constructor(private readonly UserRepository.db: Db
db: class Db
Db) {} UserRepository.findAll(): Promise<User[]>
findAll() { return this.UserRepository.db: Db
db.Db.query<User>(sql: string, args?: unknown[]): Promise<User[]>
query<interface User
User>('select id, name from users'); }}
export class class UserService
UserService { constructor(private readonly UserService.users: UserRepository
users: class UserRepository
UserRepository, private readonly UserService.ctx: RequestContext
ctx: class RequestContext
RequestContext) {} UserService.list(): Promise<User[]>
list() { return this.UserService.users: UserRepository
users.UserRepository.findAll(): Promise<User[]>
findAll(); }}
export const const services: ServiceCollection
services = new new ServiceCollection(): ServiceCollection
Collects service registrations, then builds a
ServiceProvider
.
Modeled on .NET's IServiceCollection.
ServiceCollection() .ServiceCollection.addValue<string>(token: Token<string>, value: string): ServiceCollection
Registers an existing value (config, constants, an instance you built). Any value works, null and undefined included. Never disposed by the container.
addValue(const DbUrl: InjectionToken<string>
DbUrl, const process: { env: Record<string, string | undefined>;}
process.env: Record<string, string | undefined>
env.string | undefined
DATABASE_URL ?? 'postgres://localhost/app') .ServiceCollection.addSingleton<Db>(token: Token<Db>, factory: ServiceFactory<NoInfer<Db>>): ServiceCollection (+4 overloads)
Registers a singleton: one instance for the root provider and all its scopes.
addSingleton(Class) / addSingleton(Class, [deps]): the class is its own token.
addSingleton(token, Implementation, [deps]): deps are checked against the constructor's parameters.
addSingleton(token, (provider) => instance): a factory, may be async.
addSingleton(class Db
Db, async (p: ServiceProvider
p) => new constructor Db(conn: Awaited<ReturnType<typeof connect>>): Db
Db(await function connect(url: string): Promise<{ query<T>(sql: string, args?: unknown[]): Promise<T[]>; close(): Promise<void>;}>
connect(await p: ServiceProvider
p.ServiceProvider.getRequiredService<string>(token: Token<string>): Promise<string>
Resolves token, throwing ServiceNotFoundError if it is not registered.
getRequiredService(const DbUrl: InjectionToken<string>
DbUrl)))) .ServiceCollection.addScoped<RequestContext>(token: Newable<RequestContext, []>): ServiceCollection (+4 overloads)
Registers a scoped service: one instance per scope. Same forms as
addSingleton
.
addScoped(class RequestContext
RequestContext) .ServiceCollection.addScoped<UserRepository, [db: Db]>(token: Newable<UserRepository, [db: Db]>, dependencies: readonly [db: Token<Db>]): ServiceCollection (+4 overloads)
Registers a scoped service: one instance per scope. Same forms as
addSingleton
.
addScoped(class UserRepository
UserRepository, [class Db
Db]) .ServiceCollection.addScoped<UserService, [users: UserRepository, ctx: RequestContext]>(token: Newable<UserService, [users: UserRepository, ctx: RequestContext]>, dependencies: readonly [users: Token<UserRepository>, ctx: Token<RequestContext>]): ServiceCollection (+4 overloads)
Registers a scoped service: one instance per scope. Same forms as
addSingleton
.
addScoped(class UserService
UserService, [class UserRepository
UserRepository, class RequestContext
RequestContext]);The request scope middleware
Section titled “The request scope middleware”import express from 'express';import type { ServiceProvider } from '@ylcnfrht/ioc';import { services, UserService, RequestContext } from './services.ts';
declare global { namespace Express { interface Request { scope: ServiceProvider; } }}
const provider = services.buildServiceProvider({ validateScopes: true, validateOnBuild: true });const app = express();
app.use((req, res, next) => { const scope = provider.createScope(); req.scope = scope; let disposed = false; const dispose = () => { if (disposed) return; disposed = true; scope.dispose().catch((err) => console.error('scope dispose failed', err)); }; res.on('finish', dispose); // response sent res.on('close', dispose); // client went away next();});
app.use(async (req, _res, next) => { const ctx = await req.scope.getRequiredService(RequestContext); ctx.userId = Number(req.header('x-user-id')) || undefined; next();});
app.get('/users', async (req, res, next) => { try { const users = await req.scope.getRequiredService(UserService); res.json(await users.list()); } catch (err) { next(err); }});
const server = app.listen(3000);
process.on('SIGTERM', () => { server.close(async () => { await provider.dispose(); // closes Db and other singletons });});Every service resolved through req.scope within one request shares the same RequestContext and UserRepository.
The next request gets new ones. scope.dispose() is idempotent, so listening to both finish and close is safe.
Fastify, Koa and Hono
Section titled “Fastify, Koa and Hono”The pattern is the same everywhere: create on request, dispose when the response is done.
app.decorateRequest('scope', null);app.addHook('onRequest', async (req) => { req.scope = provider.createScope();});app.addHook('onResponse', async (req) => { await req.scope.dispose();});app.use(async (ctx, next) => { await using scope = provider.createScope(); // disposed when the middleware chain returns ctx.state.scope = scope; await next();});app.use(async (c, next) => { await using scope = provider.createScope(); c.set('scope', scope); await next();});Koa and Hono run the rest of the chain inside await next(), so await using disposes the scope after the handler
finishes. In Express the handler doesn’t await anything you can wrap, so use the response events.