--- name: context-witness description: Decide between Context Tag witness and capability patterns for dependency injection, understanding coupling trade-offs --- # Context Witness Pattern Choose between witness (existence) and capability (behavior) patterns for Context Tags. ## 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.Tag("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.Tag("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.Tag("SerialService")< SerialService, { readonly next: () => string readonly validate: (s: string) => boolean } >() {} 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.Tag("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.Tag("SerialService")< SerialService, { readonly next: () => string readonly validate: (s: string) => boolean } >() {} 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.Tag("CorrelationId")() {} class RequestId extends Context.Tag("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 clock = yield* Clock // For timestamp yield* Logger.info({ message: "Creating order", correlationId, // Used for tracing requestId, // Used for logging timestamp: Clock.currentTimeMillis(clock) }) // 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.