Architecture
Effect v4 RPC as the single API boundary, Drizzle + Supabase for data, Better Auth for sessions, and how a request flows end to end.
The system is built around a small number of load-bearing decisions: Effect v4 for services and error handling, one RPC boundary between client and server, Drizzle + Supabase for data with row-level security, and Better Auth for sessions.
The API is Effect v4 RPC (not tRPC)
The API surface is Effect v4 RPC. Schemas and handlers live in
packages/api/src/rpc/ (*-schema.ts for the request/response schemas,
handlers/ for the implementations). The web app consumes them through the
Effect RPC client exported by @repo/api — there is no trpc.ts.
- Every payload is a
Schema. A request is decoded on the way in and a response is encoded on the way out, so the wire is validated at both ends and the handler only ever sees decoded, typed values. - Errors are typed. Handlers fail with
Schema.TaggedErrorvalues that the client can discriminate — no thrownErrors escaping anEffect.
Services and layers
Shared behaviour is packaged as Effect services. The house shape:
export class MyService extends Context.Service<MyService>()("@scope/MyService", {
make: Effect.gen(function* () {
/* deps + impl */
}),
}) {
static readonly layer = Layer.effect(this, this.make);
}Consumers compose with MyService.layer.pipe(Layer.provide(<deps>)). Layer
order matters: a service yielded inside a layer's make must be provided
outside that layer. Layer.mergeAll only merges siblings whose deps are
already satisfied — it does not cross-wire them.
The canonical Effect v4 reference is the vendored tools/effect-smol git subtree (pinned to the
same beta as the catalog) plus its MIGRATION.md — trust it over public docs and blog posts,
which describe v3.
Data — Drizzle + Supabase, with RLS
- Schema lives in
packages/db/src/schema/(Drizzle). RLS policies live inpackages/db/src/rls.ts. - The DB connects to Supabase Postgres through the Supavisor pooler. The
Effect DB layer (
packages/db/src/effect/layer.ts) reads its connection string viaConfig/Config.redacted— service code never touchesprocess.envdirectly. - Migrations are generated with
pnpm db:generateand applied withpnpm db:migrate.pnpm db:pushpushes schema directly (dev only) — never push to reconcile a prod migration state, because push drops RLS policies and Supabase system views that the Drizzle schema doesn't model.
Auth — Better Auth
- The server auth instance (trusted origins, providers) is in
packages/auth/src/auth-instance.ts; the Effect-wrapped server inpackages/auth/src/server.ts. The web client isapps/web/src/lib/auth-client.ts. - Never call
auth.api.getSession(...)directly. It can throw when the Supavisor pooler drops a connection. All session resolution goes through the blessed wrapper inpackages/auth/src/request.ts(getSessionFromHeaders/getSessionFromRequest), which retries on dropped pooler connections. A guard script (check:no-raw-getsession) enforces this in CI.
Request flow (web)
React component
→ @repo/api RPC client (typed call, Schema-encoded request)
→ Next.js route / handler
→ auth: getSessionFromHeaders (retry-safe session)
→ RPC handler (packages/api/src/rpc/handlers)
→ Effect service (Context.Service + layer)
→ @repo/db repo (Drizzle) → Supabase Postgres (RLS)
← typed success | Schema.TaggedError
← Schema-encoded responseDeterminism
Wall-clock and RNG are side effects and are read through Effect's default
services, never directly (enforced by effect/no-date + effect/no-math-random):
- Time —
yield* Clock.currentTimeMillis/DateTime.now. - Randomness —
yield* Random.next/Random.nextIntBetween(a, b); outside Effect use@repo/utilssecureId()/randomInt()(crypto-backed). NeverMath.random(). - Tests drive time with
TestClockand seed RNG withRandom.withSeed.
See the packages reference for the libraries named here, and local development to run it.