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, process, or persistence 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 | projectFolders |
| 4 | .createNamedProject(input) |
| 5 | .pipe(Effect.mapError((cause) => new ProjectCreateNewError({ cause }))), |
Handlers don't add their own spans or request metrics. Group middleware authorizes every call
(RpcAuthorization.ts), and the server's group
also instruments it
(RpcInstrumentation.ts). A handler
with per-call context, such as a thread id, adds it with Effect.annotateCurrentSpan.
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 Effect from "effect/Effect", never import { Effect } from "effect". Consumers use
a service module the same way: import * as Foo from "./Foo.ts", then yield* Foo.Foo and
Foo.layer. Never import { layer as fooLayer }. Named imports are fine for packages like
@t3tools/contracts and for modules used only for a pure helper, error, schema, config value, or
type. A barrel exposes a whole service module as export * as TokenStore from "./tokenStore.ts",
not as renamed make and layer exports.FooShape; name the type Foo["Service"].yield* FileSystem.FileSystem), never as parameters
to make, so the types of make and layer show what they need. Never hide one in a module
global, a closure over a singleton, or a Layer.succeed that calls runtime-backed or imperative
APIs. Tests may pass service instances directly. Configuration, immutable values, and deliberate
callbacks are fine as parameters; they aren't services.make exists when the module owns construction and stays private unless another module
imports it. Knip fails CI on an unused export. Don't write make = Effect.succeed(...) to force
Layer.effect; use the constructor that fits, like Layer.succeed or Layer.sync.make and layer
(NodePtyAdapter.ts). A port module that also
holds implementations names them, like makeCloudflaredRelayClient and layerCloudflared.ManagedRuntime.make, runPromise, and runPromiseExit belong at application and framework
boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain service, repository,
persistence code, or service constructor. A named adapter may bridge a service into a Promise API,
but no Effect service depends on it.
Compose a shared resource once in an application-owned layer and provide its context to integration runtimes. Don't create a managed or Atom runtime per feature to hand it out. When acquisition can fail and callers need a fallback, keep the failure typed: an error on the operation or an explicit optional-service layer. Don't route around the layer with an imperative runtime.
Schema.TaggedError classes with structured attributes: the
operation or stage, the resource path or entity id, a normalized category or status. The message
is fixed or built from those attributes, never from cause, cause.message, or a stringified
defect. No detail field that copies cause.message.cause; make it
required when every construction wraps one. Validation and domain errors with nothing underneath
have none.cause. Expose a category, length, count, or a URL's protocol and host instead.operation, reason, kind, or phase literal. Split classes when the distinction drives
control flow or the user-facing message; a field that only helps diagnostics stays a field. A
message that reaches HTTP, RPC, persisted state, or the UI is behavior, and a refactor keeps it.(...args) => new SomeError({ ...args }). Keep
a mapper only when it normalizes, passes domain errors through, or adds context. A mapper that
belongs to the target error is a static factory on that class.export const isFoo = Schema.is(Foo), not a function
wrapping a private Schema.is.Effect.catchTags({ ... }), even for one tag, not catchTag
or catchIf with a schema predicate. Effect.catch is for handling the whole channel; catchIf
is for structural checks like a platform error code.-- reason suffix or a comment above it?