Skip to content

Open a per-request scope

How-to. Give a service the lifetime of one request, job or delivery, layered over the application scope the kernel opened once. For the option's full contract, see start and StartOptions; for why a scope is forked rather than reopened, see Scopes and resource safety.

The application scope is opened once, by the kernel, and holds the database. Reopening it per request would give every request its own empty database. What you want is a short-lived scope forked over the one already built — a per-request span, transaction or tenant context that reads what the parent constructed and is torn down when the unit closes. A starter's own unit option is that: the runtime forks the bound module itself, through UnitHost.fork, at the moment it holds the unit's own input — and no handler code ever calls Module.forkScope itself.

unit is keyed by kind. HTTP has one kind per authentication scheme plus anonymous, and forks the kind that authenticated the request; the two workers have exactly one kind each (message, activity), because a delivery is a delivery.

Recipe

  1. Write a Module whose providers are the per-unit services. Anything application-scoped they need arrives through their inject record.
  2. Bind it under its kind on the starter's own unit option — HttpModule's unit: { anonymous }, AmqpModule's unit: { message }, TemporalModule's unit: { activity } — or @btravstack/testing's testRuntime(name, { unit }).
  3. Export from the composition root whatever the unit module reads — the gate checks it at the call site, the same UNSATISFIED DEPENDENCIES one any other unmet need is refused on. What the fork seeds is the one exception: it is subtracted from what the module owes.

Step 1 — the unit module

From examples/order-api/src/request-scope.ts, a span that logs how long the request took:

ts
import { Logger } from "@btravstack/core";
import { Module, Port, Provider } from "@btravstack/di";

export class RequestSpan extends Port("RequestSpan")<{
  readonly finish: () => void;
}> {}

export const RequestModule = Module("Request")({
  // The fork seam, declared: `Logger` comes from the application scope this
  // per-request module is forked from, never from inside it.
  needs: [Logger],
  provides: [
    Provider(RequestSpan)({
      inject: { logger: Logger },
      sync: ({ logger }) => {
        const startedAt = Date.now();
        return {
          finish: () =>
            logger.info("request finished", {
              durationMs: Date.now() - startedAt,
            }),
        };
      },
      onStop: (span) => span.finish(),
    }),
  ],
  exports: [RequestSpan],
});

Logger is the kernel's port, provided at application scope by the observability() the composition root imports: the fork reads it from the parent, it does not rebuild it. onStop puts Scope in the module's needs, and only a fork (or Module.scoped) opens one — so the teardown cannot be forgotten. Its type is Module<RequestSpan, never, Logger | Scope>.

Step 2 — bind it on the composition root

RequestModule is not passed to start or runMain any more — it rides HttpModule's own unit option, in examples/order-api/src/module.ts:

ts
export const OrderApi = HttpModule("OrderApi")({
  router: orderRouter,
  fragments: orderFragments,
  unit: { anonymous: RequestModule },
  imports: [OrdersSlice, CustomersSlice, observability(), otel()],
  exports: [Logger, Tracer, Meter],
});

main.ts does not change at all:

ts
import { runMain } from "@btravstack/core";
import {
  createLogger,
  jsonSink,
  kernelEvents,
} from "@btravstack/observability";

import { OrderApi } from "./module.js";

await runMain(OrderApi, {
  onEvent: kernelEvents(createLogger(jsonSink())),
});

That is the whole of examples/order-api/src/main.tsonEvent being the separate matter of putting the kernel's own events in the same stream, covered in Log and correlate. From here each answerer forks the bound module around every unit it handles: built as the request opens, torn down as it closes, through UnitHost.fork inside its own dispatch — not host.run, which stays the kernel's alone, counting the unit towards the drain and closing the fork's scope once the unit's Result settles.

Binding anonymous alone keeps forking on every leaf, authenticated or not: a scheme that binds no module of its own falls back to anonymous. That is what the next step specialises.

Step 3 — a module per kind, and the caller it is seeded with

A unit module may inject the caller its unit was opened for. defineHttp mints one principal port per declared scheme, on auth.principals, and a second call binds the module each kind forks:

ts
export const auth = defineHttp({ authenticators: { user: userAuth } });

// The `user` kind. `auth.principals.user` carries `userAuth`'s own principal
// type, and the fork seeds it — so this module owes the composition root
// nothing for it, and names nothing else: `OrderDatabase` and `Logger` are
// owed by the modules it imports, which say so themselves, and are read out
// of the application scope this fork sits over — one Prisma client per
// process rather than one per request.
const UserUnit = Module("UserUnit")({
  needs: [auth.principals.user],
  imports: [RequestModule, OrderTenantPersistence, OrderApplicationModule],
  provides: [
    Provider(Tenant)({
      inject: { principal: auth.principals.user },
      sync: ({ principal }) => principal.tenantId,
    }),
  ],
  exports: [RequestModule, Tenant, FindOrder],
});

export const api = auth.units<{
  anonymous: typeof RequestModule;
  user: typeof UserUnit;
}>();

// A leaf declares what it reads ONCE, beside `inject`, and reads it off
// `context.unit`. `orders.find` is marked `user`, so this leaf sees `UserUnit`'s
// exports; an unmarked one would see `RequestModule`'s. `FindOrder` arrives
// already bound to the tenant, so the call names no tenant at all.
export const findOrder = api.OrpcController(
  contract,
  "orders.find",
)({
  inject: {},
  unit: { find: FindOrder },
  sync: () => ({ errors, context }, input) =>
    context.unit.find
      .execute(input.id)
      .map((order) => ({ id: order.id, quantity: order.quantity }))
      .mapErrCases((matcher) =>
        matcher.with(P.tag("OrderNotFound"), (error) =>
          errors.NOT_FOUND({ message: error.message, data: { id: error.id } }),
        ),
      ),
});

One client per process, one pinning per unit. OrderTenantPersistence is what makes that distinction: it reads OrderDatabase out of the application scope and provides Db — that same client wrapped in @btravstack/prisma/rls's tenantScoped(tenant) — then builds the repository over Db. So every statement the unit issues is pinned to the tenant the fork was seeded with, and PostgreSQL's own policy is what narrows it. The wrapper is per unit and costs nothing to build; the pool underneath is still the one the application scope holds.

The kinds arrive on a second call for a reason a single call cannot have. A unit module names auth.principals.<scheme>, so its type depends on typeof auth; if auth also depended on the modules the kinds bind, the two would be mutually recursive and TypeScript reports TS7022. auth.units<U>() hands back the same object under a narrower type — nothing is rebuilt.

The root then binds the values, and HttpModule gates them against the kinds that call declared:

ts
export const OrderApi = HttpModule("OrderApi")({
  router: orderRouter,
  unit: { anonymous: RequestModule, user: UserUnit },
  imports: [OrdersSlice, CustomersSlice, OrderPersistenceModule, observability(), otel()],
  // `OrderDatabase` beside the three observability ports: everything a forked
  // kind reads out of the application scope has to be exported from it.
  exports: [Logger, Tracer, Meter, OrderDatabase],
});

A kind nothing can open under is refused against an "UNDECLARED UNIT KIND — no request opens under it, so it would silently fall back to anonymous" marker — the fallback is what makes unit: { usre: … } otherwise silent. See the gate.

Reading a name the leaf's kind cannot provide is TypeScript's own Property 'tenant' does not exist, at the line that reads it. A leaf accepting several schemes keeps only what every one of their modules exports, since the runtime forks exactly one of them and cannot know which in advance.

The tenant a kind provides here is layer 2 of Authorize a request: a handler is left with no tenant to thread and none to get wrong, so what remains for it to decide is a question about one resource.

On a worker, the seed is the work itself

The two workers have one kind each and seed it with what they were handed: AmqpMessage(contract) carries the validated delivery, ActivityInput(contract) the validated activity input. A module naming either in needs owes the composition root nothing for it, and a piece declares what its handler reads the same way — AmqpHandler(contract, key)({ inject, unit, sync }), TemporalWorkflowActivities(contract, key)({ inject, unit, sync }), read off context.unit. Both roots gate the bound module against what the pieces declared, against a "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export" marker. See @btravstack/amqp-worker and @btravstack/temporal-worker.

What the fork gives you

  • Teardown runs inside the unit's ambient record. RequestSpan.finish runs while currentUnit() still answers, which is what gives its log line the request's own traceId. Pinned by unit-module.spec.ts: build and onStop observe the same unit.
  • The parent is built once. Two requests build the span twice and the Logger once; the fork seeds the parent's services rather than reconstructing them.
  • A failing finaliser is an event, not an exit-report entry. It is emitted as a teardownError event under the provider's port and kept off ExitReport.teardownErrors, which is the application scope's — a per-unit finaliser failing on every request would otherwise grow it without bound.

The error channel is never

AnyUnitModule = Module<never, never, unknown> is the bound every starter's unit option constrains its own type parameter to — the middle, error, channel is never. A unit is already inside the running application, so a construction failure has no modeled startup channel to land in — it rides the unit's defect path, which every runtime already answers: @btravstack/http-server writes its 500 through the path each answerer already had for any other defect — oRPC's own INTERNAL_SERVER_ERROR collapse, refuse(response, 500) for htmx — before any procedure or fragment handler is reached; a queue consumer dead-letters. Keep the unit module's providers infallible — sync, value, class, or a make/acquire whose E is never.

The gate is the ordinary one

There is no separate marker for this any more — every bound kind's module's own unmet needs simply join HttpModule's own Needs channel, so the gate that refuses them is start's ordinary UNSATISFIED DEPENDENCIES, never a fourth arm of the kernel's own marker (an import's needs travel published in its type, and a bound unit module is no different). A root that has its runtime and router but whose bound unit module needs a port nothing in the graph provides is refused the same way any other unmet need is, ending on that port — HttpUnitDep below, which HttpUnitModule declares in its own needs:

ts
const _unloggedUnit = HttpModule("WithUnitUnmet")({
  router: orderRouter,
  unit: { anonymous: HttpUnitModule },
  imports: [OrdersSlice, CustomersSlice, observability(), cache({ adapter: memoryCache() })],
  exports: [Logger],
});
// @ts-expect-error — UNSATISFIED DEPENDENCIES: nothing provides `HttpUnitDep`, which `HttpUnitModule` needs
const _withUnitUnmet = start(_unloggedUnit, options);

observability() provides Logger, so exporting it clears nothing here: what the gate reads is HttpUnitDep, which the bound unit module needs and no provider in this graph supplies. Adding provides: [Provider(HttpUnitDep)({ inject: {}, value: { value: 1 } })] is what satisfies it. It is the same rule that makes OrderApi export Logger, Tracer and Meter next to HttpRuntimeRequestModule, the module it forks per request, reads all three out of the application scope.

RuntimeHost.ctx is the application context

A unit-provided port exists only while a unit is open, and reaches a runtime through host.run's work callback alone. host.ctx.get(RequestSpan) at runtime startup type-checks against nothing and would be a defect, so the gate rejects a runtime whose resolves names a unit-only port: UNSATISFIED RUNTIME PORTS is checked against the module's exports only, never a fork's. Resolve at start what the application module itself exports.

Two more consequences for anyone writing a runtime: with a unit module the work runs only once unit.fork(module, seed) has resolved — after an await when a unit provider is async — so a runtime that subscribes to an event from inside its work must first check whether it already fired (@btravstack/http-server checks response.closed for exactly this). And a shipped runtime does not read ctx at all: what a handler needs, its provider declared.

The raw form: Module.forkScope

Outside the kernel — a test, a script, a runtime of your own — the same fork is one call on di. Module.forkScope(parent, module, use) layers module over an already-built Context, runs use with the forked context, and tears the fork down on every path:

ts
import { Module, type Context } from "@btravstack/di";
import { OkAsync } from "unthrown";

declare const parent: Context<Logger>;

const handled = Module.forkScope(parent, RequestModule, (ctx) => {
  ctx.get(RequestSpan);
  return OkAsync("handled");
});

It carries di's own UNSATISFIED DEPENDENCIES gate on the parent's exports. unit.fork(module, seed) is this call made by the runtime per unit, with the parent being host.ctx, the application context — see Modules and Entry points.

See also

Released under the MIT License.