tf_code/packages/opencode/specs/effect-migration.md

4.0 KiB

Effect patterns

Practical reference for new and migrated Effect code in packages/opencode.

Choose scope

Use the shared runtime for process-wide services with one lifecycle for the whole app.

Use src/effect/instances.ts for services that are created per directory or need InstanceContext, per-project state, or per-instance cleanup.

  • Shared runtime: config readers, stateless helpers, global clients
  • Instance-scoped: watchers, per-project caches, session state, project-bound background work

Rule of thumb: if two open directories should not share one copy of the service, it belongs in Instances.

Service shape

For a fully migrated module, use the public namespace directly:

export namespace Foo {
  export interface Interface {
    readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
  }

  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}

  export const layer = Layer.effect(
    Service,
    Effect.gen(function* () {
      return Service.of({
        get: Effect.fn("Foo.get")(function* (id) {
          return yield* ...
        }),
      })
    }),
  )

  export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
}

Rules:

  • Keep Interface, Service, layer, and defaultLayer on the owning namespace
  • Export defaultLayer only when wiring dependencies is useful
  • Use the direct namespace form once the module is fully migrated

Temporary mixed-mode pattern

Prefer a single namespace whenever possible.

Use a *Effect namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.

export namespace FooEffect {
  export interface Interface {
    readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
  }

  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}

  export const layer = Layer.effect(...)
}

Then keep the old boundary thin:

export namespace Foo {
  export function get(id: FooID) {
    return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
  }
}

Remove the Effect suffix when the boundary split is gone.

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

Done now:

  • AccountEffect (mixed-mode)
  • AuthEffect (mixed-mode)
  • TruncateEffect (mixed-mode)
  • Question
  • PermissionNext
  • ProviderAuth
  • FileWatcher
  • FileTime
  • Format
  • Vcs
  • Skill
  • Discovery
  • File
  • Snapshot

Still open and likely worth migrating:

  • Plugin
  • ToolRegistry
  • Pty
  • Worktree
  • Installation
  • Bus
  • Command
  • Config
  • Session
  • SessionProcessor
  • SessionPrompt
  • SessionCompaction
  • Provider
  • Project
  • LSP
  • MCP