Backends and Adapters
Connect Layers to ITI or your own dependency resolver.
The core depends on one small contract:
interface LayerBackend {
register(registration: LayerRegistration): MaybePromise<void>
resolve<T extends AnyServiceToken>(token: T): InstanceType<T> | PromiseLike<InstanceType<T>>
disposeAll(options?: LayerBackendDisposeOptions): MaybePromise<void>
}
type LayerBackendDisposeOptions = {
onPendingAcquisitions?: (acquisitions: readonly Promise<unknown>[]) => void | PromiseLike<void>
}The backend translates Layer registrations into a container-specific graph, resolves providers, caches according to its policy and disposes its owned state. Runtime also tracks the active Service resolution path, so provider cycles and lazy acquisition failures include useful Service diagnostics. Core types never expose ITI keys or container types.
Built-in Map backend
MapLayerBackend is the native backend. It is lazy, caches instances by
Service tag, deduplicates concurrent acquisitions, validates tagged identity
and clears its state during disposal. Runtime.make and Runtime.run use a
fresh instance automatically when no backend is supplied:
import { MapLayerBackend, Runtime } from 'better-effect'
const runtime = await Runtime.make(AppLive)
const result = await Runtime.run(AppLive, program)For compatibility, MemoryLayerBackend remains available as an alias:
import { MemoryLayerBackend } from 'better-effect/testing'
const backend = new MemoryLayerBackend()ITI adapter
Install the optional peer and import the adapter from its subpath:
bun add itiimport { ItiLayerBackend } from 'better-effect/adapters/iti'
const runtime = await Runtime.make(AppLive, {
backend: new ItiLayerBackend()
})ITI registrations use an internal deterministic key derived from
better-effect:${serviceTag}. That translation stays inside the adapter. The
application continues to yield the Service constructor.
ITI providers are lazy and cached by the container. disposeAll() disposes the
ITI container and resets the adapter state (registrations, keys and caches) so
the backend can be reused without resolving stale instances. This is not Scope
cleanup: Layer.scoped and Layer.scopedGen release callbacks belong to the
Runtime root Scope and are not registered with or released by ITI's disposer.
Write an adapter
An adapter should keep the Service token → instance relationship intact:
class MyBackend implements LayerBackend {
async register(registration: LayerRegistration) {
// translate registration.service.serviceTag into a local identifier
}
resolve<T extends AnyServiceToken>(token: T): InstanceType<T> {
const value = this.lookup(token.serviceTag)
return value as InstanceType<T>
}
async disposeAll(options?: LayerBackendDisposeOptions) {
// synchronously pass pending acquisition Promises to the hook,
// await the hook and acquisitions, then clear state
}
}Use a Map keyed by AnyServiceToken or by the literal tag internally, then
cast only at the lookup boundary. Do not weaken the public resolver to an
unrelated generic result such as resolve<A>(token): A.
Verify an adapter
better-effect/testing exports runner-neutral conformance scenarios. Each has
a stable name and run callback, creates a fresh adapter, and calls optional
adapter cleanup even when an assertion fails. The published entrypoint does not
import Bun, Vitest, or another test runner. When a disposal begins with pending
provider acquisitions, an adapter must synchronously invoke
onPendingAcquisitions with the actual readonly acquisition Promise collection,
await the callback, then await those acquisitions before clearing its state.
Register a backend with Bun. Declare whether failed acquisitions retry or stay
cached until disposeAll():
import { describe, test } from 'bun:test'
import { layerBackendContract } from 'better-effect/testing'
describe('MyBackend', () => {
for (const scenario of layerBackendContract({
makeBackend: () => new MyBackend(),
acquisitionFailure: 'retry',
cleanup: () => closeAdapterTestResources()
})) {
test(scenario.name, scenario.run)
}
})Register context storage scenarios with Vitest in exactly the same way. Use
'concurrent' only when overlapping roots and siblings remain isolated; use
'sequential' when overlap must reject with RuntimeContextOverlapError:
import { describe, it } from 'vitest'
import { runtimeContextStorageContract } from 'better-effect/testing'
describe('MyStorage', () => {
for (const scenario of runtimeContextStorageContract({
makeStorage: () => new MyStorage(),
concurrency: 'sequential'
})) {
it(scenario.name, scenario.run)
}
})Pass makeCompanionStorage to verify that the storage does not leak context
frames into the Node or explicit strategy. The in-repository suites apply these
contracts to Map, ITI, Node, and explicit implementations.
Adapter responsibilities
Convert a class token or literal tag to whatever identifier your container needs. That identifier must not leak into Service, Layer or Runtime APIs.
disposeAll disposes backend-owned container state and resets the adapter. It does not release
scoped Layer resources; a Layer provider's release callback belongs to the better-effect root
Scope and must not be registered directly with a DI container disposer.
Service identity, Scope outcomes, Layer collisions and Runtime shutdown ordering are core contracts. Keep them independent of ITI or any other container.