| 1 | # Effect services |
| 2 | |
| 3 | Server 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 |
| 5 | page holds the rules for writing them. The |
| 6 | [Effect Service Conventions](../../.macroscope/check-run-agents/effect-service-conventions.md) |
| 7 | review check enforces the same rules; keep the two in step. |
| 8 | |
| 9 | ## Where a feature lives |
| 10 | |
| 11 | A 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 |
| 13 | does. |
| 14 | |
| 15 | A transport handler does three things: decode the request, call one service method, and map the |
| 16 | service's typed errors to the transport's error. Nothing else. Filesystem, Git, process, or |
| 17 | persistence work, folder naming, multi-step dispatch, retries, and rollback belong in the service. |
| 18 | |
| 19 | The reason is reach. Users trigger a capability from the WebSocket, agents reach it through MCP |
| 20 | tools, and scheduled tasks and the CLI run it too. Logic written into one handler is missing from |
| 21 | the others, and testing it needs a socket. Plain functions for pure work (a slug, an SVG, a |
| 22 | message) 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 | |
| 32 | Handlers 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 |
| 34 | also instruments it |
| 35 | ([`RpcInstrumentation.ts`](../../apps/server/src/observability/RpcInstrumentation.ts)). A handler |
| 36 | with per-call context, such as a thread id, adds it with `Effect.annotateCurrentSpan`. |
| 37 | |
| 38 | ## Shape of a service module |
| 39 | |
| 40 | One module per service, in this order: imports, errors and schemas, the `Context.Service` tag with |
| 41 | its interface inline, `make`, then `layer`. [`WorkspacePaths.ts`](../../apps/server/src/workspace/WorkspacePaths.ts) |
| 42 | and [`T3ProjectFileLoader.ts`](../../apps/server/src/project/T3ProjectFileLoader.ts) are good |
| 43 | references. |
| 44 | |
| 45 | ```ts |
| 46 | export 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 | |
| 55 | export class Foo extends Context.Service< |
| 56 | Foo, |
| 57 | { readonly write: (input: { readonly path: string }) => Effect.Effect<void, FooWriteError> } |
| 58 | >()("t3/area/Foo") {} |
| 59 | |
| 60 | const make = Effect.gen(function* () { |
| 61 | const fileSystem = yield* FileSystem.FileSystem; |
| 62 | // ... |
| 63 | return Foo.of({ write }); |
| 64 | }); |
| 65 | |
| 66 | export 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 |
| 96 | boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain service, repository, |
| 97 | persistence code, or service constructor. A named adapter may bridge a service into a Promise API, |
| 98 | but no Effect service depends on it. |
| 99 | |
| 100 | Compose a shared resource once in an application-owned layer and provide its context to integration |
| 101 | runtimes. Don't create a managed or Atom runtime per feature to hand it out. When acquisition can |
| 102 | fail and callers need a fallback, keep the failure typed: an error on the operation or an explicit |
| 103 | optional-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? |