Skip to main content
The codebase uses Effect 4.x idioms consistently across every feature. This page catalogs each pattern with real examples from the source.

ServiceMap.Service — defining services

Every injectable service is a class that extends ServiceMap.Service. The class body holds the make factory (an Effect.gen block), and a static layer property that wraps it in a Layer.
// packages/discord-bot/src/Ai.ts
export class AiHelpers extends ServiceMap.Service<AiHelpers>()(
  "app/AiHelpers",
  {
    make: Effect.gen(function* () {
      const rest = yield* DiscordREST
      const model = yield* ChatModel
      const application = yield* DiscordApplication
      // ...
      return { generateTitle, generateDocs, generateSummary, generateAiInput } as const
    }),
  },
) {
  static readonly layer = Layer.effect(this, this.make).pipe(
    Layer.provide(OpenAiLive),
  )
}
Other services that follow the same pattern: Github, EffectRepo, Summarizer, ChannelsCache, MemberCache, Messages, DiscordApplication.

Layer — dependency injection and composition

Effect Layers are the dependency-injection mechanism. The bot uses several Layer combinators. Layer.effectDiscard — for features that register gateway handlers or slash commands and return nothing:
// packages/discord-bot/src/AutoThreads.ts
export const AutoThreadsLive = Layer.effectDiscard(make).pipe(
  Layer.provide(ChannelsCache.layer),
  Layer.provide(AiHelpers.layer),
  Layer.provide(DiscordGatewayLayer),
)
Layer.effect — for services whose value is consumed by other layers:
// packages/discord-bot/src/Summarizer.ts
static readonly layer = Layer.effect(this, this.make).pipe(
  Layer.provide(ChannelsCache.layer),
  Layer.provide(MemberCache.layer),
  Layer.provide(Messages.layer),
  Layer.provide(DiscordGatewayLayer),
)
Layer.provide — wires a dependency into a layer:
// packages/discord/src/DiscordGateway.ts
const DiscordLayer = DiscordIxLive.pipe(
  Layer.provideMerge(NodeHttpClient.layerUndici),
  Layer.provide(NodeSocket.layerWebSocketConstructor),
  Layer.provide(DiscordConfigLayer),
)
Layer.mergeAll — combines multiple independent feature layers in main.ts:
const MainLive = Layer.mergeAll(
  AiResponse,
  AutoThreadsLive,
  DocsLookupLive,
  Summarizer.layer,
  // ...
).pipe(Layer.provide(TracerLayer("discord-bot")), Layer.provide(LogLevelLive))

Effect.gen — generator-style composition

Effect.gen lets you write sequential Effect logic using yield* for a readable, imperative style. Every make factory and most handlers use it.
// packages/discord-bot/src/Summarizer.ts
make: Effect.gen(function* () {
  const rest = yield* DiscordREST
  const channels = yield* ChannelsCache
  const registry = yield* InteractionsRegistry
  const members = yield* MemberCache
  const messagesService = yield* Messages
  // ...
  yield* registry.register(ix)

  return { thread: summarizeThread, messages: summarizeWithMessages, message: summarizeMessage } as const
}),

Effect.fn / Effect.fnUntraced — named traced functions

Effect.fn wraps a generator function and attaches an OpenTelemetry span automatically. Effect.fnUntraced skips the span for hot-path helpers.
// packages/discord-bot/src/Ai.ts
const getOpeningMessage = Effect.fn("AiHelpers.getOpeningMessage")(
  function* (thread: Discord.GuildChannelResponse | Discord.ThreadResponse) {
    if (thread.parent_id == null) {
      return yield* rest.getMessage(thread.id, thread.id)
    }
    return yield* rest
      .getMessage(thread.parent_id, thread.id)
      .pipe(Effect.catch(() => rest.getMessage(thread.id, thread.id)))
  },
)
// packages/discord-bot/src/Summarizer.ts
const summarizeThread = Effect.fn("Summarizer.summarizeThread")(
  function* (channel: Discord.ThreadResponse, small: boolean = true) {
    const parentChannel = yield* channels.get(channel.guild_id!, channel.parent_id!)
    const threadMessages = yield* Stream.runCollect(
      messagesService.cleanForChannel(channel),
    ).pipe(Effect.map((items) => [...items].toReversed()))
    return yield* summarize(parentChannel, channel, threadMessages, small)
  },
)
Effect.fnUntraced is used for gateway event handlers where the outer span comes from Effect.withSpan:
// packages/discord-bot/src/AutoThreads.ts
const handleMessages = gateway.handleDispatch(
  "MESSAGE_CREATE",
  Effect.fnUntraced(
    function* (event) { /* ... */ },
    (effect, event) =>
      Effect.withSpan(effect, "AutoThreads.handleMessages", {
        attributes: { messageId: event.id },
      }),
    Effect.catchCause(Effect.logError),
  ),
)

Config / ConfigProvider — environment variables

All configuration is read through Effect’s Config module, keeping secrets out of code.
// packages/discord/src/DiscordConfig.ts
export const DiscordConfigLayer = DiscordConfig.layerConfig({
  token: Config.redacted("DISCORD_BOT_TOKEN"),
  gateway: {
    intents: Config.succeed(
      Intents.fromList(["GuildMessages", "MessageContent", "Guilds"]),
    ),
  },
})
// packages/discord-bot/src/Ai.ts
export const OpenAiLive = OpenAiClient.layerConfig({
  apiKey: Config.redacted("OPENAI_API_KEY"),
  // ...
})
ConfigProvider.nested + ConfigProvider.constantCase — AutoThreads scopes its config under a namespace so all variables are prefixed AUTOTHREADS_:
// packages/discord-bot/src/AutoThreads.ts
Effect.provideService(
  ConfigProvider.ConfigProvider,
  ConfigProvider.fromEnv().pipe(
    ConfigProvider.nested("autothreads"),
    ConfigProvider.constantCase,
  ),
)
// reads AUTOTHREADS_KEYWORD from the environment

Data.TaggedError — typed error classes

Data.TaggedError creates nominal error types with a _tag discriminant, making error handling exhaustive and precise.
// packages/discord-bot/src/AutoThreads.ts
export class NotValidMessageError extends Data.TaggedError(
  "NotValidMessageError",
)<{
  readonly reason: "non-default" | "from-bot" | "non-text-channel" | "disabled"
}> {}

export class PermissionsError extends Data.TaggedError("PermissionsError")<{
  readonly action: string
  readonly subject: string
}> {}
// packages/discord-bot/src/Summarizer.ts
export class NotInThreadError extends Data.TaggedError("NotInThreadError")<{}> {}
Tagged errors are caught by tag in Ix.builder:
Ix.builder
  .add(command)
  .catchTagRespond("NotInThreadError", () =>
    Effect.succeed(
      Ix.response({
        type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE,
        data: { content: "This command can only be used in a thread", flags: Discord.MessageFlags.Ephemeral },
      }),
    ),
  )
  .catchAllCause(Effect.logError)

Effect.withSpan — OpenTelemetry tracing

Effect.withSpan manually annotates an effect with a span name and optional attributes. It composes with Effect.fn (which adds spans automatically) for fine-grained traces.
// packages/discord-bot/src/Summarizer.ts
const summarizeWithMessages = (
  channel: Discord.ThreadResponse,
  messages: Array<Discord.MessageResponse>,
  small = true,
) =>
  pipe(
    channels.get(channel.guild_id!, channel.parent_id!),
    Effect.flatMap((parentChannel) =>
      summarize(parentChannel, channel, messages, small),
    ),
    Effect.withSpan("Summarizer.summarizeWithMessages"),
  )
// packages/discord-bot/src/AiResponse.ts
Effect.withSpan(effect, "AiResponse.generate (inner)"),
Span attributes are annotated on the current span with Effect.annotateCurrentSpan:
yield* Effect.annotateCurrentSpan({ channelId: channel.id, small })

FiberMap — managing concurrent fibers by key

FiberMap tracks running fibers by a key, preventing duplicate work and allowing cancellation by key.
// packages/discord-bot/src/AiResponse.ts
const fiberMap = yield* FiberMap.make<Discord.Snowflake>()

// Start (or replace) the AI generation fiber for this interaction
yield* FiberMap.run(fiberMap, context.id, generate(context, history, reasoning))

// Cancel the fiber when the user clicks "Cancel"
yield* FiberMap.remove(fiberMap, interactionId)

Schedule — retry and recurrence

Schedule drives retries and periodic tasks.
// packages/discord-bot/src/Ai.ts — exponential backoff on transient HTTP errors
export const OpenAiLive = OpenAiClient.layerConfig({
  apiKey: Config.redacted("OPENAI_API_KEY"),
  transformClient: HttpClient.retryTransient({
    times: 3,
    schedule: Schedule.exponential(500),
  }),
})
// packages/discord-bot/src/EffectRepo.ts — pull the git repo every 15 minutes
yield* Effect.gen(function* () {
  while (true) {
    yield* Effect.sleep("15 minutes")
    yield* git.pull(repoPath)
    yield* RcRef.invalidate(llmsMd)
  }
}).pipe(Effect.retry(Schedule.forever), Effect.forkScoped)

Build docs developers (and LLMs) love