Recipes
A unit of work per operation
A service creates what one operation owns with a factory, and using releases it:
/** The repositories of one business operation, bound to one transaction. */
class UnitOfWork {
constructor(private readonly tx: Transaction) {}
insertOrder(item: string): void {
this.tx.query(`insert ${item}`);
}
commit(): void {
this.tx.commit();
}
[Symbol.dispose](): void {
this.tx[Symbol.dispose](); // rolls back unless committed
}
}
class Checkout {
constructor(private readonly work: { begin(): UnitOfWork }) {}
placeOrder(item: string, quantity: number): void {
// the unit of work lives in this block and is disposed at its end, also when it throws
using work = this.work.begin();
work.insertOrder(item);
if (quantity < 1) throw new Error('quantity must be at least 1');
work.commit();
}
}
// The container provides the factory, a singleton; the caller owns what it creates.
const app = new DIContainer()
.addSingleton('db', Database)
.addSingleton(
'unitOfWork',
(db) => ({ begin: () => new UnitOfWork(db.begin()) }),
['db'],
)
.addSingleton('checkout', Checkout, ['unitOfWork']);
app.get('checkout').placeOrder('book', 1); // tx1: insert book, tx1: commitMore patterns for state that belongs to one unit of work, and a table for choosing between them, are in Scoped state.
A scope
A fork for one unit of work, such as a request or a transaction, is a scope: several services that belong to it share its instances and use the app's services, and the scope disposes them together:
const app = new DIContainer()
.addSingleton('db', Database)
.addSingleton('gateway', Gateway);
// Several services share one transaction and also need the app's services: the fork wires them,
// and disposes the transaction at the end (rolled back unless committed).
export async function placeOrder(item: string, quantity: number) {
await using scope = app
.fork()
.addSingleton('tx', (db) => db.begin(), ['db'])
.addSingleton('orders', OrderRepository, ['tx'])
.addSingleton('payments', Payments, ['tx', 'gateway']);
scope.get('orders').insert(item);
if (quantity < 1) throw new Error('quantity must be at least 1');
scope.get('payments').charge(quantity * 10);
scope.get('tx').commit();
}Create the scope in one place (the handler or a framework hook) and dispose it at the end. Its services may depend on the root's, never the reverse. See Scopes for scope modules and how scopes compare with the other patterns.
Choosing implementations from configuration
// Hey, try change mailer value to 'console', see the graph on right side
const config = { mailer: 'smtp' as 'smtp' | 'console' };
const app = new DIContainer()
.addSingleton('smtpMailer', (): Mailer => ({ send: (to) => `smtp → ${to}` }))
.addSingleton('consoleMailer', (): Mailer => ({
send: (to) => `console → ${to}`,
}))
// choose the implementation once, at the composition root
.addAlias(
'mailer',
config.mailer === 'smtp' ? 'smtpMailer' : 'consoleMailer',
);
app.get('mailer').send('ada@example.com'); // "smtp → ada@example.com"Feature modules
Give each feature a module and compose them at the root:
const app = new DIContainer()
.addInstance('config', loadConfig())
.extend(addDatabase) // (c: ServiceRegistry<{ config: Config }>) => …
.extend(addUsers) // (c: ServiceRegistry<{ db: Database }>) => …
.extend(addBilling);Keep each module in a wiring file of its own; see Keep the wiring in files of its own.
When a module expects a service under another key or in another shape, see Adapting a module's dependencies.
A module's private services
Register services only a module uses under symbol keys, and apply the module as a namespace:
const cache = Symbol('cache'); // not exported
const addCatalog = (c: ServiceRegistry<{ db: Database }>) =>
c
.addSingleton(cache, () => new Map<string, string>())
.addSingleton('products', ProductService, ['db', cache]);
app.namespace('Catalog', addCatalog); // 'Catalog.products' resolves; the cache doesn'tSee Private services.
An SDK for other teams
Build the SDK's services in a container of its own (a new one per call), and give consumers an object typed by the SDK's interface:
/** The services the SDK object exposes, and what it needs to start and stop. */
type Core = ServiceProvider<{
api: ApiClient;
'Flags.flags': FeatureFlags;
'Telemetry.telemetry': Telemetry;
}> & { dispose(): Promise<void> };
/** Creates the SDK. Nothing runs until a service is used or `start()` is called. */
export function createPlatform(options: PlatformOptions): Platform {
return toPlatform(createCore(options));
}
function toPlatform(core: Core): Platform {
let running: Promise<RunningLifecycle> | undefined;
let stopped: Promise<void> | undefined;
const stop = () =>
(stopped ??= (async () => {
// a failed start already undid its hooks and disposed the container
const run = await running?.catch(() => undefined);
await (run ? run.stop() : running ? undefined : core.dispose());
})());
return {
get api() {
return core.get('api');
},
get flags() {
return core.get('Flags.flags');
},
get telemetry() {
return core.get('Telemetry.telemetry');
},
async start() {
running ??= startLifecycle(core);
await running;
},
stop,
[Symbol.asyncDispose]: stop,
};
}Consumers get lazy services, start() and stop(), plugins that replace, decorate or extend its services, and a test kit. See Building an SDK.
Sharing services between independent containers
import { addNamedResolvers, createNamedResolvers, DIContainer } from 'injecute';
const shared = createNamedResolvers(core, ['db', 'logger']);
const feature = new DIContainer().extend(addNamedResolvers(shared));
feature.get('db'); // resolved by `core`Exposing a container as a plain object
import { createProxyAccessor } from 'injecute';
const services = createProxyAccessor(app, {
keys: ['users', ['mailer', 'email']],
});
services.users; // resolves 'users'
services.email; // resolves 'mailer'Checking the wiring at startup
import { preload } from 'injecute';
await preload(app); // resolves every service now, so configuration errors surface at bootpreload() stops at the first failure, which is what a starting app needs. In a test, use checkWiring() instead: it reports every problem at once.
To run modules' startup work (routes, subscriptions, consumers) and stop it gracefully, see Startup and shutdown.
buildServicesGraph(app) returns every service with its dependencies, for tooling and diagrams; the playground uses it.