17 KiB
Effect patterns
Practical reference for new and migrated Effect code in packages/opencode.
Choose scope
Use InstanceState (from src/effect/instance-state.ts) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a ScopedCache keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use makeRuntime (from src/effect/run-service.ts) to create a per-service ManagedRuntime that lazily initializes and shares layers via a global memoMap. Returns { runPromise, runFork, runCallback }.
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
Rule of thumb: if two open directories should not share one copy of the service, it needs InstanceState.
Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, runPromise, and async facade functions:
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
}
Rules:
- Keep everything in one namespace, one file — no separate
service.ts/index.tssplit runPromisegoes inside the namespace (not exported unless tests need it)- Facade functions are plain
async function— nofn()wrappers - Use
Effect.fn("Namespace.method")for all Effect functions (for tracing) - No
Layer.fresh— InstanceState handles per-directory isolation
Schema → Zod interop
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the zod() helper from @/util/effect-zod:
import { zod } from "@/util/effect-zod"
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
See Auth.ZodInfo for the canonical example.
InstanceState init patterns
The InstanceState.make init callback receives a Scope, so you can use Effect.acquireRelease, Effect.addFinalizer, and Effect.forkScoped inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by ScopedCache. This makes it the right place for:
- Subscriptions: Yield
Bus.Serviceat the layer level, then useStream+forkScopedinside the init closure. The fiber is automatically interrupted when the instance scope closes:
const bus = yield * Bus.Service
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* (ctx) {
// ... load state ...
yield* bus.subscribeAll().pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
/* handle */
}),
),
Effect.forkScoped,
)
return {
/* state */
}
}),
)
- Resource cleanup: Use
Effect.acquireReleaseorEffect.addFinalizerfor resources that need teardown (native watchers, process handles, etc.):
yield *
Effect.acquireRelease(
Effect.sync(() => nativeAddon.watch(dir)),
(watcher) => Effect.sync(() => watcher.close()),
)
- Background fibers: Use
Effect.forkScoped— the fiber is interrupted on disposal. - Side effects at init: Config notification, event wiring, etc. all belong in the init closure. Callers just do
InstanceState.get(cache)to trigger everything, andScopedCachededuplicates automatically.
The key insight: don't split init into a separate method with a started flag. Put everything in the InstanceState.make closure and let ScopedCache handle the run-once semantics.
Effect.cached for deduplication
Use Effect.cached when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
// Inside the layer — yield* to initialize the memo
let cached = yield * Effect.cached(loadExpensive())
const get = Effect.fn("Foo.get")(function* () {
return yield* cached // concurrent callers share the same fiber
})
// To invalidate: swap in a fresh memo
const invalidate = Effect.fn("Foo.invalidate")(function* () {
cached = yield* Effect.cached(loadExpensive())
})
Prefer Effect.cached over these patterns:
- Storing a
Fiber.Fiber | undefinedwith manual check-and-fork (e.g.file/index.tsensure) - Storing a
Promise<void>task for deduplication (e.g.skill/index.tsensure) let cached: X | undefinedwith check-and-load (races when two callers seeundefinedbefore either resolves)
Effect.cached handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with yield* Effect.cached(...) — the old memo is discarded.
Scheduled Tasks
For loops or periodic work, use Effect.repeat or Effect.schedule with Effect.forkScoped in the layer definition.
Preferred Effect services
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
Prefer these first:
FileSystem.FileSysteminstead of rawfs/promisesfor effectful file I/OChildProcessSpawner.ChildProcessSpawnerwithChildProcess.make(...)instead of custom process wrappersHttpClient.HttpClientinstead of rawfetchPath.Pathinstead of mixing path helpers into service code when you already need a path serviceConfigfor effect-native configuration readsClock/DateTimefor time reads inside effects
Child processes
For child process work in services, yield ChildProcessSpawner.ChildProcessSpawner in the layer and use ChildProcess.make(...).
Keep shelling-out code inside the service, not in callers.
Shared leaf models
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
That is fine for leaf files like schema.ts. Keep the service surface in the owning namespace.
Migration checklist
Service-shape migrated (single namespace, traced methods, InstanceState where needed).
This checklist is only about the service shape migration. Many of these services still keep makeRuntime(...) plus async facade exports; that facade-removal phase is tracked separately in Destroying the facades.
-
Account—account/index.ts -
Agent—agent/agent.ts -
AppFileSystem—filesystem/index.ts -
Auth—auth/index.ts(useszod()helper for Schema→Zod interop) -
Bus—bus/index.ts -
Command—command/index.ts -
Config—config/config.ts -
Discovery—skill/discovery.ts(dependency-only layer, no standalone runtime) -
File—file/index.ts -
FileTime—file/time.ts -
FileWatcher—file/watcher.ts -
Format—format/index.ts -
Installation—installation/index.ts -
LSP—lsp/index.ts -
MCP—mcp/index.ts -
McpAuth—mcp/auth.ts -
Permission—permission/index.ts -
Plugin—plugin/index.ts -
Project—project/project.ts -
ProviderAuth—provider/auth.ts -
Pty—pty/index.ts -
Question—question/index.ts -
SessionStatus—session/status.ts -
Skill—skill/index.ts -
Snapshot—snapshot/index.ts -
ToolRegistry—tool/registry.ts -
Truncate—tool/truncate.ts -
Vcs—project/vcs.ts -
Worktree—worktree/index.ts -
Session—session/index.ts -
SessionProcessor—session/processor.ts -
SessionPrompt—session/prompt.ts -
SessionCompaction—session/compaction.ts -
SessionSummary—session/summary.ts -
SessionRevert—session/revert.ts -
Instruction—session/instruction.ts -
SystemPrompt—session/system.ts -
Provider—provider/provider.ts -
Storage—storage/storage.ts -
ShareNext—share/share-next.ts -
SessionTodo—session/todo.ts
Still open at the service-shape level:
SyncEvent—sync/index.ts(deferred pending sync with James)Workspace—control-plane/workspace.ts(deferred pending sync with James)
Tool migration
Tool-specific migration guidance and checklist live in tools.md.
Effect service adoption in already-migrated code
Some already-effectified areas still use raw Filesystem.* or Process.spawn in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
Filesystem.* → AppFileSystem.Service (yield in layer)
config/config.ts—installDependencies()now usesAppFileSystemprovider/provider.ts— recent model state now reads viaAppFileSystem.Service
Process.spawn → ChildProcessSpawner (yield in layer)
format/formatter.ts— directProcess.spawn()checks removed (air,uv)lsp/server.ts— multipleProcess.spawn()installs/download helpers
Filesystem consolidation
util/filesystem.ts is still used widely across src/, and raw fs / fs/promises imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from Filesystem.* to yielding AppFileSystem.Service where possible — this should happen naturally during each migration, not as a separate sweep.
Tool-specific filesystem cleanup notes live in tools.md.
Primitives & utilities
util/lock.ts— reader-writer lock → Effect Semaphore/Permitutil/flock.ts— file-based distributed lock with heartbeat → Effect.repeat + addFinalizerutil/process.ts— child process spawn wrapper → return Effect instead of Promiseutil/lazy.ts— replace uses in Effect code with Effect.cached; keep for sync-only code
Destroying the facades
This phase is still broadly open. As of 2026-04-11 there are still 31 makeRuntime(...) call sites under src/, and many service namespaces still export async facade helpers like export async function read(...) { return runPromise(...) }.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and AppRuntime (src/effect/app-runtime.ts) composes everything into one ManagedRuntime, we're removing them.
Process
For each service, the migration is roughly:
- Find callers.
grep -n "Namespace\.(methodA|methodB|...)"acrosssrc/andtest/. Skip the service file itself. - Migrate production callers. For each effectful caller that does
Effect.tryPromise(() => Namespace.method(...)):- Add the service to the caller's layer R type (
Layer.Layer<Self, never, ... | Namespace.Service>) - Yield it at the top of the layer:
const ns = yield* Namespace.Service - Replace
Effect.tryPromise(() => Namespace.method(...))withyield* ns.method(...)(orns.method(...).pipe(Effect.orElseSucceed(...))for the common fallback case) - Add
Layer.provide(Namespace.defaultLayer)to the caller's owndefaultLayerchain
- Add the service to the caller's layer R type (
- Fix tests that used the caller's raw
.layer. Any test that composesCaller.layer(notdefaultLayer) needs to also provide the newly-required service tag. The fastest fix is usually switching toCaller.defaultLayersince it now pulls in the new dependency. - Migrate test callers of the facade. Tests calling
Namespace.method(...)directly get converted to full effectful style usingtestEffect(Namespace.defaultLayer)+it.live/it.effect+yield* svc.method(...). Don't wrap the test body inEffect.promise(async () => {...})— do the whole thing inEffect.genand useAppFileSystem.Service/tmpdirScoped/Effect.addFinalizerfor what used to be rawfs/Bun.write/try/finally. - Delete the facades. Once
grepshows zero callers, remove theexport async functionblock AND themakeRuntime(...)line from the service namespace. Also remove the now-unusedimport { makeRuntime }.
Pitfalls
- Layer caching inside tests.
testEffect(layer)constructs the Storage (or whatever) service once and memoizes it. If a test then triesinner.pipe(Effect.provide(customStorage))to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer inLayer.fresh(...), which forces a new instance to be built instead of hitting the memoMap cache. This lets a singletestEffect(...)serve both simple and per-test-customized cases. Effect.tryPromise→yield*drops the Promise layer. The old code wasEffect.tryPromise(() => Storage.read(...))— atryPromisewrapper because the facade returned a Promise. The new code isyield* storage.read(...)directly — the service method already returns an Effect, so no wrapper is needed. Don't reach forEffect.promiseorEffect.tryPromiseduring migration; if you're using them on a service method call, you're doing it wrong.- Raw
.layertest callers break silently in the type checker. When you add a new R requirement to a service's.layer, any test that composes it raw (notdefaultLayer) becomes under-specified.tsgowill flag this — the error looks likeType 'Storage.Service' is not assignable to type '... | Service | TestConsole'. Usually the fix is to switch that composition todefaultLayer, or addLayer.provide(NewDep.defaultLayer)to the custom composition. - Tests that do async setup with
fs,Bun.write,tmpdir. Convert these toAppFileSystem.Servicecalls insideEffect.gen, and usetmpdirScoped()instead oftmpdir()so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper likeconst writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })keeps the migration tests clean.
Migration log
SessionStatus— migrated 2026-04-11. Replaced the last route and retry-policy callers withAppRuntime.runPromise(SessionStatus.Service.use(...))and removed themakeRuntime(...)facade.ShareNext— migrated 2026-04-11. Swapped remaining async callers toAppRuntime.runPromise(ShareNext.Service.use(...)), removed themakeRuntime(...)facade, and kept instance bootstrap on the shared app runtime.SessionTodo— migrated 2026-04-10. Already matched the target service shape insession/todo.ts: single namespace, traced Effect methods, and nomakeRuntime(...)facade remained; checklist updated to reflect the completed migration.Storage— migrated 2026-04-10. One production caller (Session.diff) and all storage.test.ts tests converted to effectful style. Facades andmakeRuntimeremoved.SessionRunState— migrated 2026-04-11. Single caller inserver/instance/session.tsconverted; facade removed.Account— migrated 2026-04-11. Callers inserver/instance/experimental.tsandcli/cmd/account.tsconverted; facade removed.Instruction— migrated 2026-04-11. Test-only callers converted; facade removed.FileTime— migrated 2026-04-11. Test-only callers converted; facade removed.FileWatcher— migrated 2026-04-11. Callers inproject/bootstrap.tsand test converted; facade removed.Question— migrated 2026-04-11. Callers inserver/instance/question.tsand test converted; facade removed.Truncate— migrated 2026-04-11. Caller intool/tool.tsand test converted; facade removed.
Route handler effectification
Route-handler migration guidance and checklist live in routes.md.