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.
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.
| 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 | ), |
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.
| 1 | export 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 | |
| 10 | export class Foo extends Context.Service< |
| 11 | Foo, |
| 12 | { readonly write: (input: { readonly path: string }) => Effect.Effect<void, FooWriteError> } |
| 13 | >()("t3/area/Foo") {} |
| 14 | |
| 15 | const make = Effect.gen(function* () { |
| 16 | const fileSystem = yield* FileSystem.FileSystem; |
| 17 | // ... |
| 18 | return Foo.of({ write }); |
| 19 | }); |
| 20 | |
| 21 | export const layer = Layer.effect(Foo, make); |
import * as Foo from "./Foo.ts", then
yield* Foo.Foo and Foo.layer. Never import { layer as fooLayer }.yield* FileSystem.FileSystem), never as parameters to
make.make stays private unless another module imports it. Knip fails CI on an unused export.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.