1# Effect services
2
3Server features are Effect services. Transports call them: WebSocket RPC handlers in
4[`ws.ts`](../../apps/server/src/ws.ts), HTTP routes, MCP tools, scheduled tasks, and the CLI. This
5page holds the rules for writing them. The
6[Effect Service Conventions](../../.macroscope/check-run-agents/effect-service-conventions.md)
7review check enforces the same rules; keep the two in step.
8
9## Where a feature lives
10
11A server capability is a method on a service in its domain folder (`project/`, `workspace/`, `git/`,
12`provider/`, ...). Extend the service that already owns the domain; add a new one only when none
13does.
14
15A transport handler does three things: decode the request, call one service method, and map the
16service's typed errors to the transport's error. Nothing else. Filesystem, Git, process, or
17persistence work, folder naming, multi-step dispatch, retries, and rollback belong in the service.
18
19The reason is reach. Users trigger a capability from the WebSocket, agents reach it through MCP
20tools, and scheduled tasks and the CLI run it too. Logic written into one handler is missing from
21the others, and testing it needs a socket. Plain functions for pure work (a slug, an SVG, a
22message) are fine next to the service; the capability itself is the method.
23
24```ts
25// ws.ts: a thin handler
26[WS_METHODS.projectsCreateNew]: (input) =>
27 projectFolders
28 .createNamedProject(input)
29 .pipe(Effect.mapError((cause) => new ProjectCreateNewError({ cause }))),
30```
31
32Handlers don't add their own spans or request metrics. Group middleware authorizes every call
33([`RpcAuthorization.ts`](../../apps/server/src/auth/RpcAuthorization.ts)), and the server's group
34also instruments it
35([`RpcInstrumentation.ts`](../../apps/server/src/observability/RpcInstrumentation.ts)). A handler
36with per-call context, such as a thread id, adds it with `Effect.annotateCurrentSpan`.
37
38## Shape of a service module
39
40One module per service, in this order: imports, errors and schemas, the `Context.Service` tag with
41its interface inline, `make`, then `layer`. [`WorkspacePaths.ts`](../../apps/server/src/workspace/WorkspacePaths.ts)
42and [`T3ProjectFileLoader.ts`](../../apps/server/src/project/T3ProjectFileLoader.ts) are good
43references.
44
45```ts
46export class FooWriteError extends Schema.TaggedError<FooWriteError>()("FooWriteError", {
47 path: Schema.String,
48 cause: Schema.Defect(),
49}) {
50 override get message(): string {
51 return "Failed to write the foo file.";
52 }
53}
54
55export class Foo extends Context.Service<
56 Foo,
57 { readonly write: (input: { readonly path: string }) => Effect.Effect<void, FooWriteError> }
58>()("t3/area/Foo") {}
59
60const make = Effect.gen(function* () {
61 const fileSystem = yield* FileSystem.FileSystem;
62 // ...
63 return Foo.of({ write });
64});
65
66export const layer = Layer.effect(Foo, make);
67```
68
69- **Imports.** Import Effect modules as namespaces from their subpaths:
70 `import * as Effect from "effect/Effect"`, never `import { Effect } from "effect"`. Consumers use
71 a service module the same way: `import * as Foo from "./Foo.ts"`, then `yield* Foo.Foo` and
72 `Foo.layer`. Never `import { layer as fooLayer }`. Named imports are fine for packages like
73 `@t3tools/contracts` and for modules used only for a pure helper, error, schema, config value, or
74 type. A barrel exposes a whole service module as `export * as TokenStore from "./tokenStore.ts"`,
75 not as renamed `make` and `layer` exports.
76- **Interface.** No standalone `FooShape`; name the type `Foo["Service"]`.
77- **Dependencies** come from the environment (`yield* FileSystem.FileSystem`), never as parameters
78 to `make`, so the types of `make` and `layer` show what they need. Never hide one in a module
79 global, a closure over a singleton, or a `Layer.succeed` that calls runtime-backed or imperative
80 APIs. Tests may pass service instances directly. Configuration, immutable values, and deliberate
81 callbacks are fine as parameters; they aren't services.
82- **`make`** exists when the module owns construction and stays private unless another module
83 imports it. Knip fails CI on an unused export. Don't write `make = Effect.succeed(...)` to force
84 `Layer.effect`; use the constructor that fits, like `Layer.succeed` or `Layer.sync`.
85- **Names.** A module named for its implementation uses plain `make` and `layer`
86 ([`NodePtyAdapter.ts`](../../apps/server/src/terminal/NodePtyAdapter.ts)). A port module that also
87 holds implementations names them, like `makeCloudflaredRelayClient` and `layerCloudflared`.
88- **Moves.** Moving a service deletes the old files and updates every consumer, including
89 orchestration, MCP, tests, and integration harnesses. No re-export shims.
90- **Tests** exercise behavior through the service, with test layers only for external dependencies.
91 Don't mock the logic under test.
92
93## Runtime boundaries
94
95`ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at application and framework
96boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain service, repository,
97persistence code, or service constructor. A named adapter may bridge a service into a Promise API,
98but no Effect service depends on it.
99
100Compose a shared resource once in an application-owned layer and provide its context to integration
101runtimes. Don't create a managed or Atom runtime per feature to hand it out. When acquisition can
102fail and callers need a fallback, keep the failure typed: an error on the operation or an explicit
103optional-service layer. Don't route around the layer with an imperative runtime.
104
105## Errors
106
107- **Attributes.** Failures are `Schema.TaggedError` classes with structured attributes: the
108 operation or stage, the resource path or entity id, a normalized category or status. The message
109 is fixed or built from those attributes, never from `cause`, `cause.message`, or a stringified
110 defect. No `detail` field that copies `cause.message`.
111- **Cause.** An error that wraps a failure keeps the immediate underlying error as `cause`; make it
112 required when every construction wraps one. Validation and domain errors with nothing underneath
113 have none.
114- **Safe values.** Attributes and log annotations stay bounded: no raw payloads, command arguments
115 or output, signed URLs, credentials, query strings, or arbitrary defect text. The exact value
116 lives only in `cause`. Expose a category, length, count, or a URL's protocol and host instead.
117- **Translation.** Construct the error where the failure happens, and map it to a transport error
118 only in the transport. A translation boundary passes through domain errors already in the target
119 channel and wraps only unknown or lower-level failures. Map each failure where its context is
120 known; don't wrap a whole pipeline in one generic error.
121- **Discriminators.** Don't encode one distinction twice, as a specific tag and a single-value
122 `operation`, `reason`, `kind`, or `phase` literal. Split classes when the distinction drives
123 control flow or the user-facing message; a field that only helps diagnostics stays a field. A
124 message that reaches HTTP, RPC, persisted state, or the UI is behavior, and a refactor keeps it.
125- **Mappers.** Don't write a helper that only does `(...args) => new SomeError({ ...args })`. Keep
126 a mapper only when it normalizes, passes domain errors through, or adds context. A mapper that
127 belongs to the target error is a static factory on that class.
128- **Predicates** are exported directly as `export const isFoo = Schema.is(Foo)`, not a function
129 wrapping a private `Schema.is`.
130- **Catching.** Catch known tags with `Effect.catchTags({ ... })`, even for one tag, not `catchTag`
131 or `catchIf` with a schema predicate. `Effect.catch` is for handling the whole channel; `catchIf`
132 is for structural checks like a platform error code.
133
134## Before you push
135
136- Does any handler you touched do more than decode, call, and map errors?
137- Could an agent (MCP) or a scheduled task use this capability? If not, is that deliberate?
138- Did you extend the domain's existing service before adding a new one?
139- Did you run knip? A new export with no importer fails it.
140- Does every directive you added that disables a lint, type-checker, or LSP diagnostic say why, in
141 a `-- reason` suffix or a comment above it?