1---
2title: Effect RPC on Lambda
3description: Build a typed RPC API with Effect's Rpc module and deploy it as an AWS Lambda Function behind a Function URL.
4sidebar:
5 order: 7
6---
7
8Effect RPC is a trust-boundary tool: schemas validate every request,
9so reach for it when data needs sanitizing on its way in — a web app
10or an external service calling your Lambda. Internal calls use
11[Schemaless RPC](/apis/schemaless) instead (on AWS, a Lambda Function
12driving a [MicroVM](/aws/compute/microvms)); the
13[RPC overview](/apis) has the full decision.
14
15The [HTTP API guide](/aws/apis/effect-http-api) showed how to
16build REST-style endpoints with schema validation. Effect RPC takes
17a different angle — you define **procedures** instead of HTTP
18endpoints, and you get a fully typed client for free with no URL
19construction or manual serialization.
20[Effect RPC](/apis/effect-rpc) covers the host-agnostic core; this
21page covers the Lambda side — a public Function URL via `functionUrl: true`,
22and binding implementations provided with
23`Layer.mergeAll(AWS.DynamoDB.GetItemHttp, AWS.DynamoDB.PutItemHttp)`.
24
25The transport is still HTTP under the hood — the RPC server compiles
26down to the same `HttpEffect` a Lambda's `fetch` expects — so the
27wiring story is identical to the HTTP API guide:
28
291. **Define schemas outside the Function.** Domain types and tagged
30 errors, importable by both server and client.
312. **Bind resources and construct handlers inside the Function's
32 Construction phase.** `RpcGroup.toLayer` is pure construction — safe at
33 deploy time and at cold start.
343. **Return `{ fetch }`** where `fetch` is the `HttpEffect` produced
35 by `RpcServer.toHttpEffect`.
364. **Deploy** and call the procedures from a typed client that
37 shares the exact same `RpcGroup` value.
38
39A complete, runnable version of this app lives at
40[`examples/aws-lambda-rpc`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-rpc).
41
42## 1. Define the schemas
43
44Domain model and error types — pure schemas, no runtime concerns:
45
46```typescript
47// src/Job.ts
48import * as Schema from "effect/Schema";
49
50export type JobId = Schema.Schema.Type<typeof JobId>;
51export const JobId = Schema.String.annotate({
52 description: "The ID of the job",
53});
54
55export class Job extends Schema.Class<Job>("Job")({
56 id: JobId,
57 content: Schema.String,
58}) {}
59
60export class JobNotFound extends Schema.TaggedClass<JobNotFound>()(
61 "JobNotFound",
62 { jobId: JobId },
63) {}
64
65export class PutJobFailed extends Schema.TaggedClass<PutJobFailed>()(
66 "PutJobFailed",
67 { message: Schema.String },
68) {}
69```
70
71RPC errors are schema-backed tagged classes. The client receives
72them as typed values you can `catchTag` on — not raw HTTP status
73codes, so there's no `httpApiStatus` annotation here.
74
75## 2. Declare the RPCs
76
77Each `Rpc.make` declares one procedure: a name, a payload schema, a
78success schema, and an error schema. `RpcGroup.make` collects them
79into a single value that both the server and the client will share.
80
81```typescript
82// src/JobRpcs.ts
83import * as Schema from "effect/Schema";
84import { Rpc, RpcGroup } from "effect/rpc";
85import { Job, JobId, JobNotFound, PutJobFailed } from "./Job.ts";
86
87const getJob = Rpc.make("getJob", {
88 success: Job,
89 error: JobNotFound,
90 payload: {
91 jobId: JobId,
92 },
93});
94
95const createJob = Rpc.make("createJob", {
96 success: JobId,
97 error: PutJobFailed,
98 payload: {
99 content: Schema.String,
100 },
101});
102
103export class JobRpcs extends RpcGroup.make(getJob, createJob) {}
104```
105
106`JobRpcs` is just a value-level description. Nothing executes yet.
107
108## 3. Build the Function
109
110Create `src/JobFunction.ts` with an empty Construction phase and a public
111Function URL:
112
113```typescript
114// src/JobFunction.ts
115import * as AWS from "alchemy/AWS";
116import * as Effect from "effect/Effect";
117
118export default class JobFunction extends AWS.Lambda.Function<JobFunction>()(
119 "JobFunction",
120 { main: import.meta.url, functionUrl: true },
121 Effect.gen(function* () {
122 return {};
123 }),
124) {}
125```
126
127The generator is the **Construction phase**. It runs at *deploy time* (when
128Alchemy plans the stack and collects bindings into IAM policies and
129environment variables) and again *inside the deployed Lambda* at
130cold start. Only do pure construction here — bindings, layers, never
131per-request work.
132
133### 3a. Declare the table and bind operations
134
135Jobs need somewhere durable to live. Yield a DynamoDB `Table` inside
136the constructor to declare the resource, then bind the two operations the
137handlers will need:
138
139```diff lang="typescript"
140 Effect.gen(function* () {
141+ const table = yield* AWS.DynamoDB.Table("JobsTable", {
142+ partitionKey: "id",
143+ attributes: { id: "S" },
144+ });
145+
146+ const getItem = yield* AWS.DynamoDB.GetItem(table);
147+ const putItem = yield* AWS.DynamoDB.PutItem(table);
148
149 return {};
150 }),
151```
152
153Each binding does double duty: at deploy time it attaches a
154least-privilege IAM statement to the function's execution role; at
155runtime it returns a typed callable that injects the table name
156automatically.
157
158### 3b. Construct the handlers inside the constructor
159
160`JobRpcs.toLayer` takes one handler per procedure and produces a
161`Layer`. Like `HttpApiBuilder.group`, this is pure construction — it
162builds a value, it doesn't run the server. The handlers close over
163the `getItem` / `putItem` bindings from step 3a:
164
165```diff lang="typescript"
166 const getItem = yield* AWS.DynamoDB.GetItem(table);
167 const putItem = yield* AWS.DynamoDB.PutItem(table);
168
169+ const handlersLayer = JobRpcs.toLayer({
170+ getJob: ({ jobId }) =>
171+ getItem({ Key: { id: { S: jobId } } }).pipe(
172+ Effect.orDie,
173+ Effect.flatMap(({ Item }) =>
174+ Item
175+ ? Effect.succeed(
176+ new Job({
177+ id: Item.id?.S ?? jobId,
178+ content: Item.content?.S ?? "",
179+ }),
180+ )
181+ : Effect.fail(new JobNotFound({ jobId })),
182+ ),
183+ ),
184+ createJob: ({ content }) =>
185+ Effect.gen(function* () {
186+ const jobId = crypto.randomUUID();
187+ yield* putItem({
188+ Item: {
189+ id: { S: jobId },
190+ content: { S: content },
191+ },
192+ });
193+ return jobId;
194+ }).pipe(
195+ Effect.mapError(
196+ (error) => new PutJobFailed({ message: error.message }),
197+ ),
198+ ),
199+ });
200
201 return {};
202```
203
204Each handler receives the typed payload and returns an `Effect` that
205either succeeds with the declared success schema or fails with the
206declared error schema. `getJob` uses `Effect.orDie` to turn
207unexpected DynamoDB failures into 500s — `JobNotFound` is the only
208client-visible error. `createJob` maps DynamoDB failures into the
209declared `PutJobFailed` error so the client can match on it.
210
211### 3c. Return `{ fetch }`
212
213`RpcServer.toHttpEffect` converts the `RpcGroup` into the
214`HttpEffect` that `fetch` expects. We provide two layers to it: the
215`handlersLayer` we just built and `RpcSerialization.layerJson`,
216which tells the server to encode and decode messages as JSON (swap
217for `layerNdjson` when you add streaming procedures). Unlike the
218HTTP API approach, RPC doesn't need `HttpPlatform.layer` or
219`Etag.layer` — the RPC server handles message framing internally.
220
221```diff lang="typescript"
222 return {
223- };
224+ fetch: yield* RpcServer.toHttpEffect(JobRpcs).pipe(
225+ Effect.provide(
226+ Layer.mergeAll(handlersLayer, RpcSerialization.layerJson),
227+ ),
228+ ),
229+ };
230 }).pipe(
231+ Effect.provide(
232+ Layer.mergeAll(
233+ AWS.DynamoDB.GetItemHttp,
234+ AWS.DynamoDB.PutItemHttp,
235+ ),
236+ ),
237 ),
238```
239
240The outer `Effect.provide` supplies the binding implementations —
241`GetItemHttp` / `PutItemHttp` implement the contracts over
242DynamoDB's HTTP API, signed with the Lambda's execution role.
243
244### Putting it together
245
246```typescript
247// src/JobFunction.ts
248import * as AWS from "alchemy/AWS";
249import * as Effect from "effect/Effect";
250import * as Layer from "effect/Layer";
251import { RpcSerialization, RpcServer } from "effect/rpc";
252import { Job, JobNotFound, PutJobFailed } from "./Job.ts";
253import { JobRpcs } from "./JobRpcs.ts";
254
255export default class JobFunction extends AWS.Lambda.Function<JobFunction>()(
256 "JobFunction",
257 { main: import.meta.url, functionUrl: true },
258 Effect.gen(function* () {
259 const table = yield* AWS.DynamoDB.Table("JobsTable", {
260 partitionKey: "id",
261 attributes: { id: "S" },
262 });
263
264 const getItem = yield* AWS.DynamoDB.GetItem(table);
265 const putItem = yield* AWS.DynamoDB.PutItem(table);
266
267 const handlersLayer = JobRpcs.toLayer({
268 getJob: ({ jobId }) =>
269 getItem({ Key: { id: { S: jobId } } }).pipe(
270 Effect.orDie,
271 Effect.flatMap(({ Item }) =>
272 Item
273 ? Effect.succeed(
274 new Job({
275 id: Item.id?.S ?? jobId,
276 content: Item.content?.S ?? "",
277 }),
278 )
279 : Effect.fail(new JobNotFound({ jobId })),
280 ),
281 ),
282 createJob: ({ content }) =>
283 Effect.gen(function* () {
284 const jobId = crypto.randomUUID();
285 yield* putItem({
286 Item: {
287 id: { S: jobId },
288 content: { S: content },
289 },
290 });
291 return jobId;
292 }).pipe(
293 Effect.mapError(
294 (error) => new PutJobFailed({ message: error.message }),
295 ),
296 ),
297 });
298
299 return {
300 fetch: yield* RpcServer.toHttpEffect(JobRpcs).pipe(
301 Effect.provide(
302 Layer.mergeAll(handlersLayer, RpcSerialization.layerJson),
303 ),
304 ),
305 };
306 }).pipe(
307 Effect.provide(
308 Layer.mergeAll(AWS.DynamoDB.GetItemHttp, AWS.DynamoDB.PutItemHttp),
309 ),
310 ),
311) {}
312```
313
314## 4. Add to the Stack
315
316```typescript
317// alchemy.run.ts
318import * as Alchemy from "alchemy";
319import * as AWS from "alchemy/AWS";
320import * as Effect from "effect/Effect";
321import JobFunction from "./src/JobFunction.ts";
322
323export default Alchemy.Stack(
324 "JobRpc",
325 {
326 providers: AWS.providers(),
327 state: AWS.state(),
328 },
329 Effect.gen(function* () {
330 const func = yield* JobFunction;
331 return { url: func.functionUrl };
332 }),
333);
334```
335
336Yielding `JobFunction` deploys the function *and* everything it
337declared inside the constructor — the DynamoDB table, the generated IAM role
338with the two table-scoped statements, and the Function URL.
339
340## 5. Deploy
341
342```sh
343bun alchemy deploy
344```
345
346## 6. Call it from a typed client
347
348Because `JobRpcs` is just a value, the same group drives a fully
349typed client — no codegen. `client.createJob` accepts
350`{ content: string }` and returns `Effect<JobId, PutJobFailed>`.
351
352```typescript
353// scripts/client.ts
354import * as Effect from "effect/Effect";
355import * as Layer from "effect/Layer";
356import * as FetchHttpClient from "effect/http/FetchHttpClient";
357import { RpcClient, RpcSerialization } from "effect/rpc";
358import { JobRpcs } from "../src/JobRpcs.ts";
359
360const program = Effect.gen(function* () {
361 const client = yield* RpcClient.make(JobRpcs);
362
363 const jobId = yield* client.createJob({ content: "Ship the docs" });
364 console.log("Created:", jobId);
365
366 const job = yield* client.getJob({ jobId });
367 console.log("Fetched:", job.content);
368});
369
370Effect.runPromise(
371 program.pipe(
372 Effect.scoped,
373 Effect.provide(
374 RpcClient.layerProtocolHttp({ url: process.env.JOB_RPC_URL! }).pipe(
375 Layer.provide(FetchHttpClient.layer),
376 Layer.provide(RpcSerialization.layerJson),
377 ),
378 ),
379 ),
380);
381```
382
383Get the URL from the deploy output and run it:
384
385```sh
386JOB_RPC_URL=https://<id>.lambda-url.us-west-2.on.aws bun scripts/client.ts
387```
388
389The errors are typed values: `client.getJob` returns
390`Effect<Job, JobNotFound>`, and you can
391`Effect.catchTag("JobNotFound", ...)` to handle the missing case
392explicitly.
393
394## Choosing an API style
395
396- **External Effect/TypeScript consumers** — this page.
397- **External plain-HTTP consumers** (curl, non-TypeScript clients) —
398 [Effect HTTP API on Lambda](/aws/apis/effect-http-api).
399- **Internal service-to-service calls** —
400 [Schemaless RPC](/apis/schemaless).
401- **The full decision** — [RPC overview](/apis).
402
403## Go further
404
405The full
406[`examples/aws-lambda-rpc`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-rpc)
407example grows this app in a few directions: it hides DynamoDB behind
408a cloud-agnostic `JobStorage` service (with a drop-in S3
409implementation), publishes job-created notifications to SNS, fans
410table changes out to SQS via DynamoDB Streams, and adds a CloudWatch
411dashboard and alarm. The
412[Layers](/infrastructure-as-effects/layers) page
413explains the service-Layer pattern it uses.
414
415## Where next
416
417- [Lambda](/aws/compute/lambda) — how the Function class, Function URLs, and
418 the test harness work.
419- [Effect HTTP API on Lambda](/aws/apis/effect-http-api) — the
420 REST-style sibling of this guide, same wiring.
421- [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — the same
422 `RpcGroup` deployed to Cloudflare, plus streaming and Durable
423 Object routing.
424- [DynamoDB](/aws/data/dynamodb) — the table resource and the rest of its
425 bindings.