ManagedRuntime
Not every entry point is an Effect. Web frameworks, message-queue consumers, and
legacy callback APIs call you with plain functions, and they expect promises
or synchronous returns — not effects. ManagedRuntime is the bridge: you build
one runtime from your application’s Layer, and it lets you run effects from
imperative code while keeping all your domain logic in services and layers.
import { Context, Effect, Layer, ManagedRuntime, Ref, Schema } from "effect"import { Hono } from "hono"
class Todo extends Schema.Class<Todo>("Todo")({ id: Schema.Number, title: Schema.String, completed: Schema.Boolean}) {}
class CreateTodoPayload extends Schema.Class<CreateTodoPayload>("CreateTodoPayload")({ title: Schema.String}) {}
class TodoNotFound extends Schema.TaggedErrorClass<TodoNotFound>()("TodoNotFound", { id: Schema.Number}) {}
// Ordinary Effect service — no awareness of Hono or HTTP at all.export class TodoRepo extends Context.Service<TodoRepo, { readonly getAll: Effect.Effect<ReadonlyArray<Todo>> getById(id: number): Effect.Effect<Todo, TodoNotFound> create(payload: CreateTodoPayload): Effect.Effect<Todo>}>()("myapp/TodoRepo") { static readonly layer = Layer.effect( TodoRepo, Effect.gen(function*() { const store = new Map<number, Todo>() const nextId = yield* Ref.make(1)
const getAll = Effect.sync(() => Array.from(store.values()))
const getById = Effect.fn("TodoRepo.getById")(function*(id: number) { const todo = store.get(id) if (todo === undefined) return yield* new TodoNotFound({ id }) return todo })
const create = Effect.fn("TodoRepo.create")(function*(payload: CreateTodoPayload) { const id = yield* Ref.getAndUpdate(nextId, (n) => n + 1) const todo = new Todo({ id, title: payload.title, completed: false }) store.set(id, todo) return todo })
return TodoRepo.of({ getAll, getById, create }) }) )}
// Build ONE runtime from the application layer, shared by every handler. It owns// the lifecycle of TodoRepo and anything it depends on.const runtime = ManagedRuntime.make(TodoRepo.layer)
const app = new Hono()
app.get("/todos", async (c) => { // `runPromise` runs an effect and returns a Promise — exactly what Hono wants. // `TodoRepo.use` reaches the service without an Effect.gen wrapper. const todos = await runtime.runPromise(TodoRepo.use((repo) => repo.getAll)) return c.json(todos)})
app.get("/todos/:id", async (c) => { const id = Number(c.req.param("id")) if (!Number.isFinite(id)) return c.json({ message: "id must be a number" }, 400)
// Handle the typed error at the boundary, mapping it to an HTTP status. const todo = await runtime.runPromise( TodoRepo.use((repo) => repo.getById(id)).pipe( Effect.catchTag("TodoNotFound", () => Effect.succeed(null)) ) )
return todo === null ? c.json({ message: "not found" }, 404) : c.json(todo)})
const decode = Schema.decodeUnknownEffect(CreateTodoPayload)
app.post("/todos", async (c) => { const body = await c.req.json() // Decode at the edge; on success continue into the repo, all in one effect. const result = await runtime.runPromiseExit( Effect.gen(function*() { const payload = yield* decode(body) return yield* TodoRepo.use((repo) => repo.create(payload)) }) ) return result._tag === "Success" ? c.json(result.value, 201) : c.json({ message: "invalid request body" }, 400)})
// Dispose the runtime on shutdown so layer resources are released.const shutdown = () => { void runtime.dispose() }process.once("SIGINT", shutdown)process.once("SIGTERM", shutdown)
export { app, runtime }How it works
Section titled “How it works”ManagedRuntime.make(layer) builds the layer lazily and hands you an object with
imperative run methods. Each one provides the layer’s services to the effect
you pass, so your handlers never deal with requirements directly:
runPromise(effect)— run and resolve to aPromise<A>; rejects on failure. The workhorse for async handlers.runPromiseExit(effect)— resolve to anExit, so success and failure are both values you can branch on (used above to turn a decode failure into a 400).runSync(effect)— run a synchronous effect and return its value; only works when the effect has no async boundaries.runFork(effect)— start the effect as a backgroundFiber.runCallback(effect, { onExit })— for callback-style APIs.dispose()/disposeEffect— finalize the layer and release every resource it acquired.
One runtime, shared across handlers
Section titled “One runtime, shared across handlers”Build the runtime once at module scope and reuse it for every request. Creating a runtime per request would rebuild your entire layer graph each time — reopening connection pools, re-running setup. A single shared runtime builds the graph once and amortizes it across all traffic.
If your app runs several runtimes that should share memoized layers (for example, separate runtimes per worker that all use the same database layer), pass a shared memo map so a layer included in both is built only once:
import { Layer, ManagedRuntime } from "effect"import { TodoRepo } from "./server.ts"
// A process-wide memo map shared across runtimes.const memoMap = Layer.makeMemoMapUnsafe()
const runtimeA = ManagedRuntime.make(TodoRepo.layer, { memoMap })const runtimeB = ManagedRuntime.make(TodoRepo.layer, { memoMap })The same bridge pattern works for Express, Fastify, Koa, worker queues, and any
other framework — only the surrounding glue changes. For a fully Effect-native
HTTP server (no ManagedRuntime glue), see HTTP API. For more on
running effects and runtime configuration, see running effects.
Lifecycle
Section titled “Lifecycle”A ManagedRuntime<R, ER> has a deliberately simple lifecycle:
- Created lazily.
ManagedRuntime.makereturns immediately without building anything. The layer is built the first time anyrun*,context, orcontextEffectis used. - Context cached. Once built, the resulting
Context<R>is cached on the runtime and reused for every subsequent run — no rebuild, no re-acquisition. - Resources owned. Anything the layer acquires (connections, servers, files, background fibers) lives in a scope owned by the runtime.
- Disposed once.
dispose()/disposeEffectcloses that scope, running every finalizer. After disposal the runtime cannot be reused — itscontextEffectis replaced with a defect, so further runs die with"ManagedRuntime disposed".
import { Context, Effect, Layer, ManagedRuntime } from "effect"
class Db extends Context.Service<Db, { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>}>()("Db") { // `Layer.effect` with an acquire/release finalizer simulates a connection pool. static readonly layer = Layer.effect( Db, Effect.acquireRelease( Effect.sync(() => { console.log("open pool") return Db.of({ query: () => Effect.succeed([]) }) }), () => Effect.sync(() => console.log("close pool")) ) )}
const runtime = ManagedRuntime.make(Db.layer)
await runtime.runPromise(Db.use((db) => db.query("SELECT 1")))// => "open pool" (built lazily on first run)await runtime.runPromise(Db.use((db) => db.query("SELECT 2")))// => (no rebuild — context is cached)
await runtime.dispose()// => "close pool" (finalizers run)
// Reusing a disposed runtime is a defect, not a recoverable error.await runtime.runPromise(Db.use((db) => db.query("SELECT 3")))// => rejects: "ManagedRuntime disposed"Sharing a MemoMap across runtimes
Section titled “Sharing a MemoMap across runtimes”The memoMap option lets multiple runtimes share the memoization that Layer
construction uses internally. A layer that appears in more than one runtime is
built once and its context shared — useful when each runtime is a thin
wrapper around the same expensive base layer.
import { Effect, Layer, ManagedRuntime } from "effect"
let built = 0const Shared = Layer.effectDiscard(Effect.sync(() => { built++ }))
const memoMap = Layer.makeMemoMapUnsafe()const a = ManagedRuntime.make(Shared, { memoMap })const b = ManagedRuntime.make(Shared, { memoMap })
await a.runPromise(Effect.void)await b.runPromise(Effect.void)console.log(built)// => 1 (Shared built once, reused by both runtimes)Without a shared memoMap, each make call uses its own — created via
Layer.makeMemoMapUnsafe() — so the shared layer would be built twice. You can
also build standalone layers against the runtime’s own map by reading
runtime.memoMap (see below). For the full memoization story see
Layer memoization and the Layer.MemoMap /
Layer.makeMemoMap / Layer.forkMemoMap APIs.
Integration examples
Section titled “Integration examples”Express + graceful shutdown
Section titled “Express + graceful shutdown”The runtime is built once at module scope and disposed on shutdown signals.
Domain logic stays in services; the handler is a thin runPromise boundary.
import { Context, Effect, Layer, ManagedRuntime, Schema } from "effect"import express from "express"
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()("UserNotFound", { id: Schema.Number}) {}
class Users extends Context.Service<Users, { find(id: number): Effect.Effect<{ id: number; name: string }, UserNotFound>}>()("Users") { static readonly layer = Layer.succeed(Users)({ find: Effect.fn("Users.find")(function*(id: number) { if (id !== 1) return yield* new UserNotFound({ id }) return { id, name: "Ada" } }) })}
const runtime = ManagedRuntime.make(Users.layer)const app = express()
app.get("/users/:id", async (req, res) => { const id = Number(req.params.id) const exit = await runtime.runPromiseExit(Users.use((u) => u.find(id))) if (exit._tag === "Success") { res.json(exit.value) } else { res.status(404).json({ message: "not found" }) }})
const server = app.listen(3000)const shutdown = () => server.close(() => void runtime.dispose())process.once("SIGINT", shutdown)process.once("SIGTERM", shutdown)Tests (Vitest)
Section titled “Tests (Vitest)”In a test suite, build a runtime in beforeAll from a test layer and dispose it
in afterAll. Each test runs effects with runPromise, getting your real
services with test implementations swapped in.
import { Context, Effect, Layer, ManagedRuntime } from "effect"import { afterAll, beforeAll, expect, test } from "vitest"
class Clock extends Context.Service<Clock, { readonly now: Effect.Effect<number> }>()("Clock") { static readonly test = Layer.succeed(Clock)({ now: Effect.succeed(0) })}
let runtime: ManagedRuntime.ManagedRuntime<Clock, never>
beforeAll(() => { runtime = ManagedRuntime.make(Clock.test)})
afterAll(() => runtime.dispose())
test("clock reads zero", async () => { const now = await runtime.runPromise(Clock.use((c) => c.now)) expect(now).toBe(0) // => 0})Reference
Section titled “Reference”ManagedRuntime.make(layer, options?) is the only constructor. Every other entry
point is a method or property of the returned ManagedRuntime<R, ER>. The two
type parameters are the layer’s provided services R and the layer’s
construction error ER — ER is merged into the error channel of every
runner, because building the layer can fail.
ManagedRuntime.make
Section titled “ManagedRuntime.make”Creates a runtime from a Layer<R, ER, never> (the layer must have no remaining
requirements). The optional { memoMap } lets the runtime share layer
memoization with other builds.
import { Layer, ManagedRuntime } from "effect"
// Signature:// make<R, ER>(// layer: Layer.Layer<R, ER, never>,// options?: { readonly memoMap?: Layer.MemoMap }// ): ManagedRuntime<R, ER>
declare const layer: Layer.Layer<"MyService", "BuildError", never>const runtime = ManagedRuntime.make(layer)// => ManagedRuntime<"MyService", "BuildError">.runPromise
Section titled “.runPromise”Runs an effect and returns a Promise<A> that resolves with the success value or
rejects with the first failure/defect. The default runner for async
handlers. Accepts Effect.RunOptions (e.g. { signal } for cancellation).
import { Effect, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
await runtime.runPromise(Effect.succeed(42))// => 42
await runtime.runPromise(Effect.fail("boom")).catch((e) => e)// => "boom" (Promise rejects).runPromiseExit
Section titled “.runPromiseExit”Like runPromise, but the Promise always resolves with an Exit<A, E | ER>
— never rejects. Branch on Exit.isSuccess / _tag to handle failure as a value.
import { Effect, Exit, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
const exit = await runtime.runPromiseExit(Effect.fail("boom"))Exit.isFailure(exit)// => trueexit._tag// => "Failure".runSync
Section titled “.runSync”Runs a synchronous effect and returns its value directly. Throws if the
effect fails or hits an asynchronous boundary — use runPromise for async work.
import { Effect, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
runtime.runSync(Effect.sync(() => 1 + 1))// => 2.runSyncExit
Section titled “.runSyncExit”The non-throwing variant of runSync: returns an Exit<A, E | ER> instead of
throwing. A failure to run synchronously (an async boundary) shows up as a
failure in the Exit.
import { Effect, Exit, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
const exit = runtime.runSyncExit(Effect.succeed("ok"))Exit.isSuccess(exit)// => true.runFork
Section titled “.runFork”Starts the effect on a background Fiber<A, E | ER> and returns it immediately,
without waiting. Fibers started this way are attached to the runtime’s scope, so
they are interrupted when the runtime is disposed. Accepts Effect.RunOptions.
import { Effect, Fiber, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
const fiber = runtime.runFork(Effect.succeed("done"))const result = await runtime.runPromise(Fiber.await(fiber))// => Exit.succeed("done").runCallback
Section titled “.runCallback”Runs the effect asynchronously and invokes onExit with the final
Exit<A, E | ER>. Returns a function you can call to interrupt the running
fiber. Use it to bridge Node-style callback APIs.
import { Effect, Exit, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
const cancel = runtime.runCallback(Effect.succeed(123), { onExit: (exit) => { if (Exit.isSuccess(exit)) console.log(exit.value) // => 123 }})
// Call the returned function to interrupt before completion:cancel().dispose
Section titled “.dispose”Closes the runtime’s scope from Promise-land, running every finalizer the
layer registered, and returns Promise<void>. Call it during process shutdown.
After disposal the runtime is unusable.
import { ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)await runtime.dispose()// => undefined (resources released).disposeEffect
Section titled “.disposeEffect”The Effect version of dispose — an Effect<void, never, never> you can
compose inside other effects (for instance with Effect.ensuring so the runtime
is torn down when a top-level program finishes).
import { Effect, ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
const program = Effect.log("working").pipe( Effect.ensuring(runtime.disposeEffect))
await runtime.runPromise(program)// => logs "working", then disposes the runtime.context
Section titled “.context”Forces the layer to build (if it hasn’t) and returns a Promise<Context<R>> of
the cached service context. Handy when you need the raw Context to hand to
lower-level Effect APIs.
import { Context, Effect, Layer, ManagedRuntime } from "effect"
class Greeter extends Context.Service<Greeter, { readonly hi: string }>()("Greeter") { static readonly layer = Layer.succeed(Greeter)({ hi: "hello" })}
const runtime = ManagedRuntime.make(Greeter.layer)
const context = await runtime.context()Context.get(context, Greeter).hi// => "hello".contextEffect
Section titled “.contextEffect”An Effect<Context<R>, ER> that builds the layer (lazily, once) and yields the
cached context. This is the effectful counterpart to context(); the run*
methods use it internally to provide services to your effects.
import { Context, Effect, Layer, ManagedRuntime } from "effect"
class Greeter extends Context.Service<Greeter, { readonly hi: string }>()("Greeter") { static readonly layer = Layer.succeed(Greeter)({ hi: "hello" })}
const runtime = ManagedRuntime.make(Greeter.layer)
const hi = await runtime.runPromise( Effect.map(runtime.contextEffect, (ctx) => Context.get(ctx, Greeter).hi))// => "hello" (layer built lazily, context cached).memoMap
Section titled “.memoMap”The Layer.MemoMap this runtime uses for layer memoization — either the one you
passed via options.memoMap or a fresh one created with
Layer.makeMemoMapUnsafe(). Read it to build additional layers that share this
runtime’s memoization.
import { Effect, Layer, ManagedRuntime, Scope } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
runtime.memoMap// => the Layer.MemoMap backing this runtime
// Build an extra layer against the same memo map:const extra = await runtime.runPromise( Effect.scoped( Effect.gen(function*() { const scope = yield* Scope.make() return yield* Layer.buildWithMemoMap(Layer.empty, runtime.memoMap, scope) }) ))ManagedRuntime.isManagedRuntime
Section titled “ManagedRuntime.isManagedRuntime”Type guard that narrows an unknown to ManagedRuntime<unknown, unknown> by
checking the internal marker. Note: a disposed runtime still carries the
marker, so this does not prove the runtime is still usable.
import { ManagedRuntime, Layer } from "effect"
const runtime = ManagedRuntime.make(Layer.empty)
ManagedRuntime.isManagedRuntime(runtime)// => trueManagedRuntime.isManagedRuntime({})// => falseType helpers: ManagedRuntime.Services / ManagedRuntime.Error
Section titled “Type helpers: ManagedRuntime.Services / ManagedRuntime.Error”Type-level utilities that extract the provided services R and the layer
construction error ER from a ManagedRuntime type.
import type { ManagedRuntime } from "effect"
declare const runtime: ManagedRuntime.ManagedRuntime<"MyService", "BuildError">
type R = ManagedRuntime.ManagedRuntime.Services<typeof runtime>// => "MyService"type E = ManagedRuntime.ManagedRuntime.Error<typeof runtime>// => "BuildError"