Manage a resource's lifetime
How-to. Own a service that must be torn down — a pool, a file handle, a subscription — and have it released exactly once, on every path out. For why
Scopeis a phantom port and what the scope guarantees, see Scopes and resource safety.
Goal: a resource acquired once, released exactly once, and a graph that cannot compile if nothing would release it.
Declare the resourceful provider
acquire and release come as a pair — there is no release with nothing to release, nor an acquire never torn down:
const Persistence = Module("Persistence")({
imports: [Config],
provides: [
Provider(Database)({
inject: { config: AppConfig },
acquire: ({ config }) => openPool(config.dbUrl), // Result | AsyncResult — may fail
release: (pool) => pool.close(), // void | Promise<void>
}),
],
exports: [Database],
});acquire is make's fallible twin: it returns a Result or an AsyncResult, and a failed acquisition surfaces through the module's error channel like any other construction failure.
Choosing this arm puts the phantom Scope into the provider's Needs. That is the whole mechanism: Needs propagates through every module that imports this one, and only an entry point that actually opens a scope can discharge it.
Build through Module.scoped
const result = await Module.scoped(App, (ctx) => runServer(ctx));Module.scoped opens a scope, builds the graph, runs your callback and closes the scope before its own result settles. The close runs on every path:
- your callback succeeded — released after it settles;
- your callback failed — released, and the failure passed through untouched;
- construction itself failed halfway — everything acquired before the failure is released, in reverse order.
Module.build — no scope, no teardown — refuses the graph at compile time, and the message ends on the missing piece: required in type '{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": Scope; }'.
Under start, the process is the scope
An application booted by the kernel never calls Module.scoped itself. start accepts a Module<X, E, N> and discharges Scope itself — the resourceful module is welcome as it is — wraps it and hands it to Module.scoped, so the application scope spans the whole process: opened during building, closed on every exit path (stop(), a signal, an uncaught exception, a runtime that stopped on its own). What a finaliser reports on the way down lands in ExitReport.teardownErrors and a teardownError event, and runMain exits 2 over a non-empty list rather than 0. A resource that must live per unit rather than per process goes in a module a starter's own unit option binds instead — the runtime forks that scope around every unit it opens, through UnitHost.fork (see Open a per-request scope).
Release order and failing finalisers
Finalisers run LIFO — reverse acquisition order — so a resource is always released before whatever it was built from: the transaction before the connection, the connection before the pool. Teardown is sequential for the same reason.
A finaliser that fails is reported and swallowed, never rethrown: shutdown is not abandoned halfway, and a failed close never masks the failure that triggered the unwind. Route the report with ScopedOptions:
const options: ScopedOptions = {
onTeardownError: (portId, cause) =>
logger.error({ portId, cause }, "teardown failed"),
};
await Module.scoped(App, use, options);The default reporter writes to console.error, tagged with the port id.
onStart and onStop
Every arm — not only acquire/release — accepts optional lifecycle hooks in the same options literal:
Provider(Cache)({
inject: { config: AppConfig },
make: ({ config }) => connectCache(config),
onStart: (cache) => cache.warm(), // after the WHOLE graph is built
onStop: (cache) => cache.flush(), // during teardown, LIFO with releases
});onStartfires only once the entire graph has finished constructing — never while another provider is mid-construction — in declaration order. A hook that throws or rejects is a defect, and every hook after it is skipped; the finalisers already registered still run.onStopis teardown, so declaring one putsScopeinNeedsexactly asreleasedoes: only a scope can run it, and the compiler routes the module toModule.scopedaccordingly.
Use release for undoing an acquisition; use onStop for shutdown work on a service you did not acquire — flushing a cache built with make, stopping a consumer built with class.
See also
- Open a per-request scope — a short-lived resource over a long-lived parent, forked by the runtime.
- Scopes and resource safety — the guarantees behind the scope, and why there is no scope object in your code.
- Providers — the full construction family and the hooks, precisely.
- Entry points —
Module.scoped,Module.forkScopeandScopedOptions.