Effect Guide
How we write Effect code in packages/opencode. The companion roadmap is
todo.md.
This guide describes the preferred shape for new work and migrations. If a legacy file differs, migrate it only when it is already in scope.
Service Shape
Use one module per service: flat top-level exports, traced Effect methods, explicit layers, and a self-reexport at the bottom.
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* () {
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 yield* loadFoo(s, id)
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer))
export * as Foo from "./foo"
Rules:
- Do not use
export namespace Foo { ... }. - Use
Effect.fn("Foo.method")for public service methods. - Use
Effect.fnUntracedfor small internal helpers that do not need a span. - Keep helpers as non-exported top-level declarations in the same file.
- Self-reexport with
export * as Foo from "."forindex.ts, otherwiseexport * as Foo from "./foo". - In
src/config, keep the existing top-of-file self-export pattern.
Runtime Boundaries
Most code should run through AppRuntime.
It hosts AppLayer, shares the global memoMap, and restores the current
instance/workspace refs when crossing from non-Effect code.
Use AppRuntime.runPromise(effect) at app boundaries such as CLI commands,
HTTP handlers, or plain async adapters.
makeRuntime(...) still exists for a few intentional service-local
boundaries and migration leftovers. Do not add a new service-local runtime
unless the service truly cannot live in AppLayer.
Runtime Flags
Read opencode runtime flags through
RuntimeFlags.Service, not through
mutable Flag or late process.env reads.
Tests should vary behavior with explicit layer variants:
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalScout: true }))))
Do not mutate process.env or Flag after services/layers are built.
Per-Instance State
Use InstanceState when two open
directories should not share one copy of a service's state. It is backed by
a ScopedCache, keyed by directory, and disposed automatically when an
instance is unloaded.
Put subscriptions, finalizers, and scoped background work inside the
InstanceState.make(...) initializer:
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAll().pipe(
Stream.runForEach((event) => handleEvent(event)),
Effect.forkScoped,
)
yield* Effect.acquireRelease(openResource, closeResource)
return yield* loadInitialState()
}),
)
Do not add separate started flags on top of InstanceState. Let
ScopedCache handle run-once and deduplication.
To make init() non-blocking, fork at the caller/bootstrap boundary. Do
not fork inside InstanceState.make(...) just to return early with
partially initialized state.
Errors
Expected domain failures belong on the Effect error channel. Defects are for bugs, impossible states, and final unknown-boundary fallbacks.
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
Rules:
- Use
Schema.TaggedErrorClassfor new expected domain errors. - Export a domain-level
Errorunion from service modules. - In
Effect.gen/Effect.fn, preferyield* new MyError(...)for direct expected failures. - Use
Schema.Defectfor unknown cause fields. - Use
Effect.try(...),Effect.tryPromise(...),Effect.mapError,Effect.catchTag, andEffect.catchTagsto translate external failures into domain errors. - Do not use
Effect.die(...)for user, IO, validation, missing-resource, auth, provider, or busy-state failures.
HTTP Error Boundaries
Service modules stay HTTP-agnostic. They should not import HTTP status
codes, HttpApiError, HttpServerResponse, or route-specific error
schemas.
HTTP handlers translate service errors into endpoint-declared public error schemas. Keep mappings inline when they are one-off; extract tiny shared helpers only when the same translation repeats.
Do not turn generic middleware into a registry of domain errors. Middleware should handle cross-cutting concerns and the final unknown-defect fallback.
Preserve legacy public wire shapes, such as { name, data }, until a
deliberate breaking API change.
Schemas
Use Effect Schema as the source of truth.
- Use
Schema.Classfor exported data objects with a clear identity. - Use
Schema.Structfor local shapes and simple nested objects. - Use
Schema.brandfor single-value IDs. - Reuse named refinements instead of re-spelling constraints.
- Prefer narrow boundary helpers over generic Schema-to-Zod bridges.
Intentional boundaries:
- Public plugin tools still expose Zod through
tool.schema = z. - Tool parameter JSON Schema is generated through tool-specific helpers.
- Public config and TUI schemas are generated through the schema script.
Preferred Services
In effectified code, yield existing services instead of dropping to ad hoc platform APIs.
- Use
AppFileSystem.Serviceinstead of rawfs/promisesfor app file IO. - Use
AppProcess.Serviceinstead of directChildProcessSpawner.spawnor legacy process helpers. - Use
HttpClient.HttpClientinstead of rawfetchinside Effect code. - Use
Path.Path,Config,Clock, andDateTimewhen already inside Effect. - Use
Effect.callbackfor callback-based APIs. - Use
Effect.voidinstead ofEffect.succeed(undefined). - Use
Effect.cachedwhen concurrent callers should share one in-flight computation.
For background loops, use Effect.repeat or Effect.schedule with
Effect.forkScoped in the owning layer/state scope.
Promise And ALS Bridges
EffectBridge is the sanctioned helper for
Promise/callback interop that needs to preserve instance/workspace context.
It preserves explicit InstanceRef / WorkspaceRef context for effects run
through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
Testing
Detailed test migration rules live in
test/EFFECT_TEST_MIGRATION.md.
Core pattern:
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.instance("does the thing", () =>
Effect.gen(function* () {
const svc = yield* MyService.Service
expect(yield* svc.run()).toEqual("ok")
}),
)
})
Rules:
- Use
it.effect(...)for TestClock/TestConsole tests. - Use
it.live(...)for real timers, filesystem mtimes, child processes, git, locks, or other live integration behavior. - Use
it.instance(...)for service tests that need a scoped instance. - Prefer Effect-aware fixtures from
test/fixture/fixture.ts. - Avoid sleeps; wait for real events or deterministic state transitions.
- Avoid mutable
process.env,Flag, or module-global changes after layers are built. - Use
Layer.mockfor partial service stubs. - Avoid custom
ManagedRuntime,attach(...), or ad hocrun(...)test wrappers.
Verification
From packages/opencode:
bun run typecheck
bun run test -- path/to/test.ts
Do not run tests from the repo root; the repo has a guard for that.