Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Effect stays at the server edges

The repository's architectural decision, rationale and consequences.

Markdown

Effect 4 entered the template as a local utility: fourteen server files, each running its own Effect.run*, with spans nobody exported. The cron handler fanned out five jobs behind a shared AbortSignal, and every job noticed cancellation on its own. Three copies of a Retry-After parser had two different caps, and no transient failure was retried inside a round. The question was how far to take Effect: keep it local, coordinate the edges, or make the backend Effect-native.

Decision

Edges only (depth N1). Effect runs where the server executes work it owns end to end: the scheduled jobs and the outbound transports (webhook POST, Expo push, RevenueCat, email), plus the diagnostics probe that already used it. Public contracts stay Zod, requests stay oRPC, authentication stays Better Auth, database access stays Drizzle. oRPC handlers do not become Effect (@orpc/experimental-effect is beta on a beta), and no Context.Service/Layer refactor happens without a new ADR.

Server only. No client tree imports effect. apps/web/scripts/assert-client-bundle-clean.mjs fails the build if effect/Effect reaches a client asset, and ADR-0002's lint keeps server modules out of client imports.

Stable APIs only. Use modules and functions marked @stability stable in the installed package (Effect, Queue, Stream, Schedule, Cause, Exit, Tracer, Duration, Option, Result, Schema.TaggedError, effect/testing TestClock). Since 4.0.2 an unstable module may break in a minor release; adopting one (http, rpc, workflow, cluster, persistence, observability OTLP…) needs its own ADR. ADR-0006 restricts native SQL/persistence to a workerd spike; production imports use the stable modules and Drizzle.

One run* per entry point. The cron tick is one program, scheduledJobs in packages/api/src/jobs/run.ts, run once by runScheduledJobs from scheduled(). Its jobs return Effect and never run themselves. A transport exposes its Effect (webhookAttempt, sendExpoPushEffect, verifyRevenueCatEffect, sendEmailEffect) and a thin Promise facade for callers outside Effect (oRPC routes, Better Auth, tests); the facade is the only run* on that path and goes through runEdge (packages/api/src/lib/edge.ts), which maps interruption to the transport's cancelled outcome.

Typed errors are converted once, at the edge. Inside a program failures are Schema.TaggedErrors. They become the transport's result union, a persisted row or an ORPCError in one catchTags where the program meets the outside, not in every helper. Interruption is never caught by hand (Cause.hasInterrupts lives in runEdge only). A rejection that must not become a defect uses tryPromise, or drained when the promise owns database state: drained aborts the signal and waits for the promise to settle, so a scope never closes the pool under a running transaction.

One deadline, one pool, independent jobs. scheduledJobs acquires the job pool with acquireRelease in the program scope, runs the six jobs with Effect.all({ mode: "result" }) (a typed failure or a defect in one never interrupts the others), then runs document indexing after those jobs settle, and applies one Effect.timeoutOption of 25 s to the whole tick. Documents have their own 15 s budget, bounded by the remaining tick deadline. ADR-0008 records the memory gate and ordering. The deadline interrupts every job; interruption reaches in-flight fetches through the signal tryPromise/drained pass on; the pool closes last, on success, failure or interruption. Imports reserve cleanup time with a 15 s work budget. The job pool bounds lost query responses with a 2 s client query timeout; the fault measurement explains why a SQL statement timeout alone was insufficient.

Retries only for idempotent reads. idempotentRead (packages/api/src/lib/retry.ts) is exponential backoff with jitter, two retries inside a budget, honouring a Retry-After up to 2 s and handing a longer one back to the persistent policy. It applies to the RevenueCat subscriber GET, Expo getReceipts and import source GETs. A webhook POST and an email send are never retried in-round: a repeated write can duplicate a side effect, so they make one attempt and the outbox/lease policy decides. retryAfterMs is the one Retry-After parser, capped at 24 h.

Zod for third-party payloads. Provider responses (RevenueCat, Expo) are validated with Zod, the same library as the contracts; a parse failure enters the error channel through Effect.try. Effect Schema is used for TaggedError only.

No I/O in a module-level runtime. On Workers a socket belongs to the request that opened it; a ManagedRuntime or Layer built at module scope that opens a pool fails on the next request with "Cannot perform I/O on behalf of a different request". A module-level runtime may only hold pure services (a tracer, validated config). Anything with I/O is created per invocation, as the job pool is.

Native tracing. Effect spans go to Cloudflare's own tracing, not to an OTLP exporter. workersTracer (packages/api/src/lib/tracer.ts) opens a tracing.startSpan per Effect span, copies scalar attributes, records failures and ends it; runScheduledJobs runs the tick inside tracing.enterSpan("jobs.scheduled"). The tracing API comes from ctx.tracing in the handler, typed structurally, so no module imports cloudflare:workers and the code runs untraced in Node (dev, vitest, jobs:run). observability.traces.enabled is on in apps/web/wrangler.jsonc.

Consequences

  • Cloudflare's API has no explicit parent and startSpan never becomes active, so the hierarchy is flat: every Effect span is a sibling under jobs.scheduled, next to the platform's fetch and Hyperdrive spans. There is no setStatus yet; failures show as recorded exceptions and interruptions as effect.interrupted = true.
  • Timeout tests use @effect/vitest and TestClock with the production values (10 s per transport, 25 s per tick) instead of waiting a few real milliseconds.
  • When the deadline hits, the tick reports interrupted: true with empty counters; the work itself is persisted row by row, and the job spans carry what each job did.
  • The cost is a server chunk evaluated at every cold start; docs/measurements/2026-10-12-effect-edges.md records its size. Splitting it with a dynamic import() is against the repo rules and was not done.
  • Going further (Effect-native handlers, services per request) needs a new ADR and one of these triggers: oRPC 2 and @orpc/experimental-effect stable; several handlers composing I/O with retry and timeout; or a startup measurement showing the chunk is free.
Edit on GitHubDocumentation is in English.