| name | effect-program-design |
|---|---|
| description | Design, write, and review Effect service modules (Context.Service + Layer) using the riptide-api "deep module" pattern. Use when creating or changing an Effect service, external adapter, tagged error, layer, or its @effect/vitest tests; or when reviewing Effect code for module depth, typed-error capture-then-narrow, observability (spans/logs/Sentry), Config/Redacted secrets, and real-seam layer tests. The Slack service (apps/riptide-api/src/effects/services/slack) is the canonical reference. |
Build deep Effect service modules: a small, domain-shaped public surface hiding substantial behavior,
with typed success and error channels, declared dependencies, capture-then-narrow error handling, spans,
and tests that swap real layers at real seams. The Slack service is the gold standard. workos, resend,
stripe, and s3 are shallow foils — do not copy them.
CANONICAL references on Effect v4 (effect-smol):
- https://effect-ts-effect-smol.mintlify.app/introduction
- ~/projects/effect-smol
- Deep modules. The interface is the cost, the implementation is the benefit. Hide a lot behind a small, simple shape. The caller never learns the module's internals.
- Everything stays in Effect. Typed success and error channels; dependencies declared in
R(Effect services), never passed as arguments. No plain functions that receive/inspect errors or carry errors as values. - Two-tier errors, capture-then-narrow. Classify raw failures into tagged errors → capture (log + Sentry)
before narrowing → map to a small, caller-actionable public union.
catchTag/catchTagsonly — neverinstanceof, nevererror._tag === '…'. - Observe everything. A span per public method, structured logs, Sentry for the actionable/unexpected —
all with safe fields, secrets
Redacted. - Testable by layer substitution. Every service is exercised through its public interface with deps
swapped as layers (
@effect/vitest+Effect.provide). No module mocks, no method spies.
See
REFERENCE.mdfor the annotated file skeleton, copy-paste stubs, and the anti-pattern catalog with in-repo line references.
Split by responsibility. Names like client/dispatch/persistence/oauth are Slack-specific, not required.
| File | Owns |
|---|---|
x.service.ts |
the Context.Service tag and shape; never an implementing layer |
x.layer.ts |
the live Layer implementation; always separate from the service tag/shape |
x.errors.ts |
all Data.TaggedError classes + the internal/public error unions |
x.types.ts |
domain types (and optionally the service shape — both fine) |
x.client.ts |
the external adapter: wraps the SDK/HTTP, emits typed errors, owns the SDK-error mapper |
x.persistence.ts |
DB operations (Drizzle), each mapping driver errors to a tagged error |
x.<concern>.ts |
further sub-effects (dispatch/oauth/…) by responsibility |
pure helpers (format.ts, classify.ts, …) |
functional core — no I/O, no deps |
Service tags/shapes and implementing layers always live in separate files, even for small services. Split additional implementation concerns when they span external-call, persistence, and orchestration.
- Small, domain-shaped surface. Few methods; each input/output a domain type; each method's error union
is small, caller-actionable, and distinct from (smaller than) the internal vocabulary. Slack: 8 internal
SlackProviderErrortags → publicSlackConnectionError(4);dispatch*advertisesnever. - The caller must not know the module's internals. A service resolves the data that is its own concern
rather than making the caller fetch and pass it.
listChannels({ organizationId })resolves the bot token internally; a shallowlistChannels({ botToken })leaks an internal. - But accept the domain inputs the caller legitimately holds, as named types. Not "pass a
taskIdand look it up" — if callers have theTask, take aTask. Don't destructure its fields into the signature. - IDs: raw
stringis fine, but always in a named input object ({ organizationId }) — never positional bare strings or same-typed positional args. Branding is optional/aspirational. - Decision rule: to produce this argument, would the caller have to know how the module works inside? Yes → the module resolves it. No, it's a value they already hold → accept it as its domain type.
- Deletion test: removing the module must spread complexity to callers, not erase it. Shallow tells: a 1:1 SDK/table mirror; an interface that makes callers supply internals.
Data.TaggedErrorfor every expected failure — stable tag, structured safe fields, optionalcause: unknown.- Two tiers. Internal: the rich vocabulary of everything that can break (transport + API-body + persistence). Public: a small union of caller-actionable outcomes per method.
- Model the decision, not the status.
SlackNeedsReauthError,retryable,…Unavailable,AlreadyRequested— notServerError/RateLimitErrorstatus buckets. Never make a caller string-match a message to recover a distinction (the workos'already invited'smell). - Classify in the channel. Transform the error channel with
catchTags/mapErroron typed errors — not a plain(cause: unknown) => Error. Prefer SDKs that already emit typed errors (@humanlayer/effect-slack). - Throwing SDKs (the one exception):
Effect.tryPromise({ try, catch })wherecatchdelegates to one per-adaptermapSdkErrorToEffectError(cause) => TaggedError. Centralized — no scattered cause-inspection, noinstanceof, no(x as any).statussprinkled around. - Banned:
instanceofin Effect code;error._tag === '…'equality; raw/unknownerrors leaked to callers; errors carried as values ({ ok: false, error: string }). Aggregate withEffect.result→Result<A, E>.
- Declare service deps in
R(yield* PostgresDb,yield* WorkosService). Never pass a service/layer as a function argument ((workos) => Layer.succeed(...)is wrong). - Effect-needs-effect → compose with the pipe pattern (
.pipe/yield*/flatMap), not by passing effects around. Passing an effect/capability is a rare, justified exception. - Effect-needs-layer - prefer declaring the service as a dependency of the effect, rathern than inling the effect into the service
- Services composed of effects should declare the effects outside the body of the surface where posisble and model dependencies
- E.g. an effect which depends on several layers, but which is part of the interface of some third layer - model the effect as its own effect which depends on the requisite services, then compose it into the service unless it requries access to the service's internals. This way the effect can be tested independently of the layer by providing test seams.
- Services should almost never call
.provideto provide an effect they use with a layer since effects should rely on the ambient service in the runtime, the exception to this is if the effect requires a custom HTTPClient or something which needs to be created and closed around correct fresh credentials at call time / runtime and cannot be composed at runtime-construction-time
- The functional core (parsers, formatting, decisions) is pure — no I/O, no logger, no ambient time/randomnes. The imperative shell (the layer + adapters) sequences effects, does I/O, classifies failures, observes.
- Spans (required). One
Effect.withSpan('service_name.operation', { attributes })per public method, plus child spans for sub-effects that do I/O or are expensive, none for pure helpers. Add safe context withEffect.annotateCurrentSpan({...}). Naming:snake_casedomain.operation(slack.list_channels). Spans live inside the service, not only at the orpc handler. - Logs.
Effect.logError(+logDebug/logInfofor notable events) withEffect.annotateLogs({...}). Reuse the same attribute object for logs and spans;Effect.withLogSpan(...)for log spans - Sentry — actionable or unexpected only.
Sentry.captureException(error, { tags: { error_type }, extra })for integration/transport breakage, defects (viacatchCause), and anything degrading a capability a human should see. Not pure control-flow / input validation (SlackNotConnectedError,…ValidationError). - Capture before you narrow or swallow.
tapError(log)+tapError(Sentry)on the raw error beforecatchTags. Best-effort work captures, then swallows — never swallows silently. Top-level nets usecatchCause(catches defects too), not justcatchTag. - Safe fields only. Domain IDs, operation, provider, tags,
has_access_token: Boolean(...). Secrets areRedactedand never logged/spanned.
- Load all env vars and secrets via Effect
Config(Config.string/Config.redacted/Config.url,Config.withDefault(...)) inside the layer'sEffect.gen. Noprocess.envin service logic. - Secrets are
Redactedend-to-end;Redacted.value(...)only at the adapter edge making the call. Config-based loading is what makes the service testable without env (tests injectConfigProvider).
- Parse untrusted boundaries into domain types at the adapter edge (HTTP/SDK/JSON/webhooks/user input).
Slack maps raw
conversations.listobjects →SlackChannel. Parsing of inbound request bodies happens at the oRPC/webhook entrypoint; the service receives already-parsed domain inputs. Use Effect Schema with schema-driven data modelling: https://effect-ts-effect-smol.mintlify.app/core-modules/schema and https://www.effect.solutions/data-modeling - DB rows: scalar trust, jsonb parse. Trust Drizzle
$inferSelecttypes for straightforward scalar columns (Postgres enforces them). Parsejsonb— Postgres does not enforce jsonb shape — with the existing Zod schemas (@codelayer/db/zodschemas/*,drizzle-zodselect schemas). No Effect Schema bridging. - Never return a raw
$inferSelectrow across the public interface — project to a domain type (Slack returnsSlackConnectionStatus, not the integration row). parseX/makeX/isXnaming (avoidvalidateX); no genericisRecord/isObjectguards; noas Ton decoded JSON or rows.
- Bounded concurrency for unbounded/fan-out work (
Effect.forEach(xs, f, { concurrency })); start independent work together rather than awaiting in a loop. - Leverage fibers where appropriate, with scopes, acquireRelease etc for managing resources - particularly resources which must be cleaned up: https://effect-ts-effect-smol.mintlify.app/core-concepts/resources-and-scopes
- Idempotency on retried creates (idempotency keys;
onConflictDoNothingclaims). Atomic transition guards for lifecycle writes (the Slack thread-claim viaacquireUseRelease). Do not hold a DB transaction open across a network call. - Best-effort /
Effect<void, never>is the right shape for fan-out/notification side-effects where one failure must not fail the caller: capture (log + Sentry) then swallow; surface a typed error only when the caller can act on it. Defects should always be caught, logged, and captured. - No floating/unsupervised effects.
- Always
@effect/vitest.import { describe, it } from '@effect/vitest'; keepexpect/beforeAll/afterAllfromvitest. The SlackManagedRuntime+ plain-vitest tests are legacy — write new tests withit.effect. it.effect('…', () => Effect.gen(function*(){ … }).pipe(Effect.provide(layer))). NoManagedRuntime, no manualrunPromise/dispose.- Build the layer behind a
makeLayer(opts)factory:Layer.provideMerge(ServiceLive, Layer.mergeAll(PostgresDbLive(db), …fakes, ConfigProvider…)). - Substitute each dep by category: real ephemeral DB (
PostgresDbLive(db)+createTestDb) for persistence behavior; a hand-fakePostgresDblayer for pure-logic-over-DB;Layer.succeed(Service, {...})recording-store fakes for true externals (unused methodsEffect.die('… not used')); a fakeHttpClientlayer or loopback server for transport;ConfigProvider.fromUnknown({...})for config. - Test the implementing layer directly. Keep its external dependencies in
R, then provide fake transports or other test layers withLayer.provide; do not create a second…Baseproduction layer solely for tests. - Assert on both the returned value / narrowed error and real side-effect end-state (DB rows via
Effect.promise(() => db.select()…), recording-store contents). - Banned:
vi.mock,vi.spyOn, module patching, method spies. If a dep can't be swapped via a layer, the module is wrong (hidden/ambient/arg-passed) — fix the module, not the test.
No any, no !, no unjustified as (escape hatches are local, behind precise interfaces, with a SAFETY:
comment + lint-disable reason). readonly by default. ?? not || for "absent" defaults; no filter(Boolean).
import type for type-only imports; no barrels; JSDoc on exports. Guard clauses (no else after return).
Map/Set for dynamic keyed collections. Precise file names — no utils.ts/helpers.ts dumping grounds.
parse, don't validate. Avoid x?.y accessors and typeof operations.
- Public surface small, domain-shaped; per-method error union narrow + distinct from internal.
- Errors are
Data.TaggedError, model caller actions; classified in-channel; one SDK-error mapper. - Capture (log + Sentry) happens on the raw error before narrowing/swallowing;
catchCauseat top nets. -
Effect.withSpanon every public method; safe annotations; secretsRedacted. - All deps in
R; no service/layer/effect passed as an argument; no errors-as-values. - Config via
Config.*; secretsRedacted, unwrapped only at the edge. - Tests use
@effect/vitestit.effect+Effect.provide(layer); real seams; novi.mock/vi.spyOn; assert value and persisted end-state. - No
instanceof, noerror._tag === '…', no plain(cause) => Errorclassifiers.