# 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 `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. - Global services (no per-directory state): Account, Auth, Installation, Truncate - Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth 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: ```ts export namespace Foo { export interface Interface { readonly get: (id: FooID) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Foo") {} export const layer = Layer.effect( Service, Effect.gen(function* () { // For instance-scoped services: 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 Service.of({ get }) }), ) // Optional: wire dependencies export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) // Per-service runtime (inside the namespace) const runPromise = makeRunPromise(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.ts` split - `runPromise` goes inside the namespace (not exported unless tests need it) - Facade functions are plain `async function` — no `fn()` 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`: ```ts import { zod } from "@/util/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` See `Auth.ZodInfo` for the canonical example. ## 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.FileSystem` instead of raw `fs/promises` for effectful file I/O - `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers - `HttpClient.HttpClient` instead of raw `fetch` - `Path.Path` instead of mixing path helpers into service code when you already need a path service - `Config` for effect-native configuration reads - `Clock` / `DateTime` for 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 Fully migrated (single namespace, InstanceState where needed, flattened facade): - [x] `Account` — `account/index.ts` - [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop) - [x] `File` — `file/index.ts` - [x] `FileTime` — `file/time.ts` - [x] `FileWatcher` — `file/watcher.ts` - [x] `Format` — `format/index.ts` - [x] `Installation` — `installation/index.ts` - [x] `Permission` — `permission/index.ts` - [x] `ProviderAuth` — `provider/auth.ts` - [x] `Question` — `question/index.ts` - [x] `Skill` — `skill/index.ts` - [x] `Snapshot` — `snapshot/index.ts` - [x] `Truncate` — `tool/truncate.ts` - [x] `Vcs` — `project/vcs.ts` - [x] `Discovery` — `skill/discovery.ts` - [x] `SessionStatus` Still open and likely worth migrating: - [ ] `Plugin` - [ ] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` - [ ] `Bus` - [x] `Command` - [ ] `Config` - [ ] `Session` - [ ] `SessionProcessor` - [ ] `SessionPrompt` - [ ] `SessionCompaction` - [ ] `Provider` - [ ] `Project` - [ ] `LSP` - [ ] `MCP`