Middlewares and events
A middleware wraps every resolution: it can time it, change it, or answer for keys that are not registered. An event listener only observes. Reach for events first.
Rules
use(middleware)adds a middleware;unuse(middleware)removes it. The last added runs first.- A middleware receives
(key, next, context). Callnext()to continue,next(otherKey)to resolve another key.contexthascontainer(read-only),path(keys being resolved) anddepth. - The chain runs once per
get(), including for nested dependencies (with a higherdepth). - Forks inherit their ancestors' middlewares live: one added to the parent later applies to existing forks.
fork({ middlewares: false })opts out. - Dependencies of a service registered in a parent are resolved in the parent, through the parent's middlewares (the parent owns that instance). In an isolated fork they are resolved in the fork.
Tracing and timing
ts
const tracing: Middleware = (key, next, { depth }) => {
const start = performance.now();
const value = next();
const ms = (performance.now() - start).toFixed(1);
lines.push(`${' '.repeat(depth)}${String(key)} ${ms}ms`);
return value;
};
const app = new DIContainer()
.use(tracing)
.addInstance('config', { url: 'postgres://localhost' })
.addSingleton('db', (config) => ({ config }), ['config']);
app.get('db');
// lines: [" config 0.0ms", "db 0.1ms"] (dependencies are nested)Fallbacks and dynamic keys
ts
// Resolve `env.*` keys from the environment when nothing is registered under them.
const env: Record<string, string> = { PORT: '8080' };
const app = new DIContainer().use((key, next) => {
const value = next();
if (
value === undefined &&
typeof key === 'string' &&
key.startsWith('env.')
) {
return env[key.slice('env.'.length)];
}
return value;
});
// Keys a middleware provides are not in the container's type; declare them where you use them:
const port = app.get('env.PORT' as never) as string; // "8080"Keys a middleware provides are not in the container's type. When the values are known up front, a registration is simpler and typed. To register services found at startup (files in a directory, decorated classes), see Custom registration.
Keeping renamed keys working
ts
// Keep an old key working while callers migrate to the new one.
const renamed: Record<string, string> = { mailer: 'emailSender' };
const app = new DIContainer()
.addSingleton('emailSender', () => ({
send: (to: string) => `sent to ${to}`,
}))
.use((key, next) => {
const newKey = typeof key === 'string' ? renamed[key] : undefined;
if (newKey === undefined) return next();
warnings.push(`"${String(key)}" is deprecated, use "${newKey}"`);
return next(newKey);
});
const mailer = app.get('mailer' as never) as { send(to: string): string };
mailer.send('ada@example.com'); // works, and records a deprecation warningGuards
ts
// Request-scoped services must never be resolved from the root container.
const root = new DIContainer()
.addSingleton('config', () => ({ region: 'eu' }))
.use((key, next, { container }) => {
if (
typeof key === 'string' &&
key.startsWith('request.') &&
container === root
) {
throw new Error(
`"${key}" is request-scoped: resolve it from a request fork`,
);
}
return next();
});
const request = root.fork().addInstance('request.user', { id: 42 });
request.get('request.user'); // ok
// root.get('request.user' as never) would throwTest doubles
ts
const app = new DIContainer().addSingleton('clock', () => ({
now: () => Date.now(),
}));
// A temporary override, removed after the test.
const fakes = new Map<PropertyKey, unknown>([['clock', { now: () => 0 }]]);
const withFakes: Middleware = (key, next) =>
fakes.has(key) ? fakes.get(key) : next();
app.use(withFakes);
const frozen = app.get('clock').now(); // 0
app.unuse(withFakes);
const real = app.get('clock').now(); // the real clock againTo replace a dependency for a whole graph, an isolated fork is usually clearer.
When not to use a middleware
- Decorating instances (wrapping a service in a proxy): a middleware runs on every
get(), so it would return a new wrapper each time and break singleton identity. Decorate with a self dependency instead. - Observing: use events.
Events
ts
const app = new DIContainer()
.addEventListener('add', ({ key }) => events.push(`added ${String(key)}`))
.addEventListener('produce', ({ key }) =>
events.push(`created ${String(key)}`),
)
.addEventListener('dispose', () => events.push('disposed'))
.addSingleton('db', () => ({ connected: true }));
app.get('db'); // "created db"
app.get('db'); // cached: no event| Event | When | Payload |
|---|---|---|
add | a registration is added or replaced | key, replace, kind, container |
replace | a registration is replaced | key, previous (a RegistrationInfo), container |
reset | reset() cleared cached instances | keys, resetParent, container |
get | a service is resolved (also for dependencies) | key, value, container |
produce | a factory created an instance | key, value, container |
dispose | dispose() finished | container |
container is a read-only view of the container that emitted the event.