better-effect
Integrations and adapters

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 iti
import { 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

On this page