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:
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:
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, string>()('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<PaymentIntent, never, Serial>
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:
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, string>()('Serial') {}
const createPaymentIntent = Effect.gen(function* () {
const serial = yield* Serial; // Pull from environment
return PaymentIntent.make({ serial, ...other });
});
// Type: Effect<PaymentIntent, never, Serial>
Capability: Behavior
Use when you need operations:
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<PaymentIntent, never, SerialService>
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:
import { Effect } from 'effect';
declare const myProgram: Effect.Effect<unknown, never, Serial>;
declare class Serial extends Context.Service<Serial, string>()('Serial') {}
const test = myProgram.pipe(Effect.provideService(Serial, 'test-serial-123'));
Capabilities need implementation:
import { Effect } from 'effect';
declare const myProgram: Effect.Effect<unknown, never, SerialService>;
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
import { Schema, Context, Effect, Logger, Clock } from 'effect';
declare const LineItem: Schema.Schema<any>;
declare const generateId: () => string;
declare const calculateTotal: (items: Array<any>) => 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, string>()(
'CorrelationId'
) {}
class RequestId extends Context.Service<RequestId, string>()('RequestId') {}
// Use in code, not in data
const createOrder = (items: Array<Schema.Schema.Type<typeof LineItem>>) =>
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<Order, never, CorrelationId | RequestId>
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.