Entry points
Reference. A complete, structured description of the three functions that turn a module into running services, and of the
Contextthey hand back. For the reasoning behind the gate, see Compile errors, not surprises; behind the scope, Scopes and resource safety. Full signatures: API reference.
Three functions turn a module declaration into running services. They differ in one thing: what they do about scopes — and therefore which graphs the type system lets each accept. A process has a fourth: start, which is Module.scoped with a lifecycle around it.
The gate
Every entry point carries the same compile-time gate, as a conditional rest parameter: when the module's remaining Needs (after the exclusions each entry point is entitled to) is never, the gate is the empty tuple and the call is ordinary; when it is not, two required parameters appear — error: "UNSATISFIED DEPENDENCIES", missing: N — and the call is an arity error naming exactly what is missing. There is no value to supply for the phantom arguments; the fix is always to satisfy the need.
| Entry point | Excludes from Needs before checking |
|---|---|
Module.build | nothing |
Module.scoped | Scope |
Module.forkScope | Scope and the parent's channel |
start | Scope and Env |
Module.build(module)
const built: AsyncResult<Context<X>, E> = Module.build(App);Checks the graph, constructs every provider in dependency order, resolves to the built Context. For modules with no unmet needs at all: the gate excludes nothing, so Scope in Needs — any resourceful provider, any onStop hook, anywhere in the tree — makes the call refuse to compile. build opens no scope and runs no teardown; that is exactly why it may not accept a graph that would need one.
The Context it resolves to has no scope behind it — appropriate for services that live as long as the process.
Module.scoped(module, use, options?)
const result: AsyncResult<A, E | E2> = Module.scoped(
App,
(ctx) => useIt(ctx),
options,
);The resourceful counterpart. Opens a scope, builds the graph, hands the Context<X> to use, and closes the scope before its own result settles — on use succeeding, on use failing, and on construction failing partway (releasing whatever was acquired before the failure).
- The gate is computed from
Exclude<Needs, Scope>:Scopeis the one need this entry point discharges, by actually opening a scope. Every other unmet need still gates. - The error channel is
E | E2— construction failures anduse's own failures share the result. - A non-resourceful module is fine here too; a scope with nothing registered closes trivially.
The Context must not outlive the callback — after use settles, acquired resources are released. Do what needs services inside use.
Module.forkScope(parent, module, use, options?)
const result: AsyncResult<A, E | E2> = Module.forkScope(
appCtx,
RequestModule,
(ctx) => handle(ctx.get(Transaction)),
);A short-lived scope layered over an already-built parent Context — the per-request pattern. Constructs only module's providers, seeded with the parent's services; use receives a Context<PParent | X> carrying both.
- The gate is computed from
Exclude<Needs, PParent | Scope>: the request module may depend on anything the parent already provides — that is the point of forking over a built parent — andScopeis discharged by the fresh scope this call opens. Anything neither satisfies still gates. - Closing the fork releases only what the fork acquired: the parent's finalisers were registered on the parent's scope, not this one. The parent stays up for sibling forks and for whatever follows.
- Forks nest: a fork's
usemay fork again over the context it received.
Under the kernel you rarely call this yourself: StartOptions.unit names a module the kernel forks around every unit, and the same gate is checked at start's call site as UNSATISFIED UNIT NEEDS. See Open a per-request scope.
ScopedOptions
Accepted by Module.scoped and Module.forkScope:
type ScopedOptions = {
readonly onTeardownError?: (portId: string, cause: unknown) => void;
};Called once per finaliser (release or onStop) that fails during scope close, tagged with the failing provider's port id. Failures are reported and swallowed: teardown continues past them, and the entry point's own result is never changed by one — a failed close must not mask the failure that triggered the unwind. The default reporter writes to console.error. A throwing reporter is itself swallowed; there is nowhere left to report a broken reporter to.
Context
What entry points hand back or pass to callbacks:
const service = ctx.get(SomePort); // typed exactly as the port declared| Member | Meaning |
|---|---|
ctx.get(port) | Returns the constructed service. Only ports in the context's channel — the module's Exports (plus the parent's, in a fork) — compile; everything else is unnameable. |
Context.empty() | A Context<never> with nothing in it. Useful as a typed starting point in tests. |
Context<in R> | The type. R is the union of port instance types it carries; it is contravariant, so a Context<A | B> may be passed where a Context<A> is expected — never the reverse. |
A Context is immutable and read-only from the outside: get is its entire public surface. Services construct once per build; every get returns the same instance.
start(module, options?) — the process entry point
@btravstack/core's start accepts a Module<X, E, Scope | Env>, provides Env to it, and hands it to Module.scoped — so the application scope is opened as the process boots and closed on every exit path, with what its finalisers report surfacing as ExitReport.teardownErrors. It adds a gate of its own on top of di's (NO RUNTIME, UNSATISFIED RUNTIME NEEDS, UNSATISFIED UNIT NEEDS), and RunningApp rather than a Context is what it hands back: the runtime, not the caller, is what reads the built context.
Construction order and failure
Shared by all three entry points:
- The provider tree is flattened (de-duplicated by reference — a diamond constructs once) and checked; nothing has run yet if a check fails.
- Providers are grouped into dependency levels. Each level constructs concurrently; levels run strictly in order.
- On a failure, siblings already in flight settle, then the build stops — later levels never start. Within a level, the failure reported is the first
Errin declaration order; a defect in the level outranks anyErr. Under a scope, everything acquired so far is then released. onStarthooks fire only after the whole graph is built, sequentially, in declaration order; one that throws or rejects is a defect and stops the rest.