The Effect RPC API layer
How the Effect v4 RPC surface replaced tRPC — schema groups, Effect handlers, the router layer, the @repo/api client, and how to add a new RPC.
This repo used to speak tRPC. It now speaks Effect v4 RPC. The shape is
familiar — typed procedures the client calls like local functions — but the
whole thing is built out of Effect's Schema, RpcGroup, Layer, and
middleware. That's the point of this page: to show you that the RPC layer is
not a framework you memorise, it's just Effect primitives you already know,
composed in one predictable way.
The surface lives in packages/api/src/rpc/. Three kinds of file matter:
*-schema.ts— the contract: request/response/error Schemas.handlers/*.ts— the implementation: each procedure as anEffect.schema.ts+router.ts— the wiring: contracts and handlers meet, and the whole thing becomes aLayerthe Next.js route can run.
The web app never imports a handler. It imports the contract (ApiRpc) and a
small client runtime, and gets a fully-typed client for free. Let's walk the
whole loop.
1. The contract: *-schema.ts
Every domain owns a schema file whose only job is to define the shapes that
cross the wire. Here's the audio domain — packages/api/src/rpc/audio-schema.ts:
import { Schema } from "effect";
const Id = Schema.Number.check(Schema.isInt());
/** The persisted audio row, as returned by reads + mutations. */
export const Audio = Schema.Struct({
id: Id,
userId: Schema.NonEmptyString,
filename: Schema.String,
path: Schema.String,
originalName: Schema.String,
mimeType: Schema.String,
size: Id,
/** Length in seconds; null until probed. */
duration: Schema.NullOr(Id),
createdAt: Schema.Date,
updatedAt: Schema.Date,
});
export type Audio = typeof Audio.Type;Two things to internalise here:
Audiois a runtime value first, a type second.Schema.Structbuilds an actual object that can decode an unknown payload and encode a value back to the wire.type Audio = typeof Audio.Typederives the static type from that runtime schema, so the two can never drift. You never hand-write a paralleltype Audio = { id: number; ... }— that's the rule the file header calls out: "Derive types viatype X = typeof X.Typeso the runtime schema stays authoritative."- Inputs are separate, narrower schemas. The same file defines
CreateAudioInput(what a caller may supply) andAudioIdInput({ id }).userIdis deliberately absent fromCreateAudioInput— it comes from the authenticated session on the server, never from the client. Encoding trust boundaries into the schema is how you make "the client can't set its own userId" a compile-time and runtime fact instead of a code-review hope.
This schema file is shared by both the DB repo (@repo/db's AudioRepo) and the RPC surface.
One Schema.Struct is the single source of truth for the table row, the RPC response, and the
derived TypeScript type. That's why the file lives in @repo/api and is imported, not duplicated.
Errors are Schemas too
The wire needs to carry failures as well as successes, and in Effect a typed
error channel is first-class. packages/api/src/rpc/errors.ts defines the whole
error vocabulary as tagged Schemas:
import { Schema } from "effect";
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {
message: Schema.String,
}) {}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
resource: Schema.String,
id: Schema.Union([Schema.String, Schema.Number]),
}) {}
// ...Forbidden, Internal...
export const RpcError = Schema.Union([Unauthorized, Forbidden, NotFound, Internal]);
export type RpcError = Schema.Schema.Type<typeof RpcError>;Schema.TaggedErrorClass gives each error a _tag ("NotFound", …) and makes
it serialisable, so the client receives a real typed union — not a stringified
message. RpcError is the Schema.Union of the four, and almost every procedure
declares error: RpcError. The four members map cleanly to intent:
Unauthorized (no/invalid session), Forbidden (logged in, wrong role),
NotFound (resource + id), and Internal (an infra/DB fault, redacted before it
reaches the client).
2. The procedures: RpcGroup in schema.ts
packages/api/src/rpc/schema.ts is where individual procedures are declared and
grouped. It imports the domain schemas and describes each call with
Rpc.make(name, { payload, success, error }):
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Audio, AudioIdInput, CreateAudioInput } from "./audio-schema";
import { RpcError } from "./errors";
const AudioRpc = RpcGroup.make(
Rpc.make("list", { success: Schema.Array(Audio), error: RpcError }),
Rpc.make("get", { payload: AudioIdInput, success: Audio, error: RpcError }),
Rpc.make("create", { payload: CreateAudioInput, success: Audio, error: RpcError }),
Rpc.make("delete", { payload: AudioIdInput, success: Audio, error: RpcError }),
Rpc.make("transcribe", { payload: TranscribeAudioInput, success: Transcript, error: RpcError })
).prefix("audio/");- Each
Rpc.makeis one procedure.payloadis the request Schema (omit it for no-arg calls likelist),successis the response Schema,errorthe failure Schema. Because these are Schemas, the RPC machinery decodes the payload on the server and the result on the client automatically — there is no separate validation step you can forget. .prefix("audio/")namespaces every member, so the procedures becomeaudio/list,audio/get,audio/create, … Those prefixed strings are the keys you'll see everywhere: they're the handler keys, the client method names, and the telemetry span names, all the same string.
A group can also widen its error channel per-procedure. The video group's
generate adds a render-specific failure on top of the shared union:
Rpc.make("generate", {
payload: GenerateVideoInput,
success: GeneratedVideo,
// The render seam adds a `RenderError` member on top of the shared
// `RpcError` union (auth + db failures still flow through here too).
error: Schema.Union([...RpcError.members, RenderError]),
});RpcError.members is the array of the union's constituents — spreading it keeps
all four shared errors and adds RenderError, so the client's typed error union
for video/generate is exactly those five.
Groups compose into one ApiRpc, gated by middleware
At the bottom of schema.ts, every group is merged into a single ApiRpc, and
this is where authentication is attached — declaratively, per group:
export const ApiRpc = PublicRpc.merge(
PrivateRpc,
TodoRpc.middleware(SessionMiddleware),
TravelRpc.middleware(SessionMiddleware),
DealsRpc.middleware(SessionMiddleware),
DealsServiceRpc.middleware(ServiceTokenMiddleware),
AudioRpc.middleware(SessionMiddleware)
// ...plan, telemetry, analytics, sentry, video, i18n...
);.middleware(SessionMiddleware)says "every procedure in this group requires a resolved session." The middleware provides aSessionservice to the handler and can fail the whole call withUnauthorizedbefore the handler ever runs.PublicRpc(justhealth) carries no middleware — it's callable without a session.DealsServiceRpcusesServiceTokenMiddlewareinstead: it's the n8n-facingdeals/upsertOffers, gated by a shared secret header rather than a user session.
ApiRpc is the only thing the client imports from this file. It is the complete contract —
every procedure name, payload, result, and error — so the client type is derived from it with zero
duplication. Server and client literally share one object.
3. The implementation: handlers/
A handler file exports a function that takes its dependencies (repos, services)
and returns the slice of procedures it implements. Here's the audio slice —
packages/api/src/rpc/handlers/audio.ts:
export const audioHandlers = (audioRepo: AudioRepoShape): AudioSlice => ({
"audio/list": () =>
adminHandler("audio/list")(
Effect.gen(function* () {
const session = yield* Session;
return yield* audioRepo.listForUser(session.user.id);
})
),
"audio/get": (input) =>
adminHandler("audio/get")(
Effect.gen(function* () {
const session = yield* Session;
const row = yield* audioRepo.getById(input.id, session.user.id);
if (row === undefined) {
return yield* new NotFound({ resource: "audio", id: input.id });
}
return row;
})
),
// ...create, delete...
});Read one handler carefully — the pattern repeats everywhere:
- The key is the prefixed procedure name (
"audio/get"), matching theRpc.makeinschema.ts. TypeScript checks the payload and return types against the contract, so a handler that returns the wrong shape won't compile. const session = yield* Sessionpulls the authenticated session out of context. It's there becauseSessionMiddlewareprovided it (step 2). Notice the handler never touches headers or cookies — auth already happened.- A missing row is a typed failure, not a thrown error.
return yield* new NotFound({ resource: "audio", id: input.id })fails the effect with aNotFound— one of theRpcErrormembers — which the client receives as a typed error. You neverthrow; you fail the channel. adminHandler("audio/get")(...)wraps the body. That wrapper is the house style for a whole class of concerns, and it's worth understanding because you'll apply it to every new handler.
What adminHandler actually does
From packages/api/src/rpc/handlers/shared.ts:
export const adminHandler =
(method: string) =>
<A, E, R>(body: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
yield* requireAdmin;
return yield* body.pipe(failureToInternal(method));
}).pipe(withRpcSpan(method));It composes three cross-cutting concerns so each handler body stays "just the
Session read + repo call + a NotFound guard":
requireAdminreads theSessionand failsForbiddenunless the user's role is admin. Because it sits outsidefailureToInternal, a denial stays a 403Forbidden, never a 500.failureToInternal(method)maps any untyped repo failure (a raw Drizzle error) to the redactedInternalerror — but lets an already-typedNotFound/Internal/Forbiddenpass straight through untouched, so your explicitNotFoundwins. This is why raw Postgres text (table/column names) never leaks to the client.withRpcSpan(method)wraps the whole thing in an OpenTelemetryrpc.<method>span plus request/error/latency metrics, and annotates the span with the end-user id. Themethodstring is passed once so span name, metric tag, and error-mapping label can't drift apart.
There's a non-admin sibling, rpcHandler(method), that does the same error-mapping and telemetry
without the requireAdmin gate — that's what the open handlers (todos, travel reads,
getPrivateData) use. Pick adminHandler for admin-only routes, rpcHandler for everything a
signed-in user may call.
4. The wiring: router.ts turns handlers into a Layer
schema.ts gives you the contract; the handlers are plain functions. router.ts
marries them and produces the runnable Layer. The key call is ApiRpc.toLayer:
export const ApiRpcHandlers = ApiRpc.toLayer(
Effect.gen(function* () {
const todoRepo = yield* TodoRepo;
const audioRepo = yield* AudioRepo;
const videoRepo = yield* VideoRepo;
// ...every other repo/service...
return ApiRpc.of({
health: () => Effect.succeed("ok"),
...todoHandlers(todoRepo, travelRepo),
...audioHandlers(audioRepo),
...videoHandlers(videoRepo, videoRenderer),
// ...all the other slices...
});
})
).pipe(
Layer.provide(PostHogQuery.layer),
Layer.provide(SentryQuery.layer),
Layer.provide(GitHubContents.layer)
);ApiRpc.toLayer(...)takes an effect that produces the full handler object (ApiRpc.of({...})) and yields aLayerimplementing every procedure in the contract. If you add a procedure toschema.tsbut forget its handler,ApiRpc.ofwon't type-check — the contract forces completeness.- Dependencies are yielded once, at the top. Each repo (
yield* AudioRepo) is resolved from context, then closed over by its handler slice (audioHandlers(audioRepo)). The handlers themselves never reach into Effect context for repos — they receive them as plain arguments, which keeps them trivially unit-testable with a fake repo. - Spreading the slices (
...audioHandlers(audioRepo)) merges each domain's{ "audio/list": ..., "audio/get": ... }into one flat handler map keyed by the prefixed names.
Below that, ApiRpcLive provides the real implementations of those
dependencies:
export const ApiRpcLive = ApiRpcHandlers.pipe(
Layer.provide(TodoRepo.layer),
Layer.provide(AudioRepo.layer),
Layer.provide(VideoRepo.layer),
// Default render seam: a "not configured" gate; the app overrides it.
Layer.provide(VideoRenderer.layer)
);This is standard Effect layer composition: ApiRpcHandlers requires AudioRepo
et al.; ApiRpcLive provides them. A test can instead provide fake repo layers
and exercise the exact same handlers.
5. How the middleware resolves a session
The SessionMiddleware referenced in schema.ts is defined in
packages/api/src/rpc/middleware.ts. Its job is the boundary decode of the
better-auth payload into the Session service:
export const resolveSession = (auth, headers) =>
Effect.gen(function* () {
const raw = yield* Effect.tryPromise({
try: () => auth.getSession({ headers: webHeaders }),
catch: (cause) => cause,
}).pipe(
retryOnStaleConnection,
Effect.mapError(() => new Unauthorized({ message: "auth lookup failed" }))
);
if (!raw) return yield* new Unauthorized({ message: "no session" });
// Boundary rule: validate the better-auth payload before it becomes the
// Session service; unknown fields are stripped by the decode.
const session = yield* Schema.decodeUnknownEffect(SessionSchema)(raw).pipe(
Effect.mapError(() => new Unauthorized({ message: "malformed session" }))
);
return session;
}).pipe(Effect.withSpan("rpc.auth", { kind: "server" }));- The raw better-auth result is
unknownuntilSchema.decodeUnknownEffectruns — that decode is the trust boundary.SessionUser(insession.ts) is an allowlist: it declares exactly which fields cross the wire (id,email,name,role, …), and decoding strips everything else — plugin fields, tokens, timestamps. Extending it is the single place to add a field the client legitimately needs. retryOnStaleConnectionre-attempts only connection-class faults (the Supavisor pooler dropping a socket), so a transient pool blip doesn't collapse to a spuriousUnauthorized.
This decode is why you must never call auth.api.getSession(...) raw elsewhere. Session
resolution is centralised here (and in @repo/auth's getSessionFromHeaders) precisely so the
retry-on-dropped-connection and the allowlist decode always run. A guard script
(check:no-raw-getsession) fails CI on any raw call site.
6. The server seam: one Next.js route
All of the above becomes an HTTP endpoint in exactly one place —
apps/web/src/app/api/rpc/route.ts. There is no per-procedure route; the whole
ApiRpc is served from a single POST:
const ServerLayer = Layer.provideMerge(
ApiRpcLive,
Layer.mergeAll(
SessionMiddlewareLive(auth.api),
ServiceTokenMiddlewareLive,
RpcSerialization.layerJson,
Metric.enableRuntimeMetricsLayer,
TranslationCacheUpstashLive
)
);
export async function POST(request: Request) {
const program = Effect.gen(function* () {
const httpEffect = yield* RpcServer.toHttpEffect(ApiRpc).pipe(Effect.provide(ServerLayer));
const response = yield* httpEffect;
return HttpServerResponse.toWeb(response);
}).pipe(
Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)),
Effect.scoped
// ...envelope-level catchCause → 500...
);
return runDbLive(program);
}RpcServer.toHttpEffect(ApiRpc)builds the server handler from the same contract the client uses.ServerLayersupplies its dependencies: the live handlers (ApiRpcLive), the two middleware implementations (with the realauth.apiwired intoSessionMiddlewareLive), and JSON serialisation.runDbLive(program)runs the whole thing against the shared DB runtime. Because that runtime is read fresh each call, a pool rebuilt after a stale-connection fault takes effect on the next request — the page self-heals on reload.
7. The client: @repo/api + RpcClient.make(ApiRpc)
The web app builds a client runtime from the same ApiRpc. The runtime factory
lives in packages/api/src/client/ — makeRpcAtomRuntime wires an HTTP protocol
layer with JSON serialisation:
export const makeRpcAtomRuntime = (options: MakeRpcRuntimeOptions) =>
runtime(buildRpcLayer(options));
const buildRpcLayer = (options) =>
RpcClient.layerProtocolHttp({ url: options.url }).pipe(
Layer.provide(httpClientLayer),
Layer.provide(RpcSerialization.layerJson),
Layer.provide(requestInitLayer)
);Then apps/web/src/utils/rpc.ts builds the actual runtime and derives atoms from
it. This is the whole client-side story:
const rpcAtomRuntime = makeRpcAtomRuntime({
url: "/api/rpc",
requestInit: { credentials: "include" },
});
export const todosAtom = rpcAtomRuntime.factory.withReactivity(["todo/getAll"])(
rpcAtomRuntime.atom(
Effect.gen(function* () {
const client = yield* RpcClient.make(ApiRpc);
return yield* client["todo/getAll"]();
}).pipe(Effect.scoped)
)
);RpcClient.make(ApiRpc)produces a client whose methods are exactly the contract's procedures —client["todo/getAll"](),client["audio/get"]({ id }). The argument and return types come straight from theRpc.makeSchemas, so a wrong payload is a compile error in the UI. There is no generated client, no codegen step — the types flow from the one sharedApiRpcobject.credentials: "include"sends the auth cookie, which is howSessionMiddlewarefinds a session on the server.withReactivity([...])/reactivityKeystie a query atom to the mutation atoms that should refresh it. AcreateTodoFntagged with{ reactivityKeys: ["todo/getAll"] }invalidatestodosAtomon success — the UI re-fetches without any manual cache plumbing.
Notice the client imports ApiRpc from @repo/api/rpc/schema — the contract — and never the
handlers or the router. The middleware tag (SessionMiddleware) is deliberately kept client-safe
(a pure deep import, not the @repo/db barrel) precisely so the shared contract can be bundled
into the browser without dragging server-only pg/dns code along.
Putting it together: add a new RPC
The audio slice you just read is the template. To add a procedure end to end:
- Schema — in the domain's
*-schema.ts, define (or reuse) the payload and resultSchema.Structs, deriving types withtype X = typeof X.Type. Keep server-owned fields (likeuserId) out of the input schema. - Contract — in
schema.ts, add aRpc.make("yourProc", { payload, success, error: RpcError })to the relevantRpcGroup.make(...)(or a new group with.prefix("yourdomain/")). If it's a new group, merge it intoApiRpcwith the right.middleware(...). - Handler — in
handlers/<domain>.ts, add"yourdomain/yourProc": (input) => adminHandler("yourdomain/yourProc")(Effect.gen(...))(orrpcHandlerfor non-admin). ReadSession, call the repo, fail withNotFoundwhere a lookup misses. - Wire-up — in
router.ts, make sure the handler slice is spread intoApiRpc.of({...})and its repo dependency is bothyield*-ed and provided viaLayer.provide(YourRepo.layer)inApiRpcLive. - Client — in the app's
rpc.ts, add an atom/fn that callsclient["yourdomain/yourProc"](...), with the rightreactivityKeys.
You never touch the transport, the serialiser, the router plumbing, or the Next route. The contract change flows types to both ends, and the layer wiring forces you to provide what you declared.
Because the handlers are plain functions of their repos, unit-test them by providing a fake repo
layer and the same Session — no HTTP, no Next, no DB. That's the payoff of pushing auth into
middleware and dependencies into arguments: the business logic under test is Session in → typed result out.
See also
- How a request flows — one real call traced end to end: component → RPC client → route → auth → handler → repo → Postgres.
- Architecture — where the RPC boundary sits in the wider system.