docs/internals/effect-services.md

Effect services

Server features are Effect services. Transports call them: WebSocket RPC handlers in ws.ts, HTTP routes, MCP tools, scheduled tasks, and the CLI. This page holds the rules for writing them. The Effect Service Conventions review check enforces the same rules; keep the two in step.

Where a feature lives

A server capability is a method on a service in its domain folder (project/, workspace/, git/, provider/, ...). Extend the service that already owns the domain; add a new one only when none does.

A transport handler does three things: decode the request, call one service method, and map the service's typed errors to the transport's error. Nothing else. Filesystem, Git, or process work, folder naming, multi-step dispatch, retries, and rollback belong in the service.

The reason is reach. Users trigger a capability from the WebSocket, agents reach it through MCP tools, and scheduled tasks and the CLI run it too. Logic written into one handler is missing from the others, and testing it needs a socket. Plain functions for pure work (a slug, an SVG, a message) are fine next to the service; the capability itself is the method.

ts
1// ws.ts: a thin handler
2[WS_METHODS.projectsCreateNew]: (input) =>
3 observeRpcEffect(
4 WS_METHODS.projectsCreateNew,
5 projectFolders.createNamedProject(input).pipe(
6 Effect.mapError((cause) => new ProjectCreateNewError({ cause })),
7 ),
8 { "rpc.aggregate": "orchestration" },
9 ),

Shape of a service module

One module per service, in this order: imports, errors and schemas, the Context.Service tag with its interface inline, make, then layer. WorkspacePaths.ts and T3ProjectFileLoader.ts are good references.

ts
1export class FooWriteError extends Schema.TaggedError<FooWriteError>()("FooWriteError", {
2 path: Schema.String,
3 cause: Schema.Defect(),
4}) {
5 override get message(): string {
6 return "Failed to write the foo file.";
7 }
8}
9
10export class Foo extends Context.Service<
11 Foo,
12 { readonly write: (input: { readonly path: string }) => Effect.Effect<void, FooWriteError> }
13>()("t3/area/Foo") {}
14
15const make = Effect.gen(function* () {
16 const fileSystem = yield* FileSystem.FileSystem;
17 // ...
18 return Foo.of({ write });
19});
20
21export const layer = Layer.effect(Foo, make);
  • Imports. Consumers use the module as a namespace: import * as Foo from "./Foo.ts", then yield* Foo.Foo and Foo.layer. Never import { layer as fooLayer }.
  • Dependencies come from the environment (yield* FileSystem.FileSystem), never as parameters to make.
  • make stays private unless another module imports it. Knip fails CI on an unused export.
  • Errors are Schema.TaggedError classes with structured attributes and a cause when they wrap a failure. The message is fixed or built from attributes, never from cause. Construct the error where the failure happens; map it to a transport error only in the transport. Catch known tags with Effect.catchTags.
  • Tests exercise behavior through the service, with test layers only for external dependencies. Don't mock the logic under test.

Before you push

  • Does any handler you touched do more than decode, call, and map errors?
  • Could an agent (MCP) or a scheduled task use this capability? If not, is that deliberate?
  • Did you extend the domain's existing service before adding a new one?
  • Did you run knip? A new export with no importer fails it.