# Effect Context Witness

> Decide between Context.Service witness and capability patterns for dependency injection, understanding coupling trade-offs

- Skill: `mpsuesser/effect-context-witness` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mpsuesser/effect-context-witness`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mpsuesser/effect-context-witness/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: mpsuesser (https://skillmd.com/u/mpsuesser)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mpsuesser/effect-context-witness

---


# 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, 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:

```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, 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**:

```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<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:

```typescript
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:

```typescript
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

```typescript
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.

