opencode-effect-enforcer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. package/src/write-projection.ts +66 -0
@@ -0,0 +1,656 @@
1
+ ---
2
+ name: effect-service-implementation
3
+ description: Implement Effect services as fine-grained capabilities avoiding monolithic designs
4
+ ---
5
+
6
+ # Service Implementation Skill
7
+
8
+ Design and implement Effect services as focused capabilities that compose into complete solutions.
9
+
10
+ ## Effect Source Reference
11
+
12
+ The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
13
+ Browse and read files there directly to look up APIs, types, and implementations.
14
+
15
+ Reference this for:
16
+
17
+ - Context source: `packages/effect/src/Context.ts`
18
+ - Layer source: `packages/effect/src/Layer.ts`
19
+ - Migration guide: `MIGRATION.md`
20
+ - Effect source: `packages/effect/src/`
21
+
22
+ ## Service Declaration: ES-Module Namespace Projection
23
+
24
+ First follow an existing project convention for Effect service tags and module naming. A project that has standardized another current Effect v4 tag style should keep it; do not migrate working services to `Context.Service` merely to match this guide.
25
+
26
+ When no convention exists, put the service in its own ES module with file-local role names: `Interface`, `Service`, `layer`, and, when needed, `defaultLayer`. At the bottom, the owning leaf self-exports its canonical identity with `export * as UserRepo from "./user-repo.js"`. Sibling modules import that identity directly from the owning leaf; folder and package barrels only relay it with `export { UserRepo } from "./user-repo.js"`. This gives callers a domain-specific namespace without TypeScript `namespace` declarations.
27
+
28
+ This self-referential ES-module projection is intentional, but it requires a toolchain and runtime that support it. Honor established project conventions when they use another current Effect v4 service layout or when self-projection is unsupported. With the class-style `Context.Service` shown below, the class body stays empty and construction lives in `Layer.effect`, not in `make:` or class statics.
29
+
30
+ The main payoff is a visible service graph. Prefer patterns that make dependencies obvious at `yield*` call sites and in `defaultLayer` composition over patterns that keep compatibility shims or helper modules in the foreground.
31
+
32
+ ```typescript
33
+ // user-repo.ts
34
+ import { Effect, Layer, Schema, Context } from 'effect';
35
+
36
+ class UserNotFound extends Schema.TaggedError<UserNotFound>()(
37
+ 'UserNotFound',
38
+ {
39
+ userId: Schema.String,
40
+ message: Schema.String
41
+ }
42
+ ) {}
43
+
44
+ export interface Interface {
45
+ readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
46
+ readonly create: (data: CreateUserData) => Effect.Effect<User>;
47
+ }
48
+
49
+ export class Service extends Context.Service<Service, Interface>()(
50
+ '@app/UserRepo'
51
+ ) {}
52
+
53
+ export const layer = Layer.effect(
54
+ Service,
55
+ Effect.gen(function* () {
56
+ const db = yield* DatabaseClient.Service;
57
+
58
+ const findById = Effect.fn('UserRepo.findById')(function* (id: string) {
59
+ const row = yield* db.query<User | undefined>(
60
+ 'SELECT * FROM users WHERE id = ?',
61
+ id
62
+ );
63
+ if (!row)
64
+ return yield* new UserNotFound({
65
+ userId: id,
66
+ message: `User ${id} not found`
67
+ });
68
+ return row;
69
+ });
70
+
71
+ const create = Effect.fn('UserRepo.create')(function* (
72
+ data: CreateUserData
73
+ ) {
74
+ return yield* db.insert('users', data);
75
+ });
76
+
77
+ return Service.of({ findById, create });
78
+ })
79
+ );
80
+
81
+ export const defaultLayer = layer.pipe(
82
+ Layer.provide(DatabaseClient.defaultLayer)
83
+ );
84
+
85
+ export * as UserRepo from './user-repo.js';
86
+ ```
87
+
88
+ ```typescript
89
+ // services.ts (a folder barrel relays the owning leaf's identity)
90
+ export { UserRepo } from './user-repo.js';
91
+ ```
92
+
93
+ Key properties:
94
+
95
+ - **ES-module encapsulation** — `Interface`, `Service`, `layer`, and `defaultLayer` are local roles in one service module; the owning leaf supplies the domain name
96
+ - **Owning-leaf projection** — the leaf self-exports `UserRepo`; consumers import it directly or through a relaying barrel and use `UserRepo.Service` or `UserRepo.defaultLayer`
97
+ - **Project tag convention first** — retain another standardized Effect v4 service-tag style and its established implementation-construction idiom
98
+ - **Empty class body** — when using class-style `Context.Service`, `Service` is purely a tag/identifier; no `make:`, no `static readonly layer`
99
+ - **`Layer.effect` external to class** — construction logic is a module-level `const`, not a class static
100
+ - **`Service.of({...})`** — with `Context.Service`, return service implementations via `Service.of()`, never a plain object literal
101
+ - **`Effect.fn("Domain.method")`** — span names use the projected domain name, not the file-local role name `Service`
102
+ - **`layer` / `defaultLayer`** — `layer` exposes the true dependency graph in its type; `defaultLayer` is the fully-wired production composition (only needed when `layer` has unsatisfied requirements)
103
+ - Access the service with `yield*` in generators, or `Service.use(s => ...)` / `Service.useSync(s => ...)` for one-liners
104
+
105
+ ### Default Layer Composition
106
+
107
+ Compose `defaultLayer` directly in the normal case:
108
+
109
+ ```typescript
110
+ export const defaultLayer = layer.pipe(
111
+ Layer.provide(DepA.defaultLayer),
112
+ Layer.provide(DepB.defaultLayer)
113
+ );
114
+ ```
115
+
116
+ Use `Layer.suspend(() => ...)` only when module evaluation order or a real import cycle requires deferral:
117
+
118
+ ```typescript
119
+ export const defaultLayer = Layer.suspend(() =>
120
+ layer.pipe(Layer.provide(Dep.defaultLayer))
121
+ );
122
+ ```
123
+
124
+ Do not wrap every `defaultLayer` in `Layer.unwrap(Effect.sync(...))` by default.
125
+
126
+ ### What does NOT exist in v4:
127
+
128
+ - `accessors: true` — REMOVED. Use `yield*` or `.use()` / `.useSync()` instead
129
+ - `effect:` option — does NOT exist. Use `Layer.effect` externally
130
+ - `succeed:` option — does NOT exist. Use `Layer.succeed` externally
131
+ - `dependencies: [...]` option — REMOVED. Use `Layer.provide` on the layer
132
+
133
+ ### Alternative: Project-Established Service Style
134
+
135
+ If the project already standardizes class statics or another current Effect v4 tag declaration, preserve that style. For example, an established class-statics service remains acceptable:
136
+
137
+ ```typescript
138
+ import { Context, Crypto, Effect, Layer } from 'effect';
139
+ import { NodeCrypto } from '@effect/platform-node';
140
+
141
+ export class IdGenerator extends Context.Service<
142
+ IdGenerator,
143
+ {
144
+ readonly generate: Effect.Effect<string>;
145
+ }
146
+ >()('@services/IdGenerator', {
147
+ make: Effect.gen(function* () {
148
+ const crypto = yield* Crypto.Crypto;
149
+ // `randomUUIDv4` fails with PlatformError; UUID generation is
150
+ // unrecoverable here, so collapse it into a defect with `Effect.orDie`.
151
+ return { generate: crypto.randomUUIDv4.pipe(Effect.orDie) };
152
+ })
153
+ }) {
154
+ static readonly layer = Layer.effect(this, this.make);
155
+ static readonly defaultLayer = this.layer.pipe(
156
+ Layer.provide(NodeCrypto.layer)
157
+ );
158
+ }
159
+ ```
160
+
161
+ Do not introduce this alternative into a project that has no convention. In that case prefer the ES-module projection pattern, including for small services. Provide a platform `Crypto` implementation such as `NodeCrypto.layer` at the app edge; here `defaultLayer` wires it.
162
+
163
+ Function-style keys are also current Effect v4. If a project standardizes them, a service module can retain a file-local role such as `export const Service = Context.Service<Interface>('@services/IdGenerator')`; use the resulting key exactly as the project does rather than rewriting it as a class.
164
+
165
+ ## Anti-Pattern: Monolithic Services
166
+
167
+ ```typescript
168
+ import { Effect, Layer, Context } from 'effect';
169
+
170
+ // WRONG - Mixed concerns in one service
171
+ export class PaymentService extends Context.Service<
172
+ PaymentService,
173
+ {
174
+ readonly processPayment: Effect.Effect<void>;
175
+ readonly validateWebhook: Effect.Effect<void>;
176
+ readonly refund: Effect.Effect<void>;
177
+ readonly sendReceipt: Effect.Effect<void>; // Notification concern
178
+ readonly generateReport: Effect.Effect<void>; // Reporting concern
179
+ }
180
+ >()('PaymentService') {}
181
+ ```
182
+
183
+ ## Pattern: Capability-Based Services
184
+
185
+ Each service represents ONE cohesive capability:
186
+
187
+ ```typescript
188
+ // payment-gateway.ts
189
+ import { Effect, Layer, Schema, Context } from 'effect';
190
+ import { StripeClient } from './stripe-client.js';
191
+
192
+ class HandoffError extends Schema.TaggedError<HandoffError>()(
193
+ 'HandoffError',
194
+ {
195
+ message: Schema.String
196
+ }
197
+ ) {}
198
+
199
+ // Focused capability: one concern in this service module.
200
+ export interface Interface {
201
+ readonly handoff: (
202
+ intent: PaymentIntent
203
+ ) => Effect.Effect<HandoffResult, HandoffError>;
204
+ }
205
+
206
+ export class Service extends Context.Service<Service, Interface>()(
207
+ '@services/payment/PaymentGateway'
208
+ ) {}
209
+
210
+ export const layer = Layer.effect(
211
+ Service,
212
+ Effect.gen(function* () {
213
+ const stripe = yield* StripeClient.Service;
214
+
215
+ const handoff = Effect.fn('PaymentGateway.handoff')(function* (
216
+ intent: PaymentIntent
217
+ ) {
218
+ return yield* stripe.handoff(intent);
219
+ });
220
+
221
+ return Service.of({ handoff });
222
+ })
223
+ );
224
+
225
+ export const defaultLayer = layer.pipe(
226
+ Layer.provide(StripeClient.defaultLayer)
227
+ );
228
+
229
+ export * as PaymentGateway from './payment-gateway.js';
230
+ ```
231
+
232
+ ```typescript
233
+ // payment-refund-gateway.ts
234
+ import { Context, Effect, Layer, Schema } from 'effect';
235
+ import { StripeClient } from './stripe-client.js';
236
+
237
+ class RefundError extends Schema.TaggedError<RefundError>()('RefundError', {
238
+ message: Schema.String
239
+ }) {}
240
+
241
+ export interface Interface {
242
+ readonly refund: (
243
+ paymentId: PaymentId,
244
+ amount: Cents
245
+ ) => Effect.Effect<RefundResult, RefundError>;
246
+ }
247
+
248
+ export class Service extends Context.Service<Service, Interface>()(
249
+ '@services/payment/PaymentRefundGateway'
250
+ ) {}
251
+
252
+ export const layer = Layer.effect(
253
+ Service,
254
+ Effect.gen(function* () {
255
+ const stripe = yield* StripeClient.Service;
256
+
257
+ const refund = Effect.fn('PaymentRefundGateway.refund')(function* (
258
+ paymentId: PaymentId,
259
+ amount: Cents
260
+ ) {
261
+ return yield* stripe.refund(paymentId, amount);
262
+ });
263
+
264
+ return Service.of({ refund });
265
+ })
266
+ );
267
+
268
+ export const defaultLayer = layer.pipe(
269
+ Layer.provide(StripeClient.defaultLayer)
270
+ );
271
+
272
+ export * as PaymentRefundGateway from './payment-refund-gateway.js';
273
+ ```
274
+
275
+ ```typescript
276
+ // payment-services.ts (a folder barrel only relays canonical identities)
277
+ export { PaymentGateway } from './payment-gateway.js';
278
+ export { PaymentRefundGateway } from './payment-refund-gateway.js';
279
+ ```
280
+
281
+ ## Pattern: Promote Effectful Helpers into Services
282
+
283
+ If a helper is effectful, owns configuration or policy, talks to an external system, or accumulates lifecycle state, do not leave it as a static module helper.
284
+
285
+ Promote it into its own service when any of these are true:
286
+
287
+ - callers should be able to see the dependency in `R`
288
+ - the helper closes over other services or runtime config
289
+ - the helper owns caches, background fibers, subscriptions, or coordination state
290
+ - the helper models a real domain/runtime concept such as `Git`, `Provider`, `SessionRevert`, or `SessionRunState`
291
+
292
+ ```typescript
293
+ // git.ts
294
+ import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
295
+ import { Context, Effect, Layer } from 'effect';
296
+
297
+ export interface Interface {
298
+ readonly run: (args: ReadonlyArray<string>) => Effect.Effect<string>;
299
+ }
300
+
301
+ export class Service extends Context.Service<Service, Interface>()('@app/Git') {}
302
+
303
+ export const layer = Layer.effect(
304
+ Service,
305
+ Effect.gen(function* () {
306
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
307
+
308
+ const run = Effect.fn('Git.run')(function* (
309
+ args: ReadonlyArray<string>
310
+ ) {
311
+ return yield* spawner.string(ChildProcess.make('git', args));
312
+ });
313
+
314
+ return Service.of({ run });
315
+ })
316
+ );
317
+
318
+ export * as Git from './git.js';
319
+ ```
320
+
321
+ This keeps repeated platform details, policy, and formatting logic out of downstream callers.
322
+
323
+ ## Pattern: Coordinator Services for Lifecycle State
324
+
325
+ When a service starts owning busy/idle state, in-flight work maps, cancellation handles, or runner orchestration, extract that concern into its own coordinator service instead of burying it inside a larger feature service.
326
+
327
+ ```typescript
328
+ // session-run-state.ts
329
+ import { Context, Effect } from 'effect';
330
+
331
+ export interface Interface {
332
+ readonly assertNotBusy: (sessionId: SessionID) => Effect.Effect<void>;
333
+ readonly cancel: (sessionId: SessionID) => Effect.Effect<void>;
334
+ }
335
+
336
+ export class Service extends Context.Service<Service, Interface>()(
337
+ '@app/SessionRunState'
338
+ ) {}
339
+
340
+ export * as SessionRunState from './session-run-state.js';
341
+ ```
342
+
343
+ This reduces cognitive load in the parent service and makes race-sensitive behavior directly testable.
344
+
345
+ ## Pattern: No Requirement Leakage
346
+
347
+ Service methods should **never** have requirements in their return type:
348
+
349
+ ```typescript
350
+ // database.ts
351
+ import { Effect, Layer, Schema, Context } from 'effect';
352
+
353
+ class QueryError extends Schema.TaggedError<QueryError>()('QueryError', {
354
+ message: Schema.String
355
+ }) {}
356
+
357
+ export interface Interface {
358
+ readonly query: (sql: string) => Effect.Effect<QueryResult, QueryError>;
359
+ }
360
+
361
+ export class Service extends Context.Service<Service, Interface>()(
362
+ '@services/Database'
363
+ ) {}
364
+
365
+ export const layer = Layer.effect(
366
+ Service,
367
+ Effect.gen(function* () {
368
+ const pool = yield* ConnectionPool.Service; // Captured in closure
369
+
370
+ const query = Effect.fn('Database.query')(function* (sql: string) {
371
+ // Requirements = never; dependencies are in the closure.
372
+ const conn = yield* pool.acquire();
373
+ return yield* conn.execute(sql);
374
+ });
375
+
376
+ return Service.of({ query });
377
+ })
378
+ );
379
+
380
+ export const defaultLayer = layer.pipe(
381
+ Layer.provide(ConnectionPool.defaultLayer)
382
+ );
383
+
384
+ export * as Database from './database.js';
385
+ ```
386
+
387
+ Dependencies are handled by:
388
+
389
+ 1. **`Layer.effect` closure** — services captured at construction time via `yield*`
390
+ 2. **`Layer.provide`** — wires dependency layers into `defaultLayer`
391
+
392
+ Both keep the method signatures clean (`R = never`).
393
+
394
+ ## Pattern: Whole-Function Transforms
395
+
396
+ Additional arguments after an `Effect.fn` body transform the whole function call. Each transform receives the previous effect followed by the original arguments. Use this for concise boundary policy that needs those arguments, such as error classification, annotations, retry, timeout, or cleanup; keep branch-local handling in the generator body.
397
+
398
+ ```typescript
399
+ const get = Effect.fn('UserRepo.get')(
400
+ function* (id: UserId) {
401
+ return yield* database.getUser(id);
402
+ },
403
+ (effect, id) =>
404
+ effect.pipe(
405
+ Effect.annotateLogs({ userId: id }),
406
+ Effect.mapError((cause) => new PersistenceError({ cause }))
407
+ )
408
+ );
409
+ ```
410
+
411
+ Prefer one or two readable transforms over a long wrapper pipeline. These transforms apply to every execution of the returned function.
412
+
413
+ Update Effect callers to use the project's domain projection, such as `yield* UserRepo.Service`, as early as possible once the service exists. Keep async facades only for non-Effect boundaries that still need compatibility.
414
+
415
+ ## Pattern: Leaf Services with a Platform Dependency
416
+
417
+ Even a small "leaf" service often needs a platform capability such as cryptographic UUID generation. Use the platform-agnostic `Crypto` service instead of the global `crypto.randomUUID`, capture it in `Layer.effect`, and wire a concrete implementation (e.g. `NodeCrypto.layer`) in `defaultLayer`:
418
+
419
+ ```typescript
420
+ // id-generator.ts
421
+ import { Context, Crypto, Effect, Layer } from 'effect';
422
+ import { NodeCrypto } from '@effect/platform-node';
423
+
424
+ export interface Interface {
425
+ readonly generate: Effect.Effect<string>;
426
+ }
427
+
428
+ export class Service extends Context.Service<Service, Interface>()(
429
+ '@services/IdGenerator'
430
+ ) {}
431
+
432
+ export const layer = Layer.effect(
433
+ Service,
434
+ Effect.gen(function* () {
435
+ const crypto = yield* Crypto.Crypto;
436
+ // UUID generation failure is unrecoverable here, so collapse the
437
+ // PlatformError into a defect at this boundary with `Effect.orDie`.
438
+ return Service.of({
439
+ generate: crypto.randomUUIDv4.pipe(Effect.orDie)
440
+ });
441
+ })
442
+ );
443
+
444
+ export const defaultLayer = layer.pipe(Layer.provide(NodeCrypto.layer));
445
+
446
+ export * as IdGenerator from './id-generator.js';
447
+ ```
448
+
449
+ A genuinely dependency-free service can still keep `layer` self-contained with `Layer.succeed` and skip `defaultLayer` (see the rule of thumb below).
450
+
451
+ ## Pattern: Composing Capabilities
452
+
453
+ Different implementations support different capabilities:
454
+
455
+ ```typescript
456
+ import { Layer } from 'effect';
457
+
458
+ // Cash payments: Basic handoff only
459
+ export const CashGatewayLive = Layer.mergeAll(
460
+ CashHandoffLive // Implements PaymentGateway
461
+ );
462
+
463
+ // Stripe: Full capability suite
464
+ export const StripeGatewayLive = Layer.mergeAll(
465
+ StripeHandoffLive, // Implements PaymentGateway
466
+ StripeWebhookLive, // Implements PaymentWebhookGateway
467
+ StripeRefundLive // Implements PaymentRefundGateway
468
+ );
469
+ ```
470
+
471
+ ## Pattern: Optional Capabilities
472
+
473
+ Use `Effect.serviceOption` for capabilities that may not be available:
474
+
475
+ ```typescript
476
+ import { Effect, Option } from 'effect';
477
+ import { PaymentGateway } from './payment-gateway.js';
478
+ import { PaymentRefundGateway } from './payment-refund-gateway.js';
479
+
480
+ const processPayment = Effect.gen(function* () {
481
+ const gateway = yield* PaymentGateway.Service;
482
+ const result = yield* gateway.handoff(order.paymentIntent);
483
+
484
+ // Optional capability — check if available
485
+ const refundGateway = yield* Effect.serviceOption(
486
+ PaymentRefundGateway.Service
487
+ );
488
+
489
+ if (Option.isSome(refundGateway)) {
490
+ yield* setupRefundPolicy(refundGateway.value, order);
491
+ }
492
+
493
+ return result;
494
+ });
495
+ ```
496
+
497
+ ## When to Use Interface-Only Services
498
+
499
+ Interface-only service modules (no `layer` export) are appropriate when a service has **no single obvious implementation**. The contract module is projected normally, while implementations live in separate adapter modules:
500
+
501
+ ```typescript
502
+ // clipboard.ts
503
+ import { Context } from 'effect';
504
+ import type { Effect } from 'effect';
505
+
506
+ // Interface-only: multiple implementations exist
507
+ export interface Interface {
508
+ readonly read: Effect.Effect<string, ClipboardError>;
509
+ readonly write: (text: string) => Effect.Effect<void, ClipboardError>;
510
+ }
511
+
512
+ export class Service extends Context.Service<Service, Interface>()(
513
+ '@Clipboard/Clipboard'
514
+ ) {}
515
+
516
+ // No layer here. macOS and Linux adapter modules each export their own layer.
517
+
518
+ export * as Clipboard from './clipboard.js';
519
+ ```
520
+
521
+ **Rule of thumb:**
522
+
523
+ - Existing project service convention → preserve its tag style and naming
524
+ - No project convention → one service ES module whose owning leaf self-exports its canonical identity
525
+ - Has a default implementation → export `layer` and optionally `defaultLayer`
526
+ - Multiple platform implementations → contract module exports `Service`; adapter modules export layers
527
+ - No dependencies → export `layer` only; no `defaultLayer` needed
528
+ - Folder/package barrels → relay the owning leaf's identity; never recreate it
529
+
530
+ ## Testing Benefits
531
+
532
+ Each capability can be tested in isolation:
533
+
534
+ ```typescript
535
+ import { Effect, Layer } from 'effect';
536
+ import { PaymentWebhookGateway } from './payment-webhook-gateway.js';
537
+
538
+ const TestWebhook = Layer.succeed(
539
+ PaymentWebhookGateway.Service,
540
+ PaymentWebhookGateway.Service.of({
541
+ validateWebhook: () => Effect.succeed(undefined)
542
+ })
543
+ );
544
+
545
+ // Test only webhook validation, no other payment concerns
546
+ const testProgram = Effect.gen(function* () {
547
+ const gateway = yield* PaymentWebhookGateway.Service;
548
+ yield* gateway.validateWebhook(payload);
549
+ }).pipe(Effect.provide(TestWebhook));
550
+ ```
551
+
552
+ ```typescript
553
+ // Concise alternative using Layer.mock (v4)
554
+ const TestWebhook = Layer.mock(PaymentWebhookGateway.Service)({
555
+ validateWebhook: () => Effect.succeed(undefined)
556
+ });
557
+ ```
558
+
559
+ `Layer.mock(Service)({...})` is shorthand for `Layer.succeed(Service, Service.of({...}))` — use whichever reads more clearly in context.
560
+
561
+ For a reusable stateful fake, expose a separate test-control service and provide the same implementation under both tags with `Layer.effectContext`. Production code sees only the production interface; tests can inspect state and trigger transitions deterministically.
562
+
563
+ ```typescript
564
+ // notifier.ts
565
+ import { Context, Effect, Layer, Option, Ref } from 'effect';
566
+ import * as Arr from 'effect/Array';
567
+
568
+ export interface Interface {
569
+ readonly send: (message: Message) => Effect.Effect<void, SendError>;
570
+ }
571
+
572
+ export class Service extends Context.Service<Service, Interface>()(
573
+ '@app/Notifier'
574
+ ) {}
575
+
576
+ export interface TestInterface extends Interface {
577
+ readonly sentMessages: () => Effect.Effect<ReadonlyArray<Message>>;
578
+ readonly failNextSend: (error: SendError) => Effect.Effect<void>;
579
+ }
580
+
581
+ export class TestService extends Context.Service<TestService, TestInterface>()(
582
+ '@app/Notifier/Test'
583
+ ) {}
584
+
585
+ export const testLayer = Layer.effectContext(
586
+ Effect.gen(function* () {
587
+ const sent = yield* Ref.make<ReadonlyArray<Message>>([]);
588
+ const nextFailure = yield* Ref.make<Option.Option<SendError>>(
589
+ Option.none()
590
+ );
591
+ const service = TestService.of({
592
+ send: Effect.fn('Notifier.Test.send')(function* (message) {
593
+ const failure = yield* Ref.getAndSet(nextFailure, Option.none());
594
+ if (Option.isSome(failure)) return yield* Effect.fail(failure.value);
595
+ yield* Ref.update(sent, Arr.append(message));
596
+ }),
597
+ sentMessages: Effect.fn('Notifier.Test.sentMessages')(function* () {
598
+ return yield* Ref.get(sent);
599
+ }),
600
+ failNextSend: Effect.fn('Notifier.Test.failNextSend')(function* (error) {
601
+ yield* Ref.set(nextFailure, Option.some(error));
602
+ })
603
+ });
604
+
605
+ return Context.empty().pipe(
606
+ Context.add(Service, service),
607
+ Context.add(TestService, service)
608
+ );
609
+ })
610
+ );
611
+
612
+ export * as Notifier from './notifier.js';
613
+ ```
614
+
615
+ ## Naming Convention
616
+
617
+ Use descriptive capability names for the owning leaf's projected ES-module identity:
618
+
619
+ - `*Gateway` - External system integration
620
+ - `*Repo` / `*Repository` - Data persistence; follow project vocabulary
621
+ - `*Domain` - Business logic
622
+ - `*RunState`, `*Coordinator`, `*Registry` - explicit lifecycle or orchestration state
623
+ - General domain name preferred over generic `*Service` suffix
624
+
625
+ Tag identifiers should include the domain name according to project convention:
626
+
627
+ - `"@app/PaymentGateway"`
628
+ - `"@app/UserRepo"`
629
+ - `"@app/OrderDomain"`
630
+
631
+ `Effect.fn` span names use the projected domain prefix, not the file-local role name:
632
+
633
+ - `Effect.fn("PaymentGateway.handoff")` — not `Effect.fn("Service.handoff")`
634
+ - `Effect.fn("UserRepo.findById")` — not `Effect.fn("UserRepo.Service.findById")`
635
+
636
+ ## Quality Checklist
637
+
638
+ - [ ] Existing project service-tag and module conventions are preserved; no style-only migration to `Context.Service`
639
+ - [ ] When no convention exists, each owning leaf ends with `export * as Domain from "./domain.js"`
640
+ - [ ] Siblings import canonical identities from owning leaves; folder/package barrels only relay them with `export { Domain } from "./domain.js"`
641
+ - [ ] The toolchain and runtime support intentional self-referential ES-module projection
642
+ - [ ] No TypeScript `namespace` declaration is introduced for service organization
643
+ - [ ] When using class-style `Context.Service`, the shape is its type parameter and the class body is empty
644
+ - [ ] Service methods use `Effect.fn("Domain.methodName")` with the projected domain prefix
645
+ - [ ] Service represents single capability
646
+ - [ ] All operations have Requirements = never (no R parameter)
647
+ - [ ] Dependencies captured in `Layer.effect` closure via `yield*`; wired via `Layer.provide` on `defaultLayer`
648
+ - [ ] The project tag's implementation constructor is used; for `Context.Service`, use `Service.of({...})`
649
+ - [ ] Tagged with a descriptive, unique identifier under project convention
650
+ - [ ] `defaultLayer` only present when `layer` has unsatisfied requirements
651
+ - [ ] `defaultLayer` composes directly unless there is a real need for deferred evaluation
652
+ - [ ] Can be tested in isolation with `Layer.succeed` or `Layer.mock`
653
+ - [ ] Can be composed with other capabilities
654
+ - [ ] No use of removed v3 options: `accessors`, `effect`, `succeed`, `dependencies`
655
+
656
+ Keep services focused, composable, and free of leaked requirements.