--- name: effect-context-witness description: Decide between Context.Service witness and capability patterns for dependency injection, understanding coupling trade-offs --- # Context Witness Pattern Choose between witness (existence) and capability (behavior) patterns for Context.Service definitions. ## Coupling: Hard vs Soft **Some coupling is necessary and good** - but move it from hard to soft coupling. ### Hard Coupling (Schema) Field exists in the schema - tightly coupled to domain model: ```typescript import { Schema } from 'effect'; // ❌ HARD COUPLING - Serial is part of the schema export const PaymentIntent = Schema.Struct({ id: Schema.String, serial: Schema.String, // In schema = hard coupled amount: Schema.BigInt }); // Every PaymentIntent MUST have a serial // Serialization/validation requires serial // Cannot create without providing serial // Schema change needed to remove/change serial ``` ### Soft Coupling (Witness) Field **removed from schema**, only injected in code: ```typescript import { Schema, Context, Effect, Logger } from 'effect'; declare const generateId: () => string; // ✅ SOFT COUPLING - Serial not in schema export const PaymentIntent = Schema.Struct({ id: Schema.String, amount: Schema.BigInt // No serial field! }); // Serial is a witness - required but injected via Context class Serial extends Context.Service()('Serial') {} const createPaymentIntent = (amount: bigint) => Effect.gen(function* () { const serial = yield* Serial; // Injected from context // Use serial in business logic, logging, etc. // but it's not part of the persisted data yield* Logger.info(`Creating payment intent ${serial}`); return PaymentIntent.make({ id: generateId(), amount }); }); // Type: Effect ``` **Key insight:** `schema (hard coupling) => witness (soft coupling)` By removing the field from the schema and injecting it only where needed, you: - Keep domain models minimal - Avoid unnecessary persistence - Easy to test (provide test serial) - Easy to remove/change (just change injection) - Explicit dependencies in type signature **When to use witnesses:** - Correlation IDs (for tracing, not persistence) - Request IDs (for logging, not data) - Transaction contexts (for coordination, not storage) - Tenant/Region markers (for routing, not schema) ## Witness: Existence Only Use when you only need to know something **exists** in the environment: ```typescript import { Schema, Context, Effect } from 'effect'; declare const PaymentIntent: Schema.Struct<{ id: typeof Schema.String; serial: typeof Schema.String; amount: typeof Schema.BigInt; }>; declare const other: any; // Witness - a serial number exists export class Serial extends Context.Service()('Serial') {} const createPaymentIntent = Effect.gen(function* () { const serial = yield* Serial; // Pull from environment return PaymentIntent.make({ serial, ...other }); }); // Type: Effect ``` ## Capability: Behavior Use when you need **operations**: ```typescript import { Schema, Context, Effect } from 'effect'; declare const PaymentIntent: Schema.Struct<{ id: typeof Schema.String; serial: typeof Schema.String; amount: typeof Schema.BigInt; }>; declare const other: any; // Capability - can generate/validate export class SerialService extends Context.Service< SerialService, { readonly next: () => string; readonly validate: (s: string) => boolean; } >()('SerialService') {} const createPaymentIntent = Effect.gen(function* () { const svc = yield* SerialService; const serial = svc.next(); // Behavior return PaymentIntent.make({ serial, ...other }); }); // Type: Effect ``` ## Decision Framework | Need | Pattern | | ------------------------ | ---------- | | Just presence/value | Witness | | Operations/generation | Capability | | Precondition marker | Witness | | Side effects | Capability | | Multiple implementations | Capability | | Mocking behavior | Capability | | Correlation ID | Witness | | Transaction context | Witness | | Logger | Capability | | Database | Capability | ## When to Use Witness Good fits: - **Request ID** - must exist for tracing - **Transaction context** - must be established - **Tenant/Region** - required for data boundary - **Pre-validated tokens** - already verified ## When to Use Capability Good fits: - **Serial generation** - create/validate operations - **Clock** - `now()` operation - **Logger** - structured logging methods - **Database** - query/transact operations - **HTTP clients** - fetch/post operations ## Testing Implications Witnesses are trivial to provide: ```typescript import { Effect } from 'effect'; declare const myProgram: Effect.Effect; declare class Serial extends Context.Service()('Serial') {} const test = myProgram.pipe(Effect.provideService(Serial, 'test-serial-123')); ``` Capabilities need implementation: ```typescript import { Effect } from 'effect'; declare const myProgram: Effect.Effect; declare class SerialService extends Context.Service< SerialService, { readonly next: () => string; readonly validate: (s: string) => boolean; } >()('SerialService') {} const test = myProgram.pipe( Effect.provideService(SerialService, { next: () => 'test-serial-123', validate: () => true }) ); ``` ## Coupling Strategy **Rule of thumb**: Remove non-essential fields from schema, inject via witness instead. **Ask yourself:** Does this need to be persisted/serialized? - **No** → Remove from schema, inject via witness - **Yes** → Keep in schema ```typescript import { Schema, Context, Effect, Logger, Clock } from 'effect'; declare const LineItem: Schema.Schema; declare const generateId: () => string; declare const calculateTotal: (items: Array) => bigint; // ✅ Domain model - only persisted data export const Order = Schema.Struct({ id: Schema.String, items: Schema.Array(LineItem), total: Schema.BigInt // No correlationId - not persisted! // No timestamp - derived from system! }); // Witnesses for runtime context class CorrelationId extends Context.Service()( 'CorrelationId' ) {} class RequestId extends Context.Service()('RequestId') {} // Use in code, not in data const createOrder = (items: Array>) => Effect.gen(function* () { const correlationId = yield* CorrelationId; // For tracing const requestId = yield* RequestId; // For logging const timestamp = yield* Clock.currentTimeMillis; // For timestamp yield* Logger.info({ message: 'Creating order', correlationId, // Used for tracing requestId, // Used for logging timestamp }); // Data only contains what's persisted return Order.make({ id: generateId(), items, total: calculateTotal(items) }); }); // Type: Effect ``` **Benefits:** - Minimal schemas (only persisted data) - Context values available when needed - Easy to test with different context - Can add/remove context without schema changes - Explicit dependencies in type signatures Choose witness for simplicity, capability for flexibility.