Skip to content
Buy me a coffee

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.

services.ts
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.

@example

interface Logger { log(msg: string): void }
const Logger = createToken<Logger>('Logger');
services.addSingleton(Logger, ConsoleLogger);
const logger = await provider.getRequiredService(Logger); // typed as Logger

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.

@example

const services = new ServiceCollection();
services.addSingleton(Logger, ConsoleLogger);
services.addScoped(UserService, [Logger]);
const provider = services.buildServiceProvider({ validateScopes: true });

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
]);
app.ts
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.

The pattern is the same everywhere: create on request, dispose when the response is done.

fastify.ts
app.decorateRequest('scope', null);
app.addHook('onRequest', async (req) => {
req.scope = provider.createScope();
});
app.addHook('onResponse', async (req) => {
await req.scope.dispose();
});
koa.ts
app.use(async (ctx, next) => {
await using scope = provider.createScope(); // disposed when the middleware chain returns
ctx.state.scope = scope;
await next();
});
hono.ts
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.