# Effect Guide How we write Effect code in `packages/opencode`. The companion roadmap is [`todo.md`](./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. ```ts export interface Interface { readonly get: (id: FooID) => Effect.Effect } export class Service extends Context.Service()("@opencode/Foo") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const state = yield* InstanceState.make(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.fnUntraced` for 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 "."` for `index.ts`, otherwise `export * as Foo from "./foo"`. - In `src/config`, keep the existing top-of-file self-export pattern. ## Runtime Boundaries Most code should run through [`AppRuntime`](../../src/effect/app-runtime.ts). 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`](../../src/effect/runtime-flags.ts), not through mutable `Flag` or late `process.env` reads. Tests should vary behavior with explicit layer variants: ```ts const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalReferences: true })))) ``` Do not mutate `process.env` or `Flag` after services/layers are built. ## Per-Instance State Use [`InstanceState`](../../src/effect/instance-state.ts) 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: ```ts const cache = yield * InstanceState.make( 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. ```ts export class SessionBusyError extends Schema.TaggedErrorClass()("SessionBusyError", { sessionID: SessionID, message: Schema.String, }) {} export type Error = Storage.Error | SessionBusyError export interface Interface { readonly get: (id: SessionID) => Effect.Effect } ``` Rules: - Use `Schema.TaggedErrorClass` for new expected domain errors. - Export a domain-level `Error` union from service modules. - In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` for direct expected failures. - Use `Schema.Defect` for unknown cause fields. - Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`, `Effect.catchTag`, and `Effect.catchTags` to 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.Class` for exported data objects with a clear identity. - Use `Schema.Struct` for local shapes and simple nested objects. - Use `Schema.brand` for 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 `FSUtil.Service` instead of raw `fs/promises` for app file IO. - Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or legacy process helpers. - Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code. - Use `Path.Path`, `Config`, `Clock`, and `DateTime` when already inside Effect. - Use `Effect.callback` for callback-based APIs. - Use `Effect.void` instead of `Effect.succeed(undefined)`. - Use `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. ## Promise And ALS Bridges [`EffectBridge`](../../src/effect/bridge.ts) 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`](../../test/EFFECT_TEST_MIGRATION.md). Core pattern: ```ts 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.mock` for partial service stubs. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` test wrappers. ## Verification From `packages/opencode`: ```bash 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.