Skip to content

Managed Runtime

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.

src/server.ts
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 }

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 a Promise<A>; rejects on failure. The workhorse for async handlers.
  • runPromiseExit(effect) — resolve to an Exit, 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 background Fiber.
  • runCallback(effect, cb) — for callback-style APIs.
  • dispose() / disposeEffect — finalize the layer and release every resource it acquired.

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 Runtime.