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, anddefaultLayeron the owning namespace - Export
defaultLayeronly 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.FileSysteminstead of rawfs/promisesfor effectful file I/OChildProcessSpawner.ChildProcessSpawnerwithChildProcess.make(...)instead of custom process wrappersHttpClient.HttpClientinstead of rawfetchPath.Pathinstead of mixing path helpers into service code when you already need a path serviceConfigfor effect-native configuration readsClock/DateTimefor 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)QuestionPermissionNextProviderAuthFileWatcherFileTimeFormatVcsSkillDiscoveryFileSnapshot
Still open and likely worth migrating:
PluginToolRegistryPtyWorktreeInstallationBusCommandConfigSessionSessionProcessorSessionPromptSessionCompactionProviderProjectLSPMCP