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.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. 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.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 AppFileSystem.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 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.mock for partial service stubs.
  • Avoid custom ManagedRuntime, attach(...), or ad hoc run(...) 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.