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.
The one idea everything rests on
Section titled “The one idea everything rests on”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 order that works
Section titled “The order that works”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:
-
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 (aFilesystemmodule, aProcessutility). 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 EffectFileSystemservice is a contained change instead of a thousand-site edit. -
Start with one self-contained, boundary-heavy service. Their first Effect code was a brand-new
Accountservice — network + database + schema validation — not a sprinkle ofSchemaacross 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. -
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.Servicefor services,Effect.fn("Name.method")for traced methods,Schema.TaggedErrorClassfor errors, one module per service with flat exports. Deciding these once avoids a codebase where every service looks different. -
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. -
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 singleAppLayer = Layer.mergeAll(...)behind oneAppRuntime. This is the point at which the per-service facades become redundant. -
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
effectCmdhelper (run the handler insideAppRuntime, provide per-request context, dispose afterwards) and bridged HTTP routes one typed group at a time behind anOPENCODE_EXPERIMENTAL_HTTPAPIflag, with the legacy routes still serving everything else.
The facade pattern, and removing it
Section titled “The facade pattern, and removing it”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 }) }),)// auth/index.ts — the OLD public API, unchanged for callers.import { Auth } from "./service"import { AppRuntime } from "../runtime"
// Existing call sites still do `await Auth.get(id)` — they never changed.export namespace AuthFacade { export const get = (id: string) => AppRuntime.runPromise(Auth.use((a) => a.get(id))) export const set = (id: string, token: Token) => AppRuntime.runPromise(Auth.use((a) => a.set(id, token)))}// Once callers are themselves Effects, the facade is dead weight.// They now compose the service directly instead of awaiting a Promise:
const program = Effect.gen(function* () { const auth = yield* Auth const token = yield* auth.get(id) // ...})
// ...and the entire AuthFacade namespace is simply removed.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 → ZodTypeBranded 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.
Conventions worth standardizing early
Section titled “Conventions worth standardizing early”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, aclass Service extends Context.Service<...>()("app/Name"), an openlayer(dependencies in itsRchannel so tests can substitute them), and adefaultLayerthat wires the production dependencies viaLayer.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), andEffect.fnUntracedfor tiny internal helpers that do not warrant a span. -
Errors as data.
Schema.TaggedErrorClassfor expected domain failures, and export a domainErrorunion from each service module. UseSchema.Defect(notunknown) forcausefields. ReserveEffect.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.envreads and mutable module-level flags with aConfig-backed service that reads its values once at layer-build time. Do not mutateprocess.envafter 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.mockis 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" })),})
Where it gets painful
Section titled “Where it gets painful”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 initializationThe 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 — safeFor 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.
Trap 3: providing layers on the hot path
Section titled “Trap 3: providing layers on the hot path”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.
A note on beta churn
Section titled “A note on beta churn”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.
What was easy, and what was hard
Section titled “What was easy, and what was hard”A rough scorecard from the migration, to calibrate expectations:
| Easy / mechanical | Hard / needed care |
|---|---|
| Effectifying a leaf service with clear I/O | Untangling import cycles in the service-module + barrel pattern |
| Wrapping a service behind a Promise facade | Per-instance / ambient state vs. Effect context (the Effect.suspend rule) |
Deriving Zod from a single Schema source | Getting layer construction to happen once, at the right boundary |
| Branded IDs flowing into DB + wire types | Sequencing high-fan-in services (config, provider) so they came last |
| Keeping the old API stable while internals change | Resisting scope creep — public schema changes and big-bang sweeps got reverted |
| Mechanical cross-beta API renames | Designing the typed-error story so failures don’t leak as defects |
Takeaways
Section titled “Takeaways”- Adopt incrementally with a single long-lived runtime and
runPromisefacades at the boundaries. - Centralize platform edges first, then go leaf-service first, then push Effect to the entrypoints last.
- Make Effect
Schemathe source of truth and derive any legacy schema from it; delete the generic bridge once domains are fully migrated. - Standardize service/error/test conventions on day one so the codebase stays uniform and cross-beta renames stay mechanical.
- 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.