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.
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:
export namespace Foo { ... }.Effect.fn("Foo.method") for public service methods.Effect.fnUntraced for small internal helpers that do not need a
span.export * as Foo from "." for index.ts, otherwise
export * as Foo from "./foo".src/config, keep the existing top-of-file self-export pattern.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.
Read kirincode 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({ experimentalReferences: true }))))
Do not mutate process.env or Flag after services/layers are built.
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.
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:
Schema.TaggedErrorClass for new expected domain errors.Error union from service modules.Effect.gen / Effect.fn, prefer yield* new MyError(...) for
direct expected failures.Schema.Defect for unknown cause fields.Effect.try(...), Effect.tryPromise(...), Effect.mapError,
Effect.catchTag, and Effect.catchTags to translate external
failures into domain errors.Effect.die(...) for user, IO, validation, missing-resource,
auth, provider, or busy-state failures.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.
Use Effect Schema as the source of truth.
Schema.Class for exported data objects with a clear identity.Schema.Struct for local shapes and simple nested objects.Schema.brand for single-value IDs.Intentional boundaries:
tool.schema = z.In effectified code, yield existing services instead of dropping to ad hoc platform APIs.
FSUtil.Service instead of raw fs/promises for app file IO.AppProcess.Service instead of direct ChildProcessSpawner.spawn or
legacy process helpers.HttpClient.HttpClient instead of raw fetch inside Effect code.Path.Path, Config, Clock, and DateTime when already inside
Effect.Effect.callback for callback-based APIs.Effect.void instead of Effect.succeed(undefined).Effect.cached when 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.
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.
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:
it.effect(...) for TestClock/TestConsole tests.it.live(...) for real timers, filesystem mtimes, child processes,
git, locks, or other live integration behavior.it.instance(...) for service tests that need a scoped instance.test/fixture/fixture.ts.process.env, Flag, or module-global changes after
layers are built.Layer.mock for partial service stubs.ManagedRuntime, attach(...), or ad hoc run(...) test
wrappers.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.