Effect TS

Diminuendo is built entirely on Effect, a TypeScript library that replaces ad-hoc error handling, manual dependency wiring, and unstructured concurrency with a principled, type-safe programming model. This page is the comprehensive treatment: it begins with why Effect was chosen, proceeds through the patterns used in the gateway, and concludes with a reference for every Effect primitive you will encounter in the source.

Why Effect

TypeScript is a remarkable type system bolted onto a language that was never designed for type safety. It catches shape errors at compile time — wrong field names, missing properties, incompatible types — but it is structurally blind to four categories of bugs that account for the majority of production incidents in backend services.

Invisible Errors

Functions throw exceptions that are not in their type signatures. fetchUser(id) might throw a network error, a parse error, or a 404 — but the return type says Promise<User>. Callers must remember to catch, and TypeScript cannot verify that they do.

Hidden Dependencies

Services import singletons directly: import { db } from "./database". This makes every consumer implicitly dependent on the entire module graph. Unit testing requires mocking module imports. Swapping implementations requires changing every import site.

Leaked Resources

Database connections, file handles, WebSocket streams, and timers must be manually closed in finally blocks. Miss one, and you get a slow memory leak that manifests as an OOM crash three days later. Compose two resources, and the cleanup logic becomes nested and error-prone.

Orphaned Concurrency

Promise.all([a, b, c]) starts three tasks, but if a fails, b and c keep running. There is no cancellation. Background setTimeout callbacks fire after the request is gone. Orphaned promises silently swallow errors.
These are not exotic edge cases. They are the default failure modes of every Node.js/Bun backend. Effect solves all four in a single, composable abstraction: Effect<Success, Error, Requirements>. The three type parameters make success values, failure modes, and dependencies explicit at the type level.

The Core Type

Effect<Success, Error, Requirements>
  • Success — the value produced when the computation succeeds
  • Error — the typed error(s) that may occur, tracked in the type signature
  • Requirements — the services this computation depends on, resolved at composition time
Nothing runs until you explicitly execute it. An Effect is a description of a computation, not the computation itself. This laziness is what enables composition, retry, timeout, cancellation, and resource management to work as orthogonal combinators rather than tangled ad-hoc patterns.

Patterns

Layers as Dependency Injection

Every service in Diminuendo is defined as a Context.Tag — a typed key that identifies a service interface — with a corresponding Live implementation wrapped in a Layer.

Defining a Service

// src/auth/AuthService.ts
export class AuthService extends Context.Tag("AuthService")<
  AuthService,
  {
    readonly authenticate: (token: string | null) => Effect.Effect<AuthIdentity, Unauthenticated>
  }
>() {}

export const AuthServiceLive = Layer.effect(
  AuthService,
  Effect.gen(function* () {
    const config = yield* AppConfig  // dependency declared, not imported

    if (config.devMode) {
      return {
        authenticate: (_token) => Effect.succeed({
          userId: "dev-user-001",
          email: "developer@example.com",
          tenantId: "dev",
        }),
      }
    }
    // Production: JWT verification via JWKS endpoint
    // ...
  })
)
The critical insight: AuthServiceLive does not import AppConfig as a module — it requires it as a Layer dependency. The Effect runtime resolves this dependency at startup via the Layer graph. If the dependency is not provided, the program does not compile.

Composing the Layer Graph

In src/main.ts, all layers are composed into a single dependency tree. The composition makes the dependency structure explicit and visible in one place:
const TenantDbPoolLayer = TenantDbPoolLive.pipe(Layer.provide(AppConfigLive))

const ConfigProvidedLayers = Layer.mergeAll(
  SessionRegistryServiceLive,
  ProjectRegistryServiceLive,
  PodiumClientLive,
  AuthServiceLive,
  WorkerManagerLive,
  EnsembleClientLive,
  MembershipServiceLive,
  TurnTrackerLive,
  CredentialServiceLive,
  InvitationServiceLive,
  AuditServiceLive,
  FileStorageServiceLive,
  SkillServiceLive,
  UserPreferencesServiceLive,
).pipe(Layer.provide(Layer.mergeAll(AppConfigLive, TenantDbPoolLayer)))

const BillingLayer = BillingServiceUnlimitedLive.pipe(
  Layer.provide(Layer.mergeAll(AppConfigLive, ConfigProvidedLayers)),
)

const AutomationEngineLayer = AutomationEngineLive.pipe(
  Layer.provide(Layer.mergeAll(
    AutomationStoreLayer, SessionRuntimeLayer, RegistryLayer,
    BroadcastLayer, BillingLayer, AppConfigLive,
  )),
)
Layer.mergeAll combines independent layers in parallel. Layer.provide chains dependent layers in sequence. The Effect runtime resolves the full dependency graph, constructs each service exactly once, and provides it to the program.
There is no service locator, no global registry, no runtime reflection. If a layer requires a dependency that is not provided, it is a compile-time type error. The entire wiring is statically verified before the gateway accepts its first connection.

Typed Errors

Diminuendo uses Data.TaggedError to define a closed set of domain errors. Each error is a branded class with a _tag discriminant:
// src/errors.ts
export class Unauthenticated extends Data.TaggedError("Unauthenticated")<{
  readonly reason: string
}> {}

export class SessionNotFound extends Data.TaggedError("SessionNotFound")<{
  readonly sessionId: string
}> {}

export class PodiumConnectionError extends Data.TaggedError("PodiumConnectionError")<{
  readonly message: string
  readonly cause?: unknown
}> {}

export class DbError extends Data.TaggedError("DbError")<{
  readonly message: string
  readonly cause?: unknown
}> {}
These errors appear in the type signatures of the functions that can produce them. PodiumClient.connect returns Effect.Effect<PodiumConnection, PodiumConnectionError> — the caller knows exactly what can go wrong and must handle it explicitly.

Error Propagation Without Try/Catch

In the MessageRouter, errors propagate through Effect.gen without any try/catch blocks:
// src/session/MessageRouterLive.ts (simplified)
case "run_turn": {
  const canProceed = yield* billing.canProceed(identity.tenantId)
  const session = yield* registry.get(identity.tenantId, message.sessionId)
  const active = yield* ensurePodiumConnection(
    message.sessionId, identity, session.agentType
  )
  yield* active.connection.sendMessage(message.text, { ... })
  return { kind: "broadcast" as const }
}
Each yield* can fail with a typed error. If any fails, execution short-circuits to the outer catchAll, which maps every typed error to a safe client-facing response:
.pipe(
  Effect.catchAll((err) =>
    Effect.gen(function* () {
      yield* Effect.logError(`Route error: ${err}`)
      const code = (err as { _tag?: string })._tag ?? "INTERNAL_ERROR"
      const safeMessages: Record<string, string> = {
        Unauthenticated: "Authentication required",
        SessionNotFound: "Session not found",
        PodiumConnectionError: "Failed to connect to agent",
        DbError: "Database operation failed",
      }
      return {
        kind: "respond" as const,
        data: { type: "error", code, message: safeMessages[code] ?? sanitizeErrorMessage(err) },
      }
    })
  )
)
The sanitizeErrorMessage function strips internal details from error messages before they reach clients. This prevents information leakage while preserving useful error codes for debugging.

Streams for Event Processing

The Podium agent produces a stream of events (thinking progress, tool calls, text deltas, terminal output) over a WebSocket connection. Diminuendo models this as an Effect Stream and consumes it in a forked daemon fiber.

Creating the Event Stream

PodiumClient.connect() creates an unbounded Queue, feeds incoming WebSocket messages into it, and exposes it as a Stream:
// src/upstream/PodiumClient.ts (simplified)
const eventQueue = yield* Queue.unbounded<PodiumEvent>()

ws.addEventListener("message", (ev) => {
  const event: PodiumEvent = {
    messageType: json.type ?? json.message_type ?? "",
    content: json.content,
    agentId: json.agent_id,
  }
  Effect.runSync(Queue.offer(eventQueue, event))
})

ws.addEventListener("close", () => {
  Effect.runSync(Queue.shutdown(eventQueue))
})

const connection: PodiumConnection = {
  events: Stream.fromQueue(eventQueue),
  // ...
}

Consuming the Stream

MessageRouterLive consumes the stream with Stream.runForEach, forked as a daemon fiber so it outlives the originating request:
const startEventStreamFiber = (
  sessionId: string, tenantId: string,
  connection: PodiumConnection, cs: ConnectionState,
) => {
  return connection.events.pipe(
    Stream.runForEach((event) =>
      Effect.gen(function* () {
        const turnId = (yield* Ref.get(cs.turnId)) ?? "unknown"
        const clientEvents = mapPodiumEvent(sessionId, turnId, event)
        for (const clientEvent of clientEvents) {
          yield* broadcaster.sessionEvent(sessionId, clientEvent)
          yield* dispatchToHandler(ctx, clientEvent, turnId, event)
        }
      })
    ),
    Effect.tap(() =>
      Effect.gen(function* () {
        yield* Ref.update(activeSessionsRef, HashMap.remove(sessionId))
        yield* transitionSessionState(tenantId, sessionId, cs, "inactive")
      })
    ),
    Effect.catchAll((err) =>
      Effect.gen(function* () {
        yield* Effect.logError(`Event stream error: ${err}`)
        yield* broadcaster.sessionEvent(sessionId, {
          type: "turn_error", message: "Lost connection to agent",
          code: "AGENT_DISCONNECTED",
        })
        yield* transitionSessionState(tenantId, sessionId, cs, "error")
      })
    ),
  )
}

// Forked as a daemon fiber -- runs independently of the request fiber
const eventFiber = yield* Effect.forkDaemon(
  startEventStreamFiber(sessionId, identity.tenantId, connection, cs)
)
The daemon fiber runs until the stream completes (agent disconnects), errors out (network failure), or is explicitly interrupted (session deletion). Structured concurrency guarantees cleanup in all three cases.

Refs for Mutable State

Effect’s Ref provides thread-safe mutable state with atomic read-modify-write operations. Diminuendo uses Refs extensively for per-connection state that changes during a turn.

ConnectionState

Each active session maintains a ConnectionState — a struct of Refs tracking the current turn, accumulated text, pending tool calls, thinking state, billing reservation, and more:
export interface ConnectionState {
  readonly turnId: Ref.Ref<string | null>
  readonly fullContent: Ref.Ref<string>
  readonly stopRequested: Ref.Ref<boolean>
  readonly turnStopped: Ref.Ref<boolean>
  readonly pendingToolCalls: Ref.Ref<Map<string, { toolName: string; startedAt: number }>>
  readonly completedToolIds: Ref.Ref<Set<string>>
  readonly isThinking: Ref.Ref<boolean>
  readonly thinkingContent: Ref.Ref<string>
  readonly currentReservation: Ref.Ref<CreditReservation | null>
  readonly sessionState: Ref.Ref<SessionState>
  // ... 15+ refs total
}
Ref.update guarantees atomic read-modify-write, which is essential when multiple events arrive concurrently on the stream fiber:
// Atomic text accumulation -- no race condition even with concurrent events
yield* Ref.update(ctx.cs.fullContent, (text) => text + clientEvent.text)
The active sessions map uses HashMap (an immutable persistent data structure) inside a Ref for concurrent-safe updates:
const activeSessionsRef = yield* Ref.make(HashMap.empty<string, ActiveSession>())

yield* Ref.update(activeSessionsRef, HashMap.set(sessionId, activeSession))
yield* Ref.update(activeSessionsRef, HashMap.remove(sessionId))

Effect.gen for Sequential Composition

Effect’s generator syntax provides readable sequential composition without callback nesting or promise chains. Each yield* suspends execution until the effect completes, and failures short-circuit through the generator:
case "run_turn": {
  // 1. Check billing
  const canProceed = yield* billing.canProceed(identity.tenantId)
  if (!canProceed) {
    return { kind: "respond", data: { type: "error", code: "INSUFFICIENT_CREDITS" } }
  }
  // 2. Reserve credits
  const reservation = yield* billing.reserveCredits({ ... })
  // 3. Fetch session metadata
  const session = yield* registry.get(identity.tenantId, message.sessionId)
  // 4. Connect to agent
  const active = yield* ensurePodiumConnection(message.sessionId, identity, session.agentType)
  // 5. Reset turn state and send message
  yield* resetTurnState(active.state)
  yield* active.connection.sendMessage(message.text, { ... })
  return { kind: "broadcast" }
}
Each step reads linearly. If any yield* fails, execution jumps to the outer catchAll. No intermediate try/catch is needed. The generator is the happy path; the error channel is the type system’s concern.

Schedule for Retry and Backoff

Effect’s Schedule combinator provides composable retry policies:
// src/resilience/RetryPolicy.ts

/** Podium retry: 500ms base exponential with jitter, 3 retries max */
export const podiumRetry = Schedule.exponential("500 millis").pipe(
  Schedule.jittered,
  Schedule.compose(Schedule.recurs(3)),
)

/** Ensemble retry: 1s base exponential with jitter, 2 retries max */
export const ensembleRetry = Schedule.exponential("1 seconds").pipe(
  Schedule.jittered,
  Schedule.compose(Schedule.recurs(2)),
)
Without jitter, all retrying clients hit the backend simultaneously after each exponential delay, creating a thundering herd effect. Schedule.jittered adds random variance to each delay, spreading retries over time and reducing peak load on the recovering service. This is especially important for Podium connections, where multiple sessions may disconnect simultaneously during a backend restart.
These schedules compose with any effectful operation via Effect.retry:
yield* podium.createInstance(params).pipe(Effect.retry(podiumRetry))

Circuit Breaker

For persistent upstream failures, the gateway includes a circuit breaker that prevents cascading failures by fast-failing requests when a backend is known to be down:
// src/resilience/CircuitBreaker.ts (simplified)
const breaker = yield* makeCircuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 })

yield* breaker.execute(ensembleCall).pipe(
  Effect.retry(ensembleRetry),
  Effect.catchTag("CircuitBreakerOpen", (err) =>
    Effect.fail(new EnsembleError({ message: err.message, statusCode: 503 }))
  ),
)
The circuit breaker uses a Ref for its internal state (closed/open/half-open, failure count, cooldown timer), ensuring atomic state transitions even when multiple fibers attempt concurrent requests. The three-state model — closed (allowing requests), open (fast-failing), half-open (probing recovery) — is the standard Hystrix pattern, implemented here without external dependencies.

Redacted for Secrets

Configuration values that contain secrets are typed as Redacted<string> — an opaque wrapper that prevents accidental logging or serialization:
// src/config/AppConfig.ts
export interface AppConfigShape {
  readonly podiumApiKey: Redacted.Redacted<string>
  readonly ensembleApiKey: Redacted.Redacted<string>
  readonly authClientSecret: Redacted.Redacted<string>
  readonly e2bApiKey: Redacted.Redacted<string>
  // ...
}
If you accidentally pass a Redacted value to console.log, JSON.stringify, or Effect’s structured logger, it renders as <redacted>. You must explicitly call Redacted.value() to unwrap it — making every secret consumption site auditable with a single grep:
const apiKey = Redacted.value(config.podiumApiKey)
const authHeaders = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
This is not security by obscurity — it is defense in depth. The type system makes accidental exposure a deliberate act that requires an explicit function call.

Pattern Summary

PatternWhat It SolvesWhere It Appears
Context.Tag + LayerDependency injection without singletonsEvery service definition (20+ services, 1 composition file)
Data.TaggedErrorTyped errors in the type signaturesrc/errors.ts — 14 error types
Stream + QueueBackpressure-aware event streamingPodiumClient.connect() event stream
Effect.forkDaemonBackground fibers that outlive their parentEvent stream consumption, automation scheduler
Ref + HashMapThread-safe mutable stateConnectionState, activeSessionsRef, Broadcaster
Effect.genReadable sequential compositionEvery handler in the router
ScheduleComposable retry policiesRetryPolicy.ts — exponential + jitter
RedactedPreventing accidental secret loggingAppConfig — API keys, client secrets

Addressing the Objections

This is true. Effect’s programming model — generators, typed error channels, layer composition — is unfamiliar to most TypeScript developers. The first week is uncomfortable. The second week is productive. By the third week, you cannot imagine going back to try/catch.The investment is frontloaded: once you understand Effect.gen, Layer, Ref, and TaggedError, you have 90% of what you need. The remaining features (Stream, Queue, Fiber, Schedule, Deferred) are used in specific modules and can be learned as needed.Diminuendo’s codebase is itself a teaching resource. Every pattern is used in a real context, not an abstract example. Read src/automation/AutomationEngine.ts to see Queue, Fiber, Ref, HashMap, Deferred, Duration, Either, and Effect.race working together in a single file.
Effect.gen(function* () { ... }) is more verbose than async/await. This is the most common initial reaction.What it buys you:
  • Every yield* is a suspension point where the type system tracks what can go wrong
  • Every service dependency is visible in the function signature, not hidden in module imports
  • Every resource cleanup is guaranteed by the runtime, not by developer discipline
After the first few weeks, Effect code reads like imperative code with explicit error handling — which is what all backend code should be, but rarely is.
Effect adds approximately 200KB to the bundled output. For a backend service (which is Diminuendo’s primary deployment), this is negligible — startup time is unaffected, and the runtime is in-process.For client-side use, tree-shaking removes unused Effect modules. The web client’s GatewayAdapter uses Effect types, and the impact is acceptable for a single-page application with a modern bundler.
The pool of developers fluent in Effect TS is smaller than the general TypeScript pool. This is a real constraint.However: Effect is TypeScript. Any TypeScript developer can read and modify Effect code with a one-week ramp-up. The patterns (typed errors, dependency injection, resource management) are universal. Developers who know Scala’s ZIO, Haskell’s IO monad, or Rust’s Result type will recognize the concepts immediately. And the alternative — hiring developers fluent in raw TypeScript who spend weeks debugging leaked resources, unhandled rejections, and hidden dependencies — is not cheaper.
Inversify, tsyringe, and similar DI libraries solve dependency injection but not the other three problems (typed errors, resource management, structured concurrency). Effect solves all four in a single, cohesive abstraction.If you adopt a DI framework for injection, you still need a separate error handling strategy, a separate resource management strategy, and a separate concurrency strategy. Effect provides all of these as one system where the features compose with each other.

Measurable Outcomes

Since adopting Effect TS for Diminuendo:
  • Zero uncaught exceptions in production. Every error is typed, handled, and mapped to a client-facing code before it leaves the message router. The catchAll at the bottom of the route function is the single error boundary.
  • Zero resource leaks. Structured concurrency guarantees that every fiber, queue, and database connection is cleaned up on shutdown. The force-exit timer (10 seconds) has never fired.
  • Zero hidden dependencies. Every service’s requirements are visible in its Layer type. Adding a new dependency to a service produces a compile-time error if the Layer graph does not provide it.
  • 935+ tests pass with no flaky failures. The typed error system means tests assert on specific error types (Unauthenticated, SessionNotFound) rather than string-matching error messages. No test has ever failed due to an unhandled rejection or a leaked timer.

Reference

The remainder of this page is a condensed reference for every Effect concept used in Diminuendo. Each entry gives the one-sentence explanation, a minimal code example, and a link to the official docs.

Core Types

Effect<Success, Error, Requirements>

The fundamental type. Represents a lazy computation that may succeed with Success, fail with Error, or require services described by Requirements. Nothing runs until you explicitly execute it.
const program: Effect.Effect<number, string> = Effect.succeed(42)
Official docs

Effect.gen

Generator-based syntax for writing sequential Effect code. yield* unwraps an Effect (like await for Promises, but type-safe).
const program = Effect.gen(function* () {
  const config = yield* AppConfig
  const db = yield* getDatabase(config)
  const users = yield* db.listUsers()
  return users.length
})
Official docs

Effect.pipe

Left-to-right function composition. Chains transformations on an Effect without nested calls.
const result = pipe(
  fetchUser(id),
  Effect.map(user => user.name),
  Effect.catchAll(() => Effect.succeed("anonymous")),
  Effect.tap(name => Effect.log(`Resolved: ${name}`))
)
Official docs

Creating Effects

FunctionWhat it doesWhen to use it
Effect.succeed(value)Wraps a value in a successful EffectConstants, already-computed values
Effect.fail(error)Creates a failed EffectDomain errors, validation failures
Effect.sync(() => ...)Wraps a synchronous functionSide effects that cannot throw
Effect.try(() => ...)Wraps a sync function that might throwJSON.parse, SQLite queries
Effect.tryPromise(() => ...)Wraps a Promise that might rejectfetch, file I/O
Effect.async(resume => ...)Bridges callback-based APIsEvent emitters, process signals
Effect.sleep(duration)Delay executionTimeouts, backoff, throttling
// Effect.try -- wraps sync code that can throw
const parseJson = (raw: string) =>
  Effect.try({
    try: () => JSON.parse(raw),
    catch: (e) => new ParseError({ message: String(e) })
  })

// Effect.tryPromise -- wraps async code that can reject
const fetchData = (url: string) =>
  Effect.tryPromise({
    try: () => fetch(url).then(r => r.json()),
    catch: (e) => new NetworkError({ message: String(e) })
  })

Running Effects

Effects are lazy — nothing happens until you run them. These are the escape hatches that bridge Effect world to the outside.
FunctionWhat it doesWhen to use it
Effect.runPromise(effect)Execute and return a PromiseApplication entry point
Effect.runSync(effect)Execute synchronouslyMigrations, CLI tools
// Typically used exactly once, at the entry point
const main = Effect.gen(function* () { /* ... */ })
Effect.runPromise(main).catch(console.error)
Avoid calling runPromise or runSync deep inside your code. The purpose of Effect is to compose computations and run them once at the boundary.

Error Handling

Data.TaggedError

Type-safe error classes with a _tag discriminator. Unlike thrown exceptions, these are tracked in the Effect type signature — the compiler forces you to handle them.
class SessionNotFound extends Data.TaggedError("SessionNotFound")<{
  readonly sessionId: string
}> {}

class DbError extends Data.TaggedError("DbError")<{
  readonly message: string
  readonly cause?: unknown
}> {}
Official docs

Catching Errors

FunctionWhat it does
Effect.catchAll(handler)Catch all errors, must return a new Effect
Effect.catchTag("Tag", handler)Catch a specific tagged error by name
Effect.matchEffect({ onSuccess, onFailure })Branch on success or failure
Effect.orElseSucceed(fallback)Recover from any error with a default value
Effect.catchAllDefect(handler)Catch unexpected panics (not typed errors)
const safe = pipe(
  fetchUser(id),
  Effect.catchTag("NotFound", () => Effect.succeed(defaultUser)),
  Effect.catchTag("DbError", (e) => Effect.fail(new ServiceUnavailable({ cause: e }))),
)

Dependency Injection

Context.Tag

A typed key that identifies a service interface. Think of it as a type-safe string key for a service registry.
class AuthService extends Context.Tag("AuthService")<AuthService, {
  readonly verify: (token: string) => Effect.Effect<Identity, Unauthenticated>
  readonly isAdmin: (userId: string) => Effect.Effect<boolean>
}>() {}

Layer

A recipe for building a service. Layers declare what they provide and what they require. Effect resolves the full dependency graph at composition time.
const AuthServiceLive = Layer.effect(
  AuthService,
  Effect.gen(function* () {
    const config = yield* AppConfig      // declare dependency
    return {
      verify: (token) => /* ... */,
      isAdmin: (userId) => /* ... */,
    }
  })
)

// Layer.sync -- build from a pure value (no dependencies)
const LoggerLive = Layer.sync(Logger, () => consoleLogger)

Composing Layers

FunctionWhat it does
Layer.provide(layer, dependency)Wire a dependency into a layer
Layer.merge(a, b)Combine two layers side by side
Layer.mergeAll(a, b, c, ...)Combine many layers
const AppLayer = Layer.mergeAll(
  AuthServiceLive,
  BillingServiceLive,
  TenantDbPoolLive,
  /* ... 15 more ... */
).pipe(Layer.provide(AppConfigLive))
Official docs

Concurrency Primitives

Ref<A>

A mutable reference that is safe for concurrent access. Every read and update is atomic.
const counter = yield* Ref.make(0)
const current = yield* Ref.get(counter)
yield* Ref.set(counter, 10)
yield* Ref.update(counter, (n) => n + 1)
const [old, _] = yield* Ref.modify(counter, (n) => [n, n + 1])
Official docs

Fiber

A lightweight virtual thread. Like a Promise, but supports cancellation, supervision, and composition.
const fiber = yield* Effect.fork(longRunningTask)
const result = yield* Fiber.join(fiber)

// forkDaemon -- runs independently, survives parent scope
yield* Effect.forkDaemon(backgroundPoller)

// Interrupt a running fiber
yield* Fiber.interrupt(fiber)
Official docs

Queue<A>

A bounded, async-safe work queue. Producers offer items, consumers take them. Backpressure is built in.
const q = yield* Queue.bounded<Task>(100)
yield* Queue.offer(q, myTask)          // enqueue (blocks if full)
const task = yield* Queue.take(q)      // dequeue (blocks if empty)
const maybe = yield* Queue.poll(q)     // non-blocking peek
Official docs

Deferred<A>

A one-shot, write-once value. One fiber produces a result, another fiber waits for it. Like a typed, cancellation-aware Promise.resolve().
const slot = yield* Deferred.make<TurnOutcome, TurnError>()

// Producer side (in another fiber):
yield* Deferred.succeed(slot, { status: "complete", tokens: 1500 })

// Consumer side (blocks until resolved):
const outcome = yield* Deferred.await(slot)
Official docs

HashMap

An immutable, structurally-shared map. Unlike JavaScript Map, updates return new instances — safe to use inside Ref.update().
const empty = HashMap.empty<string, Session>()
const updated = HashMap.set(empty, "sess-1", mySession)
const found = HashMap.get(updated, "sess-1")  // Option<Session>

Schema

Runtime type validation that generates both TypeScript types and JSON decoders from a single definition. Replaces Zod/Joi with compile-time and runtime safety.

Defining Schemas

const UserSchema = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  age: Schema.Number,
  role: Schema.optional(Schema.Literal("admin", "member")),
  tags: Schema.Array(Schema.String),
})

// TypeScript type is inferred automatically
type User = typeof UserSchema.Type

Validating Data

// Decode unknown input -- returns Either<ParseError, User>
const result = Schema.decodeUnknownEither(UserSchema)(jsonBody)

// Or decode as an Effect (fails with ParseError)
const user = yield* Schema.decodeUnknown(UserSchema)(jsonBody)

Discriminated Unions

The wire protocol uses discriminated unions for message types:
const ClientMessage = Schema.Union(
  Schema.Struct({ type: Schema.Literal("run_turn"), sessionId: Schema.String, text: Schema.String }),
  Schema.Struct({ type: Schema.Literal("stop_turn"), sessionId: Schema.String }),
  Schema.Struct({ type: Schema.Literal("list_sessions") }),
  // ... 21 total message types
)
Official docs

Config and Secrets

Config

Type-safe environment variable loading with defaults, validation, and secret handling.
const port = Config.number("PORT").pipe(Config.withDefault(8080))
const host = Config.string("HOST").pipe(Config.withDefault("0.0.0.0"))
const debug = Config.boolean("DEBUG").pipe(Config.withDefault(false))
const apiKey = Config.redacted("API_KEY")  // Redacted<string>
Official docs

Redacted<A>

A wrapper that prevents secrets from being accidentally logged or serialized.
const secret = Redacted.make("sk-live-abc123")
console.log(secret)                  // --> <redacted>
const value = Redacted.value(secret) // --> "sk-live-abc123" (explicit unwrap)

Scheduling and Retries

Composable retry and repetition policies. Combine strategies with pipe.
// Exponential backoff: 500ms --> 1s --> 2s, max 3 attempts, with jitter
const retryPolicy = Schedule.exponential("500 millis").pipe(
  Schedule.jittered,
  Schedule.compose(Schedule.recurs(3))
)

// Apply to any Effect
const resilient = Effect.retry(fetchFromPodium, retryPolicy)
Official docs

Logging

Built-in structured logging that flows through the Effect context. No global logger imports needed.
yield* Effect.logInfo("Server started", { port: 8080 })
yield* Effect.logWarning("Connection slow", { latencyMs: 450 })
yield* Effect.logError("Request failed", { error: e.message })
yield* Effect.logDebug("Cache hit", { key: "user:42" })
Log levels: Trace < Debug < Info < Warning < Error < Fatal Configure via Layer:
const LoggerLive = Logger.replace(Logger.defaultLogger, prettyLogger).pipe(
  Layer.merge(Logger.minimumLogLevel(LogLevel.Info))
)
Official docs

Control Flow

Iterating

yield* Effect.forEach(sessions, (s) => closeSession(s), { concurrency: 4 })
yield* Effect.forEach(ids, (id) => notify(id), { discard: true })

Combining

FunctionWhat it does
Effect.all([a, b, c])Run effects, collect all results
Effect.all([a, b, c], { concurrency: 3 })Same, but in parallel
Effect.race(a, b)First to complete wins
Effect.zip(a, b)Run sequentially, return tuple
Effect.zipRight(a, b)Run both, keep second result
Effect.zipLeft(a, b)Run both, keep first result

Transforming

FunctionWhat it does
Effect.map(effect, fn)Transform the success value
Effect.flatMap(effect, fn)Chain into another Effect
Effect.tap(effect, fn)Side-effect without changing the value
Effect.as(effect, value)Replace the success value

Quick Comparison

For developers coming from other ecosystems:
ConceptPromise / async-awaitEffect
Async computationPromise<T>Effect<T, E, R>
Error handlingtry/catchType-tracked error channel
DependenciesModule imports / DI containerContext.Tag + Layer
Mutable statelet x = ...Ref<T>
Work queueHand-rolled with arraysQueue<T>
Background tasksetTimeout / detached promiseFiber via Effect.fork
One-shot signalnew Promise(resolve => ...)Deferred<T>
Type validationZod / JoiSchema
Retry logicManual loopSchedule
Env configprocess.env.PORTConfig.number("PORT")
SecretsRaw stringsRedacted<string>

Feature Map

Every Effect feature used in Diminuendo maps to a specific operational benefit:
Effect FeatureWhere It Is UsedWhat It Prevents
Context.Tag + LayerEvery service definition (20+ services, 1 composition file)Import-coupled singletons, untestable modules
Data.TaggedErrorsrc/errors.ts (14 error types)Untyped exceptions, string-matching error handling
Effect.genEvery handler, every service methodCallback nesting, promise chain readability
Stream + QueuePodiumClient event stream, SessionRuntime event consumptionBackpressure-ignorant event handling, memory leaks
Effect.forkDaemonEvent stream fibers, scheduler fibers, worker fibersOrphaned background tasks, shutdown leaks
Ref + HashMapConnectionState (15 refs), AutomationEngine scheduler registry, Broadcaster topic trackingRace conditions in mutable state
DeferredTurn completion in SessionRuntime, automation run completionCallback-based completion signaling
QueueAutomation scheduler wake signal, work queueUnbounded task accumulation, missing backpressure
Fiber.interruptSession deletion, shutdown, automation cleanupDangling background work after resource destruction
ScheduleRetry policies for Podium and EnsembleAd-hoc retry loops, missing jitter, thundering herds
DurationAutomation timeouts, backoff computationString-based duration parsing, unit confusion
Config + RedactedAppConfig (16 config values, 5 redacted)Accidental secret logging, unvalidated config access
Effect.raceScheduler sleep-or-wake patternPolling loops, missed wake signals
EitherError handling in AutomationEngine execution pathsUnhandled rejection, missing error branches
CronAutomation schedule computationThird-party cron libraries, parse-time vs runtime errors
SchemaProtocol validation (21 client message types)Runtime type errors, unchecked JSON shapes

Getting Started with Effect

If you are convinced and want to adopt Effect in your own service:
1

Read the official documentation

effect.website has comprehensive guides. Start with the “Getting Started” and “Error Management” sections.
2

Study Diminuendo's patterns

Read src/config/AppConfig.ts for a minimal Layer example. Read src/errors.ts for TaggedError definitions. Read src/automation/AutomationEngine.ts for a complex example using most Effect features together.
3

Start with TaggedError + Effect.gen

The highest-value, lowest-effort starting point is replacing thrown exceptions with TaggedError classes and replacing async/await with Effect.gen. This gives you typed errors without adopting the full Layer system.
4

Add Layers incrementally

Once your service has typed errors, introduce Context.Tag for your core services and compose them with Layer in your entry point. You do not need to convert everything at once.