Recipes
Six tasks in the module layout. Code blocks start with their file path; the files on this page form one application. Every module directory also has the tsconfig.json and check command from Check one module.
Add a request-scoped service with cleanup
Providers are scoped per container by default, so each child gets its own request state and consumers. Explicit scoped marks document that intent; providerWithDisposal releases owned instances when the child closes.
// src/features/audit/contract.ts
export type Audit = { record(event: string): void; flush(): Promise<void> };
export type AuditSink = { write(lines: readonly string[]): Promise<void> };// src/features/audit/module.ts
import { DiBag } from 'di-bag';
import type { Audit, AuditSink } from './contract.js';
export const auditModule = DiBag.createBuilder().withServices({
audit: DiBag.providerWithLifetime({
provider: DiBag.providerWithDisposal({
provider: ({ sink }: { sink: AuditSink }): Audit => {
const lines: string[] = [];
return { record: event => { lines.push(event); }, flush: () => sink.write(lines.splice(0)) };
},
disposeService: audit => audit.flush(),
}), lifetime: 'scoped:one-per-container' }),
}).buildModule({ exportedServiceKeys: ['audit'] });// src/features/audit/check.ts
import { DiBag } from 'di-bag';
import type { AuditSink } from './contract.js';
import { auditModule } from './module.js';
DiBag.createBuilder().withInstalledModules([auditModule])
.withServices({ sink: (): AuditSink => ({ write: async () => {} }) })
.verifyGraphAtCompileTime() satisfies void;Open one child container per request and close it when the request ends:
// src/server.ts
import { DiBag } from 'di-bag';
import type { Audit } from './features/audit/contract.js';
import { composition } from './app.js';
type RequestContext = { requestId: string };
const app = composition.withServices({
request: DiBag.providerWithLifetime({ provider: (): RequestContext => ({ requestId: 'outside-request' }), lifetime: 'scoped:one-per-container' }),
handler: DiBag.providerWithLifetime({
provider: ({ request, audit }: { request: RequestContext; audit: Audit }) => ({ run() { audit.record(`request:${request.requestId}`); } }),
lifetime: 'scoped:one-per-container',
}),
}).buildContainer();
export async function handle(requestId: string) {
const requestContainer = app.createChildContainer(['request'], { request: (): RequestContext => ({ requestId }) });
try { requestContainer.resolve('handler').run(); }
finally { await requestContainer.close(); } // flushes this request's audit
}
export async function shutdown() { await app.close(); }Write a fixture test with an independent container
Build the module once with a default for each requirement that fails if used, then give each test an independent container with its own fixture. Independent containers are separate: close each one.
// src/features/audit/audit.test.ts
import assert from 'node:assert/strict';
import { after, test } from 'node:test';
import { DiBag } from 'di-bag';
import type { AuditSink } from './contract.js';
import { auditModule } from './module.js';
const fixture = DiBag.createBuilder()
.withInstalledModules([
auditModule,
])
.withServices({ sink: (): AuditSink => ({ write: async () => { throw new Error('supply a sink'); } }) })
.buildContainer();
after(() => fixture.close());
test('closing a child container flushes what it recorded', async () => {
const written: string[][] = [];
const testContainer = fixture.createIndependentContainer(['sink'], {
sink: (): AuditSink => ({ write: async lines => { written.push([...lines]); } }),
});
try {
const request = testContainer.createChildContainer();
request.resolve('audit').record('GET /');
await request.close();
assert.deepEqual(written, [['GET /']]);
} finally {
await testContainer.close();
}
});Run it with the module's fast check.
Build one composite from collection members
Give the composite logger a service token and its ordered sinks a collection token. The factory receives the collection as a positional dependency.
// src/features/logging/contract.ts
import { DiBag } from 'di-bag';
export type Logger = { log(line: string): void };
const loggerSymbol = Symbol('logger');
const loggerSinksSymbol = Symbol('logger sinks');
export const loggerToken = DiBag.createToken(loggerSymbol).forService<Logger>();
export const loggerSinksToken = DiBag.createToken(loggerSinksSymbol).forCollectionOf<Logger>();// src/features/logging/module.ts
import { DiBag } from 'di-bag';
import { loggerSinksToken, loggerToken, type Logger } from './contract.js';
export const loggingModule = DiBag.createBuilder()
.withCollectionContribution({ collectionToken: loggerSinksToken, provider: (): Logger => ({ log: line => console.log(line) }) })
.withCollectionContribution({ collectionToken: loggerSinksToken, provider: (): Logger => ({ log: line => { process.stderr.write(`${line}\n`); } }) })
.withTokenService(loggerToken, DiBag.createProviderFromFunction({
dependencies: [loggerSinksToken],
factoryFunction: (sinks: readonly Logger[]): Logger => ({ log: line => { for (const sink of sinks) sink.log(line); } }),
}))
.buildModule({ exportedServiceKeys: [loggerToken], moduleLabel: 'logging' });// src/features/logging/check.ts
import { DiBag } from 'di-bag';
import { loggerToken } from './contract.js';
import { loggingModule } from './module.js';
const app = DiBag.createBuilder().withInstalledModules([loggingModule]).buildContainer();
app.resolve(loggerToken).log('ready');
await app.close();Another module can contribute a sink without changing the composite factory. The collection keeps contribution order.
Split a feature into a module with private services
- Create
src/features/billing/and move the types other code uses intocontract.ts: what the module exports and what it requires from the host. - Move helpers into private files. Their registration names stay inside the module, so another module may also define a
store. - Register the factories in
module.ts, export only the entry points, and passmoduleLabel: 'billing'so runtime messages name private servicesbilling/store. - Add
check.tsandtsconfig.json, run the per-module check, then replace the old registrations insrc/app.tswith.withInstalledModules([billingModule]).
// src/features/billing/contract.ts
export type Billing = { charge(orderId: string, cents: number): Promise<string> };
export type PaymentGateway = { charge(cents: number): Promise<string> };// src/features/billing/store.ts
export const createStore = () => new Map<string, string>();
export type Store = ReturnType<typeof createStore>;// src/features/billing/module.ts
import { DiBag } from 'di-bag';
import type { Billing, PaymentGateway } from './contract.js';
import { createStore, type Store } from './store.js';
export const billingModule = DiBag.createBuilder()
.withServices({
store: createStore,
billing: ({ store, gateway }: { store: Store; gateway: PaymentGateway }): Billing => ({
async charge(orderId, cents) {
const receipt = store.get(orderId) ?? await gateway.charge(cents);
store.set(orderId, receipt);
return receipt;
},
}),
})
.buildModule({ exportedServiceKeys: ['billing'], moduleLabel: 'billing' });// src/features/billing/check.ts
import { DiBag } from 'di-bag';
import type { PaymentGateway } from './contract.js';
import { billingModule } from './module.js';
DiBag.createBuilder()
.withInstalledModules([
billingModule,
])
.withServices({ gateway: (): PaymentGateway => ({ charge: async () => 'receipt' }) })
.verifyGraphAtCompileTime() satisfies void;Debug a missing-dependency rejection
Without the gateway fixture, check.ts fails on the verifyGraphAtCompileTime() line:
// src/features/billing/check.ts
// expect-error: required services are missing: gateway
import { DiBag } from 'di-bag';
import { billingModule } from './module.js';
DiBag.createBuilder()
.withInstalledModules([
billingModule,
])
.verifyGraphAtCompileTime() satisfies void;- The text after
missing:lists the keys. WithoutverifyGraphAtCompileTime(), the same message appears where the builder expression starts. - Find who declares the key:
grep -rn "gateway" src/features/*/contract.ts src/features/*/module.ts. - Decide where it belongs. A dependency the module requires is registered by the host (
src/app.ts) and bycheck.tsand tests as a typed fixture. Add it to the module only if the module should own the implementation. - Rerun the per-module check and
src/app.check.ts.
At runtime the same problem is DI_BAG_MISSING_DEPENDENCY: details.path is the resolution chain and details.dependency the key. It is reachable only when a cast, any, or JavaScript hid the dependency from the compiler; remove the cast rather than adding a registration by trial.
Add and consume an async client
The client is created once for the container tree, awaited by consumers, and closed with the root container. Its configuration must use the singleton lifetime too.
// src/features/catalog/contract.ts
export type Catalog = { names(): Promise<string[]> };
export type Db = { query(sql: string): Promise<string[]>; end(): Promise<void> };
export type DbConfig = { url: string };// src/features/catalog/client.ts
import type { Db } from './contract.js';
export async function connect(url: string): Promise<Db> {
return { query: async () => [url], end: async () => {} }; // a driver's connect()
}// src/features/catalog/module.ts
import { DiBag } from 'di-bag';
import { connect } from './client.js';
import type { Catalog, Db, DbConfig } from './contract.js';
const source = DiBag.createProvider(async ({ config }: { config: DbConfig }) => connect(config.url), { factoryReturnKind: 'native-promise' });
const registered = DiBag.providerWithRegistrationMetadata({ provider: source, registrationMetadata: { owner: 'platform' } });
const observed = DiBag.providerWithAcquisitionMetadata({ provider: registered, callbackReceives: 'fulfilled-value', describeAcquisition: () => ({ driver: 'database' }) });
const mapped = DiBag.providerWithTransformedService({ provider: observed, callbackReceives: 'fulfilled-value', transformService: connection => connection });
const owned = DiBag.providerWithDisposal({ provider: mapped, disposeService: connection => connection.end() });
const database = DiBag.providerWithLifetime({ provider: owned, lifetime: 'singleton:one-per-container-tree' });
export const catalogModule = DiBag.createBuilder()
.withServices({
db: database,
catalog: ({ db }: { db: Promise<Db> }): Catalog => ({
names: async () => (await db).query('select name from products'),
}),
})
.buildModule({ exportedServiceKeys: ['catalog'] });// src/features/catalog/check.ts
import { DiBag } from 'di-bag';
import type { DbConfig } from './contract.js';
import { catalogModule } from './module.js';
DiBag.createBuilder().withInstalledModules([catalogModule])
.withServices({ config: DiBag.providerWithLifetime({ provider: (): DbConfig => ({ url: 'memory:' }), lifetime: 'singleton:one-per-container-tree' }) })
.verifyGraphAtCompileTime() satisfies void;A scoped config fails with singleton lifetime cannot capture scoped dependency: db -> config. If a driver returns a query builder, return Promise.resolve(builder); see structural thenable.
Own a resource a factory acquires on the way
providerWithDisposal owns the value a provider returns; pushDisposer owns what the factory acquires on the way. A resource that is both — acquired before the factory can fail, then returned — gets both, with a reason check on the push.
// src/features/feed/contract.ts
export type Socket = { send(text: string): Promise<void>; close(): Promise<void> };
export type Feed = { publish(text: string): Promise<void> };
export type FeedConfig = { url: string; token: string };// src/features/feed/client.ts
import type { Socket } from './contract.js';
export async function open(url: string): Promise<Socket> {
return { send: async () => {}, close: async () => {} }; // a driver's connect()
}
export async function authenticate(socket: Socket, token: string): Promise<void> {
await socket.send(token); // rejects on a bad token, after the socket is open
}// src/features/feed/module.ts
import { DiBag } from 'di-bag';
import { authenticate, open } from './client.js';
import type { Feed, FeedConfig, Socket } from './contract.js';
export const feedModule = DiBag.createBuilder()
.withServices({
socket: DiBag.providerWithDisposal({ provider: DiBag.createProvider(async ({ config }: { config: FeedConfig }, factoryContext): Promise<Socket> => {
const socket = await open(config.url);
factoryContext.pushDisposer(disposerContext => { if (disposerContext.reason !== 'service-disposed') return socket.close(); });
await authenticate(socket, config.token);
return socket;
}, { factoryReceivesContext: true }), disposeService: socket => socket.close() }),
feed: ({ socket }: { socket: Promise<Socket> }): Feed => ({
publish: async text => (await socket).send(text),
}),
})
.buildModule({ exportedServiceKeys: ['feed'] });A pushed disposer runs exactly once, last pushed first: at once if the factory fails, otherwise at close() after the providerWithDisposal disposer. A failed handshake closes the socket through the push; a clean shutdown closes it through providerWithDisposal, and the push sees 'service-disposed' and does nothing. The reason describes providerWithDisposal on the returned value, not ownership a consumer attaches to a transformed value. A rejecting disposer is reported through DI_BAG_DISPOSAL_FAILED; pushing after the factory settled throws DI_BAG_DISPOSER_PUSHED_AFTER_FACTORY.
Make a graph portable to browsers and workers
Hosts without process.getBuiltinModule cannot classify Promises, so every registration states its factory kind; the same module then runs on Node, Bun, Deno, and in a browser Worker.
// src/features/search/contract.ts
export type Index = { lookup(term: string): Promise<string[]>; close(): Promise<void> };
export type IndexConfig = { url: string }; export type Search = { find(term: string): Promise<string[]> };// src/features/search/module.ts
import { DiBag } from 'di-bag';
import type { Index, IndexConfig, Search } from './contract.js';
export const searchModule = DiBag.createBuilder().withServices({
index: DiBag.providerWithLifetime({
provider: DiBag.providerWithDisposal({
provider: DiBag.createProvider(async ({ config }: { config: IndexConfig }): Promise<Index> => ({
lookup: async term => [`${config.url}#${term}`],
close: async () => {},
}), { factoryReturnKind: 'native-promise' }),
disposeService: index => index.close(),
}),
lifetime: 'singleton:one-per-container-tree',
}),
search: DiBag.createProvider(({ index }: { index: Promise<Index> }): Search => ({
find: async term => (await index).lookup(term),
}), { factoryReturnKind: 'sync-value' }),
}).buildModule({ exportedServiceKeys: ['search'], moduleLabel: 'search' });// src/features/search/check.ts
import { DiBag } from 'di-bag';
import type { IndexConfig } from './contract.js';
import { searchModule } from './module.js';
DiBag.createBuilder().withInstalledModules([searchModule]).withServices({ config: DiBag.providerWithLifetime({ provider: DiBag.createProvider((): IndexConfig => ({ url: 'memory:' }), { factoryReturnKind: 'sync-value' }), lifetime: 'singleton:one-per-container-tree' }) })
.verifyGraphAtCompileTime() satisfies void;factoryReturnKind: 'sync-value' makes the exact synchronous value the service, never reads then, and rejects Promise and structural-thenable outputs at compile time. 'native-promise' requires a native Promise, exposes that Promise, and gives its fulfilled value to disposal callbacks. 'uninspected' preserves the exact returned value, including a Promise or structural thenable. Omit the option for 'auto-detect' on hosts with a native-Promise classifier.
import { DiBag } from 'di-bag';
type Config = { url: string };
declare function openCatalog(url: string): Promise<{ url: string }>;
declare function knex(table: string): { readonly table: string; then(resolve: (value: string) => void): void };
const config = DiBag.createProvider((): Config => ({ url: 'memory:' }), { factoryReturnKind: 'sync-value' });
const catalog = DiBag.createProvider(async ({ config }: { config: Config }) => openCatalog(config.url), {
factoryReturnKind: 'native-promise',
});
const query = DiBag.createProvider(() => knex('users'), { factoryReturnKind: 'uninspected' });
const app = DiBag.createBuilder().withServices({ config, catalog, query }).buildContainer();An auto-detect registration fails buildContainer() with DI_BAG_CLASSIFIER_REQUIRED on hosts without a native-Promise classifier; the error names the registration.
Review a merge
The merge check is src/app.check.ts plus the full test suite. src/app.ts installs its modules together and supplies what the modules require.
// src/app.ts
import { DiBag } from 'di-bag';
import type { AuditSink } from './features/audit/contract.js';
import { auditModule } from './features/audit/module.js';
import type { PaymentGateway } from './features/billing/contract.js';
import { billingModule } from './features/billing/module.js';
import type { DbConfig } from './features/catalog/contract.js';
import { catalogModule } from './features/catalog/module.js';
export const composition = DiBag.createBuilder()
.withInstalledModules([
auditModule,
billingModule,
catalogModule,
])
.withServices({
config: DiBag.providerWithLifetime({ provider: (): DbConfig => ({ url: 'memory:' }), lifetime: 'singleton:one-per-container-tree' }),
gateway: (): PaymentGateway => ({ charge: async cents => `receipt:${cents}` }),
sink: (): AuditSink => ({ write: async lines => { console.log(lines.join('\n')); } }),
});// src/app.check.ts
import { composition } from './app.js';
composition.verifyGraphAtCompileTime() satisfies void;- Resolve conflicts in
src/app.tsby keeping every module in thewithInstalledModuleslist. npx tsc --noEmit -p tsconfig.jsoncheckssrc/app.check.ts: a requirement no branch supplies, or a contract one branch changed under another's consumer, fails there by name.- Run the full test suite.
- Optionally, in CI:
npx di-bag-graph --checkexits 1 on dependency cycles and unresolved names before any factory runs. It reads./tsconfig.jsonunless--projectnames another, and needs its own package:npm install --save-dev di-bag-graph. The type check does not see cycles; without this step they fail at first resolve withDI_BAG_DEPENDENCY_CYCLE. The graph is a merge-review and CI artifact, not a map for finding code; the layout is the map. Options and output: di-bag-graph README.
Install modules that both require config
Rename each module value at the install site. Both factories still read config. The host supplies separate values and gives the colliding handler exports distinct names.
// src/features/orders-and-billing/contract.ts
export type OrdersConfig = { currency: string };
export type BillingConfig = { vatRate: number };// src/features/orders-and-billing/modules.ts
import { DiBag } from 'di-bag';
import type { BillingConfig, OrdersConfig } from './contract.js';
export const ordersModule = DiBag.createBuilder()
.withServices({ handler: ({ config }: { config: OrdersConfig }) => () => config.currency })
.buildModule({ exportedServiceKeys: ['handler'], moduleLabel: 'orders' });
export const billingModule = DiBag.createBuilder()
.withServices({ handler: ({ config }: { config: BillingConfig }) => () => config.vatRate })
.buildModule({ exportedServiceKeys: ['handler'], moduleLabel: 'billing' });// src/features/orders-and-billing/check.ts
import { DiBag } from 'di-bag';
import type { BillingConfig, OrdersConfig } from './contract.js';
import { billingModule, ordersModule } from './modules.js';
const app = DiBag.createBuilder()
.withInstalledModules([
ordersModule
.withRenamedRequirement({ currentRequirementKey: 'config', newRequirementKey: 'ordersConfig' })
.withRenamedExport({ currentExportKey: 'handler', newExportKey: 'ordersHandler' }),
billingModule
.withRenamedRequirement({ currentRequirementKey: 'config', newRequirementKey: 'billingConfig' })
.withRenamedExport({ currentExportKey: 'handler', newExportKey: 'billingHandler' }),
])
.withServices({
ordersConfig: (): OrdersConfig => ({ currency: 'EUR' }),
billingConfig: (): BillingConfig => ({ vatRate: 0.2 }),
})
.buildContainer();
console.log(app.resolve('ordersHandler')(), app.resolve('billingHandler')());
await app.close();