# 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**](https://github.com/sst/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.
**Note:** This is a strategy guide, not an API reference. For the v3 → v4 rename maps see
[Migrating from v3](https://effect.plants.sh/additional-resources/migrating-from-v3/); for the service
idiom itself see [Services & Layers](https://effect.plants.sh/services-and-layers/), and for the runtime
boundary see [Runtime](https://effect.plants.sh/runtime/).

## 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`.

```ts
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.
**Caution:** Build the runtime **once** and reuse it. The most common early mistake is
calling `Effect.runPromise(effect.pipe(Effect.provide(AppLayer)))` on every
invocation — that rebuilds the entire layer graph each call. See
[Provide layers once, never per call](#trap-3-providing-layers-on-the-hot-path).

## 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:

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.
**Tip:** There is a natural "shape" to a half-migrated codebase: a **pure Effect core**
(services and their layers), a **thin facade ring** translating to/from Promises,
and **plain code at the edges** (entrypoints, UI). The migration is the process
of growing the core and shrinking the ring until the facades meet the edges.

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

```ts
// 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 })
  }),
)
```

```ts
// 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)))
}
```

```ts
// 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.
**Tip:** A good signal that a facade is ready to delete: grep for its callers and find
they are all already inside `Effect.gen`/`Effect.fn` blocks. At that point the
facade is just an `await` wrapping something that wants to be `yield*`.

## 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).

```ts
// 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:

```ts
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.
**Note:** A generic bridge is the right tool *during* the transition — it buys you time
without forking your schemas. It is the wrong tool *after*: once a domain is
fully on `Schema`, a general-purpose AST translator is a large, leaky surface
that is better replaced by small per-boundary functions and then removed.

## 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`, 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(...)`.

  ```ts
  // 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.

  ```ts
  export class StorageError extends Schema.TaggedErrorClass<StorageError>()(
    "StorageError",
    { message: Schema.String, cause: Schema.optional(Schema.Defect) },
  ) {}
  ```

- **Keep services transport-agnostic.** A service should not // through the barrel — cycle risk
+ // direct file import — safe
```
**Danger:** TypeScript will **not** catch this. It resolves *types* lazily and never
evaluates your module-scope expressions, so the `ReferenceError` only appears at
runtime — and sometimes only in a bundled/compiled build, where the bundler
reorders module initialization. opencode's reliable detector was to eagerly
evaluate the whole module graph in a build step and watch for the error, rather
than trusting that dev mode exercised it.

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

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

```ts
// ❌ 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.
**Tip:** When you do need many runtimes (e.g. several services that each carry one early
in the migration), have them share a single memo map —
`ManagedRuntime.make(layer, { memoMap })` with
`const memoMap = Layer.makeMemoMapUnsafe()` — so a layer that appears in several
runtimes is still only constructed once across the process.

### 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

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

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

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

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

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

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.
**Note:** opencode's migration was still ongoing as of this writing — that is the realistic
picture. A gradual migration is not a project with an end date so much as a
direction you steer the codebase in, shipping the whole time. You can read their
living migration notes under `packages/opencode/specs/effect/` and their codified
rules in `packages/opencode/AGENTS.md`.