A real migration story: typed error channels, Layer dependency injection, and Effect.Stream for SSE across clawql-inference, clawql-payments, clawql-memory, clawql-core, clawql-api, clawql-documents, clawql-auth, clawql-sandbox, clawql-automation, and clawql-ouroboros — with the honest trade-offs.
This pairs with clawql-inference vs LiteLLM (where Effect.Stream and fail-open cache show up in production), four agentic payment rails (the nine-service McpProxyPipeline), and model escalation in the twelve-layer cost stack. Architectural rationale also lives at docs.clawql.com under /learn/effect-ts.
The Problem With Try-Catch at Scale
Every TypeScript codebase that lives long enough develops the same pattern. It starts with a few try-catch blocks around external calls. Then the domain logic gets complex. Then there are errors that need different handling depending on context. Then someone adds a utility that might throw three different exception types but the type signature just says Promise<T>. Then the codebase grows and nobody can remember which functions throw, which return null, which reject with an Error, and which reject with a domain-specific object that someone typed inline six months ago.
You end up with code like this scattered everywhere:
async function processDocument(id: string): Promise<ProcessedDocument> {
try {
const doc = await fetchDocument(id); // throws? returns null? who knows
const extracted = await extract(doc); // might throw ExtractError or just Error
const indexed = await indexInOnyx(extracted); // network timeout? auth error? both?
return indexed;
} catch (e) {
// What is e? Error? ExtractError? AxiosError? string? null?
// How do we handle each case differently?
// How does the caller know which case occurred?
logger.error('something went wrong', e);
throw e; // propagate and hope the caller handles it
}
}
The type system tells you the happy path. It tells you nothing about what goes wrong, how it goes wrong, or how callers should handle each failure mode differently. The errors are real — they happen in production — but they’re invisible to the type system. You discover them in logs, in Sentry, in postmortems.
ClawQL’s architecture made this problem acute. The IDP pipeline, inference gateway, and payment layer all have complex async chains where failure semantics matter enormously:
- A WORM write failure after a successful action should roll back execution — but only if the write is the kind that gates execution
- A model escalation from Frugal to Standard is different from a provider timeout, which is different from an auth failure, which is different from a rate limit
- A payment WORM entry that fails to write is a different severity than a payment verification that fails — one should retry, one should alert
- A semantic cache embedding failure should fail open to live inference, never fail closed
- A steganography detection failure in the IDP pipeline should quarantine the document, not silently pass it through
With try-catch and union return types, representing all of this correctly requires either enormous discipline or enormous runtime surprises. We had the latter. The Effect-TS migration was the architectural response.
Lesson: if your failure modes only show up in Sentry, the type system already lost. Effect doesn’t invent those modes — it makes them impossible to ignore at compile time.
What Effect-TS Actually Is
Effect is a TypeScript library for writing programs as composable, typed descriptions of computation. The core type is:
Effect<Success, Failure, Requirements>;
Where:
Successis what you get when things workFailureis the typed union of every way things can failRequirementsis what services the computation needs to run
The critical property: failures are values in the type system, not exceptions that escape it. Every failure mode is explicit in the function signature. Callers cannot accidentally ignore them. The compiler enforces handling.
Compare:
// TypeScript: the type system lies to you about errors
async function fetchDocument(id: string): Promise<Document>
// Reality: throws NetworkError | AuthError | NotFoundError | Error
// The caller has no idea
// Effect: the type system tells the truth
const fetchDocument = (
id: string,
): Effect<Document, NetworkError | AuthError | NotFoundError, HttpService>
// Every failure mode is explicit. Every requirement is declared.
The Migration Decision: Boundary-Only Effect
The first architectural decision most Effect tutorials skip: do you go full Effect everywhere, or do you use Effect internally and expose Promise-based APIs at boundaries?
We chose boundary-only Effect. The reasoning:
ClawQL is a monorepo with 12 packages. clawql-mcp — the MCP transport layer — is consumed by third-party MCP clients that have no knowledge of Effect and no interest in adding it as a dependency. Making them consume Effect<A, E, R> at the API boundary would require either adding Effect as a peer dependency or using Effect.runPromise wrappers on every call. That’s unreasonable friction for a public-facing package.
The correct boundary: MCP handlers stay Promise-based. Domain logic runs through Effect with Layer DI.
// MCP handler — Promise-based, public API, unchanged from caller's perspective
async function handleSearch(query: string): Promise<SearchResult> {
return Effect.runPromise(searchEffect(query).pipe(Effect.provide(SearchServiceLive)));
}
// Domain logic — Effect-native, fully typed errors, injectable dependencies
const searchEffect = (
query: string
): Effect<SearchResult, SearchError | EmbeddingError, SearchService> =>
Effect.gen(function* () {
const service = yield* SearchService;
const embedding = yield* service
.embed(query)
.pipe(Effect.mapError((e) => new EmbeddingError({ cause: e })));
return yield* service.search(embedding);
});
The MCP handler is testable with standard patterns. The domain logic has fully typed errors and injectable dependencies. Consumers of the MCP package see nothing different.
The pattern throughout: Context.Tag + Layer for dependency injection, Data.TaggedError for typed errors, Effect.gen for composition, Effect.runPromise only at the outermost boundary.
The Migration Scope
Over the course of roughly one week — the majority done in a single extended weekend session — the migration touched 10 of 12 packages and crossed 90% completion. Here’s the full scope:
Completed:
clawql-inference— 5 Effect services: FallbackChainService, SemanticCacheService, EntitlementEnforcementService, ModelEscalationService, Effect.Stream SSE streamingclawql-payments— McpProxyPipeline (9 Effect services), Stripe Effect services (client, webhook, meter, billing)clawql-core— CacheService Effect layer, ConfigService centralized env readersclawql-api— execute-core and search-core both native Effect.gen (the two highest-traffic MCP tools)clawql-memory— MemoryIngestService, MemoryRecallService, VaultConfigService, MemoryDbService, EmbeddingService, recall vector passclawql-documents— ingest_external_knowledge Effect bridge
Remaining (~10%):
clawql-automationclawql-ouroborosclawql-sandboxclawql-auth
These migrate when touched for other reasons — new features, test coverage improvements — rather than as a dedicated sprint. The pattern is established; it’s mechanical application at this point.
The Key Patterns
Typed Errors as Tagged Classes
Every failure mode is a class that extends Data.TaggedError:
export class RoutingFailureSignal extends Data.TaggedError('RoutingFailureSignal')<{
readonly tier: ModelTier;
readonly reason: 'low_confidence' | 'provider_error' | 'timeout' | 'drift_exceeded';
readonly correlation_id: string;
}> {}
export class AllProvidersExhausted extends Data.TaggedError('AllProvidersExhausted')<{
readonly request: InferenceRequest;
readonly attempted: ProviderConfig[];
}> {}
export class WORMWriteError extends Data.TaggedError('WORMWriteError')<{
readonly cause: unknown;
readonly entry: Omit<WORMEntry, 'worm_seq'>;
}> {}
Effect.catchTag matches on specific error types. Callers can handle RoutingFailureSignal differently from AllProvidersExhausted without parsing message strings.
Layer Dependency Injection
Services are defined as Context tags and implemented as Layers:
// Service interface
export class SearchService extends Context.Tag('ClawQL/Search')<
SearchService,
{
readonly search: (
query: string,
options: SearchOptions
) => Effect<SearchResult[], SearchError | EmbeddingError, never>;
readonly embed: (text: string) => Effect<Float32Array, EmbeddingError, never>;
}
>() {}
// Live implementation
export const SearchServiceLive: Layer<SearchService, ConfigError, OnyxClient | EmbeddingClient> =
Layer.effect(
SearchService,
Effect.gen(function* () {
const onyx = yield* OnyxClient;
const embedder = yield* EmbeddingClient;
return {
search: (query, options) =>
Effect.gen(function* () {
const embedding = yield* embedder.embed(query);
return yield* onyx.search(embedding, options);
}),
embed: (text) => embedder.embed(text),
};
})
);
// Test implementation — no external dependencies
export const SearchServiceTest = Layer.succeed(SearchService, {
search: () => Effect.succeed([fixtures.searchResult()]),
embed: () => Effect.succeed(new Float32Array(1536)),
});
Tests compose layers without module mocking:
it('records WORM entry on provider fallback', () =>
Effect.gen(function* () {
const result = yield* complete(fixtures.request());
const worm = yield* WORMServiceTest.recorded();
expect(worm.entries).toHaveLength(1);
expect(worm.entries[0].event_kind).toBe('PROVIDER_FALLBACK');
expect(result.provider).toBe('second-provider');
}).pipe(
Effect.provide(
Layer.mergeAll(
FallbackChainServiceTest({ providers: [failingProvider, succeedingProvider] }),
WORMServiceTest(),
ObservabilityServiceTest()
)
),
Effect.runPromise
));
No jest.mock(). No shared mock state. The test Layer is a first-class value that composes cleanly.
Fail-Open vs Fail-Closed: Made Explicit
The semantic cache has a failure mode that requires explicit handling: embedding failures should fail open (skip the cache, call the model), while cache corruption should fail closed (surface the error, don’t silently proceed).
With try-catch, both were handled identically — catch and ignore. With Effect:
const completeWithCache = (
request: InferenceRequest
): Effect<InferenceResponse, ProviderError, CachedInferenceService> =>
Effect.gen(function* () {
const cache = yield* SemanticCacheService;
const cached = yield* cache.lookup(request).pipe(
Effect.catchTag('EmbeddingError', () => Effect.succeed(Option.none())), // fail open
Effect.catchTag('CacheMissError', () => Effect.succeed(Option.none())) // fail open
// CacheCorruptionError propagates — it's a bug, surface it
);
if (Option.isSome(cached)) return cached.value;
const response = yield* liveComplete(request);
yield* cache.store(request, response).pipe(
Effect.catchAll(() => Effect.void) // store failure never blocks response
);
return response;
});
Effect.catchTag is surgical. EmbeddingError and CacheMissError are explicitly handled. CacheCorruptionError propagates because it indicates a bug that shouldn’t be silently swallowed. The store failure after a successful live call is explicitly voided — best-effort, never blocking.
This is the kind of nuanced error handling that’s effectively impossible to reason about correctly with untyped exceptions.
Entitlements: Fail Closed When Service Is Down
Plan limit enforcement has two distinct failure modes: PlanLimitExceeded (user hit their limit — surface to caller as a structured 429) and EntitlementServiceUnavailable (service is down — fail closed, don’t let inference run unchecked):
export class PlanLimitExceeded extends Data.TaggedError('PlanLimitExceeded')<{
readonly tenant: string;
readonly limit: number;
readonly current: number;
readonly resetAt: Date;
}> {}
export class EntitlementServiceUnavailable extends Data.TaggedError(
'EntitlementServiceUnavailable'
)<{
readonly cause: unknown;
}> {}
const enforceEntitlements = (
request: InferenceRequest
): Effect<void, PlanLimitExceeded | EntitlementServiceUnavailable, EntitlementService> =>
Effect.gen(function* () {
const svc = yield* EntitlementService;
const plan = yield* svc
.getPlan(request.tenantId)
.pipe(Effect.mapError((e) => new EntitlementServiceUnavailable({ cause: e })));
const usage = yield* svc
.getCurrentUsage(request.tenantId)
.pipe(Effect.mapError((e) => new EntitlementServiceUnavailable({ cause: e })));
if (usage.inferenceCallsThisMonth >= plan.inferenceCallLimit) {
yield* Effect.fail(
new PlanLimitExceeded({
tenant: request.tenantId,
limit: plan.inferenceCallLimit,
current: usage.inferenceCallsThisMonth,
resetAt: usage.resetAt,
})
);
}
});
If EntitlementServiceUnavailable, inference is blocked. If PlanLimitExceeded, inference is blocked with a structured error the HTTP layer turns into a well-formed response with retry-after information. Neither case silently succeeds.
Effect.Stream for SSE Streaming
This was the most significant ergonomic improvement. SSE streaming was previously manual async generator plumbing with a fragile custom retry loop. With Effect.Stream:
const streamCompletion = (
request: InferenceRequest
): Stream.Stream<StreamChunk, StreamError, InferenceGatewayService> =>
Stream.unwrap(
Effect.gen(function* () {
const gateway = yield* InferenceGatewayService;
const provider = yield* gateway.resolveProvider(request);
return provider.stream(request).pipe(
Stream.mapError((e) => new StreamError({ cause: e, provider: provider.id })),
Stream.tap((chunk) => gateway.recordChunk(request.correlationId, chunk)),
Stream.ensuring(
// Runs regardless of normal completion, interruption, or error
gateway.recordStreamComplete(request.correlationId)
)
);
})
);
Stream.ensuring is the critical piece — it runs regardless of how the stream ends. No more “stream started but not completed” state in the call store. The completion record is always written.
For Anthropic and Ollama, which use different streaming protocols from OpenAI SSE, streamCompletionAsOpenAiSseEffect normalizes the output. The stream composition handles backpressure correctly and propagates interruption cleanly.
The 9-Service McpProxyPipeline
The payments migration produced the most complex Effect composition in the codebase: a pipeline of 9 Effect services handling the full MCP tool call lifecycle:
Request → ConfigService → AuditService → EntitlementService
→ X402GateService → X402RuntimeService → X402FacilitatorService
→ X402EnforcementService → UsageService → DiscoveryService
→ Response
Each service has typed error channels. The pipeline composes them with Effect.gen. If any service fails — including x402 payment verification, entitlement checking, or usage recording — the failure propagates with the specific typed error that lets the HTTP layer return the right response.
Before Effect, this was nine try-catch blocks in sequence with inconsistent error handling at each step. After Effect, it’s one typed composition where every failure mode is visible in the return type. Same stack described from the payments side in the four rails post.
The ConfigService Win
Before the migration, environment variable reads were scattered across 12 packages:
// In clawql-inference/src/gateway.ts
const store = process.env.CLAWQL_INFERENCE_STORE ?? 'jsonl';
// In clawql-inference/src/cache.ts
const threshold = parseFloat(process.env.CLAWQL_INFERENCE_CACHE_THRESHOLD ?? '0.92');
// In clawql-memory/src/vault.ts
const addr = process.env.VAULT_ADDR; // undefined crash if missing, no error message
After ConfigService:
export class ConfigService extends Context.Tag('ClawQL/Config')<
ConfigService,
{
readonly inferenceStore: Effect<'jsonl' | 'postgres', never, never>;
readonly semanticCacheThreshold: Effect<number, never, never>;
readonly vaultAddr: Effect<string, ConfigMissingError, never>;
readonly syncBucket: Effect<string, ConfigMissingError, never>;
}
>() {}
vaultAddr returns Effect<string, ConfigMissingError> — required, fails with a named error if missing. inferenceStore returns Effect<'jsonl' | 'postgres', never> — optional with a default, never fails. The return type encodes the optionality. No more TypeError: Cannot read property 'split' of undefined in production from a missing required env var — the error happens at service construction with a message that names the missing variable.
The Test Story
This is where the migration paid for itself most visibly. Before Effect, testing the fallback chain required mocking the entire provider module with jest.mock() and managing mock state across tests. With Layer composition, every test is isolated:
const TestLayers = Layer.mergeAll(
FallbackChainServiceTest({
providers: [failingProvider, succeedingProvider],
}),
WORMServiceTest(),
ObservabilityServiceTest()
);
it('escalates to second provider on first provider failure', () =>
Effect.gen(function* () {
const result = yield* complete(fixtures.request());
const worm = yield* WORMServiceTest.recorded();
expect(worm.entries[0].event_kind).toBe('PROVIDER_FALLBACK');
expect(result.provider).toBe('second-provider');
}).pipe(Effect.provide(TestLayers), Effect.runPromise));
The WORMServiceTest.recorded() accessor returns all WORM entries written during the test. Audit trail correctness is testable as a first-class assertion — not just “did the function return the right value” but “did the audit trail reflect what happened correctly.” This was effectively untestable before Effect without significant test infrastructure investment.
Lesson: Layer test doubles turn the WORM trail into an assertion surface. If you can’t prove the audit entry without jest.mock gymnastics, your error model is still ambient.
The Honest Trade-offs
Effect-TS is not a free lunch.
Learning curve is real. Effect’s mental model — typed errors as values, services as Context tags, Layers as dependency graphs — is different from standard TypeScript async/await. Budget two to four weeks for a developer to become productive. The documentation has improved substantially but is still not comprehensive.
Type error messages are cryptic. When you compose Effect operations incorrectly, the TypeScript compiler produces errors that require understanding Effect’s internals to parse. This improves with experience but it’s a genuine cost at the start.
Not every problem benefits from Effect. Simple transformations, pure utility functions, and straightforward data manipulation don’t need Effect’s machinery. We kept those as plain TypeScript. Effect is for complex async chains with multiple failure modes — not everything.
The Zod vs @effect/schema decision. We have existing Zod schemas throughout the codebase. We kept Zod for now and add @effect/schema as we touch each package, rather than migrating everything at once. Both work. The interop is not frictionless.
Full Effect vs boundary-only. We chose boundary-only. Full Effect — public APIs return Effect<A, E, R> and callers use Effect too — delivers greater benefits but requires every consumer to adopt Effect. The right choice depends on whether you control your consumers. We don’t control all of ours.
When Effect Is Worth It
The honest answer: Effect is worth it when you have multiple failure modes that need different handling, complex async composition, and dependency injection requirements that make constructor injection painful at scale.
The clearest signal it’s the right choice: you have functions that throw multiple distinct exception types, and callers somewhere in your codebase are trying to catch specific ones by checking the error message string. That’s the pain Effect is built to address.
For a simple CRUD API with one database and standard error handling, Effect would be over-engineering. Use the tools that match the problem.
What’s Next
The remaining ~10% — clawql-automation, clawql-ouroboros, clawql-sandbox, clawql-auth — migrates as each is touched for other features. The pattern is established; the remaining work is mechanical application.
The most architecturally interesting remaining migration is clawql-ouroboros. Once it’s Effect-native, the full chain from “inference call failed” to “Ouroboros triggers agent coordination” to “escalation decision logged to WORM” will be typed end-to-end. The model escalation router’s RoutingFailureSignal flows from clawql-inference into Ouroboros’s convergence gate as a typed value, not an exception. That’s the endpoint: a codebase where the type system enforces that every failure mode is handled, and the audit trail is a consequence of the type composition rather than something you remember to add.
The ClawQL monorepo, including all Effect-migrated packages, is at github.com/danielsmithdevelopment/ClawQL. The /learn/effect-ts guide at docs.clawql.com covers the architectural rationale.