Skip to content

Incrementally migrating to Effect

Most teams cannot stop the world and rewrite an application in Effect. The good news is that you do not have to: Effect is designed to be adopted gradually, one module at a time, while the rest of your code keeps running as plain async/Promise TypeScript. The mechanics of doing this well — and the sharp edges to avoid — are not obvious, so this page lays them out concretely.

It is grounded in a real, large migration. opencode (SST’s coding agent) moved a mature Bun/TypeScript codebase onto Effect v4 service by service over several months, in the open, while shipping daily. They even kept their migration plans and post-mortems checked into the repo. The patterns below are the ones that survived that process — and the traps are the ones that actually bit them, with the fixes they landed.

Effect code is just a value until you run it, and you run it with a runtime. The entire trick of incremental adoption is to build one long-lived runtime, then call into it from your existing code with runtime.runPromise(...) wherever you need a result back as a normal Promise.

import { Effect, Layer, ManagedRuntime } from "effect"
// 1. Compose the layers your effects need into a single application layer.
const AppLayer = Layer.mergeAll(Database.layer, Logger.layer)
// 2. Build ONE runtime for the whole process. It builds the layers lazily on
// first use and memoizes them — so this is cheap to hold onto.
const AppRuntime = ManagedRuntime.make(AppLayer)
// 3. Call into Effect from ordinary async code. You get a normal Promise back.
export async function getUser(id: string): Promise<User> {
return AppRuntime.runPromise(
Database.use((db) => db.findUser(id))
)
}

That getUser is a facade: its signature is unchanged, every existing caller still does await getUser(id), but the body now runs inside Effect. This single move is what lets Effect and non-Effect code coexist indefinitely. Everything else on this page is about where to put these boundaries, what order to convert things in, and how to delete the facades once they have served their purpose.

The biggest lever is sequencing. opencode’s migration succeeded because it went leaf-first and pushed the boundaries outward, rather than starting at the entrypoint. The shape that worked:

  1. Centralize your platform edges first — in plain code. Before adopting Effect at all, opencode spent a wave of PRs funnelling every Bun.file(), Bun.write(), Bun.spawn, and glob call through a handful of internal modules (a Filesystem module, a Process utility). No Effect yet — just ordinary wrappers. This sounds unrelated, but it is what made the later swap mechanical: once every filesystem call lives behind one module, replacing its internals with an Effect FileSystem service is a contained change instead of a thousand-site edit.

  2. Start with one self-contained, boundary-heavy service. Their first Effect code was a brand-new Account service — network + database + schema validation — not a sprinkle of Schema across existing files. A leaf service with clear I/O boundaries gives you a complete, end-to-end vertical slice (service → layer → runtime → facade) to establish conventions on, without having to untangle anything.

  3. Set the conventions on day one. Write them down somewhere the whole team (and your AI tools) will see them. opencode put their rules in an AGENTS.md: Context.Service for services, Effect.fn("Name.method") for traced methods, Schema.TaggedErrorClass for errors, one module per service with flat exports. Deciding these once avoids a codebase where every service looks different.

  4. Effectify leaf services first, orchestration last. Convert services with few dependencies before the ones that depend on them. opencode’s order ran roughly: auth and platform I/O → file/format/snapshot/vcs → tool registry, plugins, LSP → config and provider → session orchestration last. The high-fan-in services (Config, Provider, Session) came last precisely because everything depends on them; converting them early would have forced you to convert everything else at the same time.

  5. Unify into one runtime once you have enough services. Early on, each service can carry its own small ManagedRuntime. After a dozen or so, collapse them into a single AppLayer = Layer.mergeAll(...) behind one AppRuntime. This is the point at which the per-service facades become redundant.

  6. Push Effect to the outermost edges last. The CLI entrypoints and the HTTP server are converted after the services they call. opencode wrapped CLI commands in a small effectCmd helper (run the handler inside AppRuntime, provide per-request context, dispose afterwards) and bridged HTTP routes one typed group at a time behind an OPENCODE_EXPERIMENTAL_HTTPAPI flag, with the legacy routes still serving everything else.

A facade is a namespace of Promise-returning functions that forward into an Effect service. You add one when you effectify a service so its callers keep working; you delete it once those callers are themselves Effects.

// auth/service.ts — the real logic now lives in an Effect service.
import { Context, Effect, Layer } from "effect"
export class Auth extends Context.Service<Auth, {
readonly get: (id: string) => Effect.Effect<Token, AuthError>
readonly set: (id: string, token: Token) => Effect.Effect<void, AuthError>
}>()("app/Auth") {}
export const layer = Layer.effect(
Auth,
Effect.gen(function* () {
const store = yield* KeyStore
// Effect.fn names the span so this method shows up in traces.
const get = Effect.fn("Auth.get")((id: string) => store.read(id))
const set = Effect.fn("Auth.set")((id: string, token: Token) => store.write(id, token))
return Auth.of({ get, set })
}),
)

The lifecycle is the important part: add the facade, migrate the service’s callers to Effect over time, then delete the facade. opencode did this in two distinct sweeps. First, once AppRuntime existed, every per-service facade was deleted in a flurry over a few days — they were redundant now that all services were reachable from one runtime. Second came a staged campaign (they literally called the final pass “Stage 4 — drop the runPromise bridges”) to collapse the inner runPromise calls that had crept into command bodies, hoisting yield* Service to the top so a whole command became one Effect with runPromise only at the true outer edge.

Bridging Effect Schema with an existing validation library

Section titled “Bridging Effect Schema with an existing validation library”

If you already validate with Zod (or similar), you do not have to choose overnight. opencode’s strategy is worth copying exactly:

Make Effect Schema the single source of truth, and derive the other representation from it — never maintain two hand-written schemas in parallel. They built a small function that walked a Schema AST and emitted a Zod type, so a module could define its schema once in Effect and still hand a ZodType to the parts of the app that still spoke Zod (config, tool arguments, HTTP DTOs).

// One source of truth, two consumers.
const UserId = Schema.String.pipe(Schema.brand("UserId"))
// Effect-native consumers use the Schema directly:
const decodeUser = Schema.decodeUnknownSync(User)
// Legacy Zod consumers get a derived schema — no second definition to keep in sync:
const UserIdZod = toZod(UserId) // walks the Schema AST → ZodType

Branded IDs are the cleanest example of “define once, project everywhere.” A single branded Schema flowed into all three systems in their stack:

const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"))
// Effect Schema — the source.
type ProjectId = typeof ProjectId.Type
// Drizzle (DB) — the column carries the brand: text().$type<ProjectId>()
// Zod (wire/validation) — derived from the Schema, replacing a bare z.string().

The instructive ending: as more domains moved to Schema, the generic Schema→Zod bridge was deleted and replaced with a few narrow, boundary-specific helpers (e.g. a Schema → JSON-Schema function just for tool arguments). Their codified rule afterwards:

Effect Schema should own the type. Boundaries should consume Effect Schema directly or use narrow boundary-specific helpers. Avoid reintroducing a generic Effect Schema → Zod bridge.

These are the day-one decisions that kept opencode’s growing service layer consistent. Adopt equivalents before you have fifty services, not after.

  • Service shape. One module per service: an interface, a class Service extends Context.Service<...>()("app/Name"), an open layer (dependencies in its R channel so tests can substitute them), and a defaultLayer that wires the production dependencies via Layer.provide(...).

    // Open layer: deps stay in R, easy to swap in tests.
    export const layer = Layer.effect(Storage, /* uses FileSystem, Clock */)
    // Default layer: production wiring.
    export const defaultLayer = layer.pipe(
    Layer.provide(NodeFileSystem.layer),
    Layer.provide(Clock.layer),
    )
  • Method definitions. Effect.fn("Service.method") for public methods (it names a span, so the method shows up in traces for free), and Effect.fnUntraced for tiny internal helpers that do not warrant a span.

  • Errors as data. Schema.TaggedErrorClass for expected domain failures, and export a domain Error union from each service module. Use Schema.Defect (not unknown) for cause fields. Reserve Effect.die/defects strictly for bugs — never for validation, missing resources, auth, or I/O failures.

    export class StorageError extends Schema.TaggedErrorClass<StorageError>()(
    "StorageError",
    { message: Schema.String, cause: Schema.optional(Schema.Defect) },
    ) {}
  • Keep services transport-agnostic. A service should not import HTTP status codes or response types. Translate domain errors to transport concerns at the boundary with Effect.catchTag/catchTags:

    session.get(id).pipe(
    Effect.catchTag("StorageNotFoundError", () => HttpError.notFound("Session not found")),
    )
  • Config and flags through a service, read once. Replace scattered process.env reads and mutable module-level flags with a Config-backed service that reads its values once at layer-build time. Do not mutate process.env after layers are built; vary behaviour in tests by providing a different layer instead.

  • Test with layers, not wrappers. Provide a real or mocked layer and run the effect with the test runner’s Effect integration. Layer.mock is ideal for partial stubs — supply only the methods a test exercises, and any accidental call to an unimplemented method fails loudly:

    const failingAuth = Layer.mock(Auth, {
    get: () => Effect.fail(new AuthError({ message: "simulated" })),
    })

This is the part worth reading twice. Almost every hard bug in opencode’s migration came from the same root confusion: building an Effect or a Layer is not the same as running it. Constructing the value runs your assembly code eagerly; the effectful code runs later, inside a fiber. Mix those up and you get bugs the type checker cannot see.

Trap 1: reading ambient context at construction time

Section titled “Trap 1: reading ambient context at construction time”

Effect’s contextual values — including anything backed by AsyncLocalStorage, process.cwd(), the current time, or ambient request state — must be read inside the effect, when it runs, not while you are assembling it.

opencode hit this with per-directory state keyed off “the current project.” Written naively, the lookup of “current directory” ran once, when the layer was first built, and froze to whatever directory triggered that build — so every other open project silently read the first one’s state:

// ❌ Reads the ambient directory ONCE, at layer-construction time.
export const get = (self) => Cache.get(self.cache, Instance.directory)
// ✅ Effect.suspend defers the read to each evaluation, in the running fiber.
export const get = (self) =>
Effect.suspend(() => Cache.get(self.cache, Instance.directory))

The same hazard exists for layers that reference sibling modules or ambient state during their build — wrap the layer body in Layer.suspend so the build graph is assembled at first run, not at module-evaluation time:

// ✅ Defer a layer whose construction touches not-yet-initialized siblings.
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)),
)

Trap 2: circular imports and the service-module pattern

Section titled “Trap 2: circular imports and the service-module pattern”

The idiomatic Effect service module exports module-scope consts (Service, layer, defaultLayer). Combine that with barrel files (index.ts that re-exports siblings) and it is alarmingly easy to create an import cycle where one module’s top-level const layer = Layer.effect(...) executes before a module it depends on has finished initializing:

lsp.ts → config/index.ts → config.ts → lsp/index.ts → lsp.ts 💥
ReferenceError: Cannot access 'X' before initialization

The fix is mechanical but the discipline matters: import siblings by their exact file path, never through the directory barrel.

- import { Config } from "." // through the barrel — cycle risk
+ import * as Config from "./config" // direct file import — safe

For genuine cycles between two services, break the runtime edge with import type (erased at compile time) for the type-only direction, and inject the real dependency through call context rather than a static import for the value direction. For cross-module value references (e.g. one schema referencing another module’s value), defer with Schema.suspend/z.lazy/Effect.suspend.

Effect.provide(someLayer) is not memoized across independent runs. Provide a layer inside a per-request or per-call effect and you rebuild that layer’s entire construction graph every single time — a silent performance and correctness bug.

// ❌ Rebuilds Project's whole layer graph on every call.
await Effect.runPromise(
Project.use((p) => p.fromDirectory(dir)).pipe(Effect.provide(Project.defaultLayer)),
)
// ✅ Build one runtime at module scope; reuse it. Layers are built once.
const runtime = ManagedRuntime.make(Project.defaultLayer)
await runtime.runPromise(Project.use((p) => p.fromDirectory(dir)))

The same mistake at the HTTP layer is .pipe(Effect.provide(SomeLayer)) inside a route handler, rebuilding per request. Hoist service acquisition to router-construction time (yield the services once when registering routes) so the layer is built when the server starts, not when a request arrives.

Trap 4: deciding sharing/freshness at the wrong site

Section titled “Trap 4: deciding sharing/freshness at the wrong site”

Layer.fresh forces a layer to build a new instance instead of sharing the memoized one. opencode initially blanket-wrapped every service in Layer.fresh at the central composition site, which made it impossible to see at a service’s definition whether it was shared or per-instance. The fix was to express freshness at each service’s own layer definition, where the intent is visible, rather than at the place that wires everything together.

Trap 5: hiding initialization order inside a layer

Section titled “Trap 5: hiding initialization order inside a layer”

It is tempting to encode “plugins must initialize before any config read” by replacing the Config layer with a decorator that wraps each method to run plugin.init() first. opencode tried exactly this and reverted it within a day. A layer that both consumes and re-provides the same service is fragile to initialize, and spreading ...config while overriding three methods means any new method silently skips the init step. Keep critical ordering explicit at a bootstrap boundary (yield* plugin.init() before you use config), not buried in a clever layer decorator.

Trap 6: changing public schemas mid-migration

Section titled “Trap 6: changing public schemas mid-migration”

Effectifying internals should be invisible to your users. Two opencode changes that altered a user-facing config shape or wire format while “just migrating” were reverted within hours. Treat schema/wire changes as deliberate, separately reviewed breaking changes — never a side effect of a refactor. Likewise, resist the urge to do a big-bang platform-API abstraction sweep: an early wholesale Bun.file → abstraction rewrite was reverted across dozens of files. Introduce platform services lazily, only inside code you are already effectifying.

Trap 7: expected failures escaping as defects

Section titled “Trap 7: expected failures escaping as defects”

If an expected failure (config parse error, validation, not-found) is thrown as a defect rather than placed on the typed error channel, your generic error boundary will swallow it. opencode shipped a regression where an invalid user config crossed the HTTP boundary as a defect and the middleware replaced a useful ConfigInvalidError with a generic UnknownError — so users saw an opaque 500 instead of “your config is invalid at this path.” Put expected failures on the error channel with Schema.TaggedErrorClass; reserve defects for bugs.

Effect v4 is in beta, and opencode tracked it continuously — including a 75-file sweep when a core service API was renamed between betas. This was tolerable because of the conventions above: flat exports and a consistent service shape made such renames a single find-and-replace. If you adopt a beta, budget for periodic mechanical renames, and keep your service modules uniform so they stay mechanical.

A rough scorecard from the migration, to calibrate expectations:

Easy / mechanicalHard / needed care
Effectifying a leaf service with clear I/OUntangling import cycles in the service-module + barrel pattern
Wrapping a service behind a Promise facadePer-instance / ambient state vs. Effect context (the Effect.suspend rule)
Deriving Zod from a single Schema sourceGetting layer construction to happen once, at the right boundary
Branded IDs flowing into DB + wire typesSequencing high-fan-in services (config, provider) so they came last
Keeping the old API stable while internals changeResisting scope creep — public schema changes and big-bang sweeps got reverted
Mechanical cross-beta API renamesDesigning the typed-error story so failures don’t leak as defects
  1. Adopt incrementally with a single long-lived runtime and runPromise facades at the boundaries.
  2. Centralize platform edges first, then go leaf-service first, then push Effect to the entrypoints last.
  3. Make Effect Schema the source of truth and derive any legacy schema from it; delete the generic bridge once domains are fully migrated.
  4. Standardize service/error/test conventions on day one so the codebase stays uniform and cross-beta renames stay mechanical.
  5. Internalize the one rule behind most bugs: constructing an Effect or Layer is not running it — defer ambient reads and sibling references with Effect.suspend / Layer.suspend, import siblings directly, and provide layers exactly once.