| ... | ... | @@ -13,8 +13,8 @@ A server capability is a method on a service in its domain folder (`project/`, ` |
| 13 | 13 | does. |
| 14 | 14 | |
| 15 | 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, or process work, |
| 17 | | folder naming, multi-step dispatch, retries, and rollback belong in the service. |
| 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 | 18 | |
| 19 | 19 | The reason is reach. Users trigger a capability from the WebSocket, agents reach it through MCP |
| 20 | 20 | tools, and scheduled tasks and the CLI run it too. Logic written into one handler is missing from |
| ... | ... | @@ -64,21 +64,76 @@ const make = Effect.gen(function* () { |
| 64 | 64 | export const layer = Layer.effect(Foo, make); |
| 65 | 65 | ``` |
| 66 | 66 | |
| 67 | | - **Imports.** Consumers use the module as a namespace: `import * as Foo from "./Foo.ts"`, then |
| 68 | | `yield* Foo.Foo` and `Foo.layer`. Never `import { layer as fooLayer }`. |
| 69 | | - **Dependencies** come from the environment (`yield* FileSystem.FileSystem`), never as parameters to |
| 70 | | `make`. |
| 71 | | - **`make`** stays private unless another module imports it. Knip fails CI on an unused export. |
| 72 | | - **Errors** are `Schema.TaggedError` classes with structured attributes and a `cause` when they wrap |
| 73 | | a failure. The message is fixed or built from attributes, never from `cause`. Construct the error |
| 74 | | where the failure happens; map it to a transport error only in the transport. Catch known tags |
| 75 | | with `Effect.catchTags`. |
| 67 | - **Imports.** Import Effect modules as namespaces from their subpaths: |
| 68 | `import * as Effect from "effect/Effect"`, never `import { Effect } from "effect"`. Consumers use |
| 69 | a service module the same way: `import * as Foo from "./Foo.ts"`, then `yield* Foo.Foo` and |
| 70 | `Foo.layer`. Never `import { layer as fooLayer }`. Named imports are fine for packages like |
| 71 | `@t3tools/contracts` and for modules used only for a pure helper, error, schema, config value, or |
| 72 | type. A barrel exposes a whole service module as `export * as TokenStore from "./tokenStore.ts"`, |
| 73 | not as renamed `make` and `layer` exports. |
| 74 | - **Interface.** No standalone `FooShape`; name the type `Foo["Service"]`. |
| 75 | - **Dependencies** come from the environment (`yield* FileSystem.FileSystem`), never as parameters |
| 76 | to `make`, so the types of `make` and `layer` show what they need. Never hide one in a module |
| 77 | global, a closure over a singleton, or a `Layer.succeed` that calls runtime-backed or imperative |
| 78 | APIs. Tests may pass service instances directly. Configuration, immutable values, and deliberate |
| 79 | callbacks are fine as parameters; they aren't services. |
| 80 | - **`make`** exists when the module owns construction and stays private unless another module |
| 81 | imports it. Knip fails CI on an unused export. Don't write `make = Effect.succeed(...)` to force |
| 82 | `Layer.effect`; use the constructor that fits, like `Layer.succeed` or `Layer.sync`. |
| 83 | - **Names.** A module named for its implementation uses plain `make` and `layer` |
| 84 | ([`NodePtyAdapter.ts`](../../apps/server/src/terminal/NodePtyAdapter.ts)). A port module that also |
| 85 | holds implementations names them, like `makeCloudflaredRelayClient` and `layerCloudflared`. |
| 86 | - **Moves.** Moving a service deletes the old files and updates every consumer, including |
| 87 | orchestration, MCP, tests, and integration harnesses. No re-export shims. |
| 76 | 88 | - **Tests** exercise behavior through the service, with test layers only for external dependencies. |
| 77 | 89 | Don't mock the logic under test. |
| 78 | 90 | |
| 91 | ## Runtime boundaries |
| 92 | |
| 93 | `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at application and framework |
| 94 | boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain service, repository, |
| 95 | persistence code, or service constructor. A named adapter may bridge a service into a Promise API, |
| 96 | but no Effect service depends on it. |
| 97 | |
| 98 | Compose a shared resource once in an application-owned layer and provide its context to integration |
| 99 | runtimes. Don't create a managed or Atom runtime per feature to hand it out. When acquisition can |
| 100 | fail and callers need a fallback, keep the failure typed: an error on the operation or an explicit |
| 101 | optional-service layer. Don't route around the layer with an imperative runtime. |
| 102 | |
| 103 | ## Errors |
| 104 | |
| 105 | - **Attributes.** Failures are `Schema.TaggedError` classes with structured attributes: the |
| 106 | operation or stage, the resource path or entity id, a normalized category or status. The message |
| 107 | is fixed or built from those attributes, never from `cause`, `cause.message`, or a stringified |
| 108 | defect. No `detail` field that copies `cause.message`. |
| 109 | - **Cause.** An error that wraps a failure keeps the immediate underlying error as `cause`; make it |
| 110 | required when every construction wraps one. Validation and domain errors with nothing underneath |
| 111 | have none. |
| 112 | - **Safe values.** Attributes and log annotations stay bounded: no raw payloads, command arguments |
| 113 | or output, signed URLs, credentials, query strings, or arbitrary defect text. The exact value |
| 114 | lives only in `cause`. Expose a category, length, count, or a URL's protocol and host instead. |
| 115 | - **Translation.** Construct the error where the failure happens, and map it to a transport error |
| 116 | only in the transport. A translation boundary passes through domain errors already in the target |
| 117 | channel and wraps only unknown or lower-level failures. Map each failure where its context is |
| 118 | known; don't wrap a whole pipeline in one generic error. |
| 119 | - **Discriminators.** Don't encode one distinction twice, as a specific tag and a single-value |
| 120 | `operation`, `reason`, `kind`, or `phase` literal. Split classes when the distinction drives |
| 121 | control flow or the user-facing message; a field that only helps diagnostics stays a field. A |
| 122 | message that reaches HTTP, RPC, persisted state, or the UI is behavior, and a refactor keeps it. |
| 123 | - **Mappers.** Don't write a helper that only does `(...args) => new SomeError({ ...args })`. Keep |
| 124 | a mapper only when it normalizes, passes domain errors through, or adds context. A mapper that |
| 125 | belongs to the target error is a static factory on that class. |
| 126 | - **Predicates** are exported directly as `export const isFoo = Schema.is(Foo)`, not a function |
| 127 | wrapping a private `Schema.is`. |
| 128 | - **Catching.** Catch known tags with `Effect.catchTags({ ... })`, even for one tag, not `catchTag` |
| 129 | or `catchIf` with a schema predicate. `Effect.catch` is for handling the whole channel; `catchIf` |
| 130 | is for structural checks like a platform error code. |
| 131 | |
| 79 | 132 | ## Before you push |
| 80 | 133 | |
| 81 | 134 | - Does any handler you touched do more than decode, call, and map errors? |
| 82 | 135 | - Could an agent (MCP) or a scheduled task use this capability? If not, is that deliberate? |
| 83 | 136 | - Did you extend the domain's existing service before adding a new one? |
| 84 | 137 | - Did you run knip? A new export with no importer fails it. |
| 138 | - Does every directive you added that disables a lint, type-checker, or LSP diagnostic say why, in |
| 139 | a `-- reason` suffix or a comment above it? |