1---
2title: Effect HTTP API on Lambda
3description: Build a schema-validated HTTP API with Effect's HttpApi module and deploy it as an AWS Lambda Function behind a Function URL.
4sidebar:
5 order: 5
6---
7
8Effect HTTP defines real REST endpoints — URLs, path params, query
9strings, typed bodies — behind an RPC-like typed interface, which
10makes it the natural fit when your consumers aren't Effect (or
11aren't TypeScript at all) and want a plain HTTP client. This page is
12the Lambda wiring; the concept lives at
13[Effect HTTP](/apis/effect-http), and [RPC](/apis) covers choosing
14between the RPC styles.
15
16The [Lambda](/aws/compute/lambda) page serves HTTP with a hand-rolled
17`fetch` handler — raw request parsing, manual routing. That works,
18but as your API grows you lose type safety at the boundary: request
19payloads aren't validated, response shapes aren't enforced, and
20errors slip through untyped.
21
22Effect's `HttpApi` module solves this. You declare endpoints with
23schemas for payloads, responses, and errors, then implement handlers
24against those schemas. The result is an `HttpEffect` — the same type
25a Lambda's `fetch` expects — so it plugs in directly. It's the exact
26same module used in the
27[Workers version of this guide](/cloudflare/apis/effect-http-api);
28the spec and handlers are portable, only the host changes.
29
30The mental model:
31
321. **Define the schema and API outside the Function.** Both are pure
33 descriptions and can be imported by clients.
342. **Bind resources and construct handlers inside the Function's
35 Construction phase.** The Construction phase runs at deploy time *and* at cold
36 start, so we only do pure construction here — bindings, layers,
37 never per-request work.
383. **Return `{ fetch }`** where `fetch` is an `HttpEffect` produced
39 by `HttpRouter.toHttpEffect`. That's the value Lambda invokes on
40 every request.
414. **Deploy** and call the API from a fully typed test.
42
43A complete, runnable version of this app lives at
44[`examples/aws-lambda-httpapi`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-httpapi).
45
46## 1. Define the schema
47
48Start with a domain model. The schema lives outside the Function so
49the same file can be imported by clients without pulling in any
50runtime code.
51
52```typescript
53// src/Job.ts
54import * as Schema from "effect/Schema";
55
56export type JobId = Schema.Schema.Type<typeof JobId>;
57export const JobId = Schema.String.annotate({
58 description: "The ID of the job",
59});
60
61export class Job extends Schema.Class<Job>("Job")({
62 id: JobId,
63 content: Schema.String,
64}) {}
65
66export class JobNotFound extends Schema.TaggedErrorClass<JobNotFound>()(
67 "JobNotFound",
68 { id: Schema.String },
69 { httpApiStatus: 404 },
70) {}
71```
72
73`Schema.Class` gives you a runtime-validated class with an inferred
74TypeScript type. `Schema.TaggedErrorClass` gives you a typed error
75you can fail with from handlers and discriminate against on the
76client — `httpApiStatus: 404` tells the HttpApi runtime which status
77code to serialize it as.
78
79## 2. Declare the API
80
81Endpoints are declarations — they describe `(method, path, query,
82payload, success, error)` without implementing anything yet. Putting
83them in their own file keeps the spec importable from both the
84server *and* a typed client.
85
86```typescript
87// src/JobApi.ts
88import * as Schema from "effect/Schema";
89import * as HttpApi from "effect/http-api/HttpApi";
90import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
91import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
92import { Job, JobId, JobNotFound } from "./Job.ts";
93
94export const getJob = HttpApiEndpoint.get("getJob", "/", {
95 success: Job,
96 error: JobNotFound,
97 query: {
98 jobId: JobId,
99 },
100});
101
102export const createJob = HttpApiEndpoint.post("createJob", "/", {
103 success: JobId,
104 payload: Schema.Struct({
105 content: Schema.String,
106 }),
107});
108
109export const JobApi = HttpApi.make("JobApi").add(
110 HttpApiGroup.make("Jobs").add(getJob, createJob),
111);
112```
113
114Nothing executes yet — `JobApi` is purely a value-level description.
115The same constant is what we'll hand to the client at the end of
116this guide.
117
118## 3. Build the Function
119
120Now wire it up. Create `src/JobFunction.ts` with an empty Construction
121phase and a public Function URL:
122
123```typescript
124// src/JobFunction.ts
125import * as AWS from "alchemy/AWS";
126import * as Effect from "effect/Effect";
127
128export default class JobFunction extends AWS.Lambda.Function<JobFunction>()(
129 "JobFunction",
130 { main: import.meta.url, functionUrl: true },
131 Effect.gen(function* () {
132 return {};
133 }),
134) {}
135```
136
137The generator is the **Construction phase**. It runs at *deploy time* (when
138Alchemy plans the stack and collects bindings into IAM policies and
139environment variables) and again *inside the deployed Lambda* at
140cold start, where the same yields resolve to live clients. Anything
141you `yield*` here must be safe in both contexts — resource bindings,
142layer construction, never per-request work.
143
144### 3a. Declare the table and bind operations
145
146Jobs need somewhere durable to live. Yield a DynamoDB `Table` inside
147the constructor to declare the resource, then bind the two operations the
148handlers will need:
149
150```diff lang="typescript"
151 Effect.gen(function* () {
152+ const table = yield* AWS.DynamoDB.Table("JobsTable", {
153+ partitionKey: "id",
154+ attributes: { id: "S" },
155+ });
156+
157+ const getItem = yield* AWS.DynamoDB.GetItem(table);
158+ const putItem = yield* AWS.DynamoDB.PutItem(table);
159
160 return {};
161 }),
162```
163
164Each binding does double duty: at deploy time it attaches a
165least-privilege IAM statement to the function's execution role
166(`GetItem` grants exactly `dynamodb:GetItem` on this table's ARN);
167at runtime it returns a typed callable that injects the table name
168automatically.
169
170### 3b. Provide the binding implementations
171
172The bindings are contracts; their implementations are Layers that
173must be bundled into the function. Provide them on the constructor Effect:
174
175```diff lang="typescript"
176 Effect.gen(function* () {
177 // ...
178- }),
179+ }).pipe(
180+ Effect.provide(
181+ Layer.mergeAll(
182+ AWS.DynamoDB.GetItemHttp,
183+ AWS.DynamoDB.PutItemHttp,
184+ ),
185+ ),
186+ ),
187```
188
189`GetItemHttp` / `PutItemHttp` implement the binding contracts over
190DynamoDB's HTTP API, signed with the Lambda's execution role — no
191credentials to wire up.
192
193### 3c. Construct the handler group
194
195`HttpApiBuilder.group` *constructs* a `Layer` that wires handlers
196into the API spec. It's pure — it doesn't run them — so it's safe to
197build inside the constructor. The handlers close over the `getItem` / `putItem`
198bindings from step 3a:
199
200```diff lang="typescript"
201 const getItem = yield* AWS.DynamoDB.GetItem(table);
202 const putItem = yield* AWS.DynamoDB.PutItem(table);
203
204+ const jobsGroup = HttpApiBuilder.group(JobApi, "Jobs", (handlers) =>
205+ handlers
206+ .handle("getJob", ({ query }) =>
207+ getItem({ Key: { id: { S: query.jobId } } }).pipe(
208+ Effect.orDie,
209+ Effect.flatMap(({ Item }) =>
210+ Item
211+ ? Effect.succeed(
212+ new Job({
213+ id: Item.id?.S ?? query.jobId,
214+ content: Item.content?.S ?? "",
215+ }),
216+ )
217+ : Effect.fail(new JobNotFound({ id: query.jobId })),
218+ ),
219+ ),
220+ )
221+ .handle("createJob", ({ payload }) =>
222+ Effect.gen(function* () {
223+ const jobId = crypto.randomUUID();
224+ yield* putItem({
225+ Item: {
226+ id: { S: jobId },
227+ content: { S: payload.content },
228+ },
229+ });
230+ return jobId;
231+ }).pipe(Effect.orDie),
232+ ),
233+ );
234
235 return {};
236```
237
238Each handler receives a typed request. `query.jobId` is a `string`
239because that's what the endpoint declared, and the return type must
240satisfy `Job` (or fail with `JobNotFound`). Mismatches are caught at
241compile time. `Effect.orDie` converts DynamoDB's infrastructure
242errors into defects — the HttpApi runtime turns those into 500s —
243keeping the typed error channel reserved for `JobNotFound`.
244
245### 3d. Return `{ fetch }`
246
247The return value of the constructor is the Function's runtime surface — for an
248HTTP Lambda that means an object with a `fetch` field.
249`HttpApiBuilder.layer(JobApi)` assembles the API from the handler
250group, and `HttpRouter.toHttpEffect` converts that Layer into the
251`HttpEffect` that `fetch` expects:
252
253```diff lang="typescript"
254 return {
255- };
256+ fetch: yield* HttpRouter.toHttpEffect(
257+ HttpApiBuilder.layer(JobApi).pipe(Layer.provide(jobsGroup)),
258+ ),
259+ };
260 }).pipe(
261 Effect.provide(
262 Layer.mergeAll(
263 AWS.DynamoDB.GetItemHttp,
264 AWS.DynamoDB.PutItemHttp,
265+ HttpPlatform.layer,
266+ Etag.layer,
267 ),
268 ),
269 ),
270```
271
272`HttpPlatform.layer` and `Etag.layer` are platform services the
273builder needs (content negotiation, ETag generation) — they join the
274binding implementations in the same `Layer.mergeAll`.
275
276### Putting it together
277
278Here's the complete `src/JobFunction.ts`:
279
280```typescript
281// src/JobFunction.ts
282import * as AWS from "alchemy/AWS";
283import * as Effect from "effect/Effect";
284import * as Layer from "effect/Layer";
285import * as Etag from "effect/http/Etag";
286import * as HttpPlatform from "effect/http/HttpPlatform";
287import * as HttpRouter from "effect/http/HttpRouter";
288import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
289import { Job, JobNotFound } from "./Job.ts";
290import { JobApi } from "./JobApi.ts";
291
292export default class JobFunction extends AWS.Lambda.Function<JobFunction>()(
293 "JobFunction",
294 { main: import.meta.url, functionUrl: true },
295 Effect.gen(function* () {
296 const table = yield* AWS.DynamoDB.Table("JobsTable", {
297 partitionKey: "id",
298 attributes: { id: "S" },
299 });
300
301 const getItem = yield* AWS.DynamoDB.GetItem(table);
302 const putItem = yield* AWS.DynamoDB.PutItem(table);
303
304 const jobsGroup = HttpApiBuilder.group(JobApi, "Jobs", (handlers) =>
305 handlers
306 .handle("getJob", ({ query }) =>
307 getItem({ Key: { id: { S: query.jobId } } }).pipe(
308 Effect.orDie,
309 Effect.flatMap(({ Item }) =>
310 Item
311 ? Effect.succeed(
312 new Job({
313 id: Item.id?.S ?? query.jobId,
314 content: Item.content?.S ?? "",
315 }),
316 )
317 : Effect.fail(new JobNotFound({ id: query.jobId })),
318 ),
319 ),
320 )
321 .handle("createJob", ({ payload }) =>
322 Effect.gen(function* () {
323 const jobId = crypto.randomUUID();
324 yield* putItem({
325 Item: {
326 id: { S: jobId },
327 content: { S: payload.content },
328 },
329 });
330 return jobId;
331 }).pipe(Effect.orDie),
332 ),
333 );
334
335 return {
336 fetch: yield* HttpRouter.toHttpEffect(
337 HttpApiBuilder.layer(JobApi).pipe(Layer.provide(jobsGroup)),
338 ),
339 };
340 }).pipe(
341 Effect.provide(
342 Layer.mergeAll(
343 AWS.DynamoDB.GetItemHttp,
344 AWS.DynamoDB.PutItemHttp,
345 HttpPlatform.layer,
346 Etag.layer,
347 ),
348 ),
349 ),
350) {}
351```
352
353## 4. Add to the Stack
354
355```typescript
356// alchemy.run.ts
357import * as Alchemy from "alchemy";
358import * as AWS from "alchemy/AWS";
359import * as Effect from "effect/Effect";
360import JobFunction from "./src/JobFunction.ts";
361
362export default Alchemy.Stack(
363 "JobApi",
364 {
365 providers: AWS.providers(),
366 state: AWS.state(),
367 },
368 Effect.gen(function* () {
369 const func = yield* JobFunction;
370 return { url: func.functionUrl };
371 }),
372);
373```
374
375Yielding `JobFunction` deploys the function *and* everything it
376declared inside the constructor — the DynamoDB table, the generated IAM role
377with the two table-scoped statements, and the Function URL.
378
379## 5. Deploy
380
381```sh
382bun alchemy deploy
383```
384
385Hit the URL from the deploy output with `curl`:
386
387```sh
388curl -X POST https://<id>.lambda-url.us-west-2.on.aws/ \
389 -H "Content-Type: application/json" \
390 -d '{"content": "Ship the docs"}'
391# → "d3adb33f-..."
392
393curl "https://<id>.lambda-url.us-west-2.on.aws/?jobId=d3adb33f-..."
394# → {"id":"d3adb33f-...","content":"Ship the docs"}
395```
396
397Invalid payloads get automatic 400 responses with structured
398validation errors, and a missing job comes back as a 404 — the
399`httpApiStatus` we declared on `JobNotFound`.
400
401## 6. Call it from a typed test
402
403Because `JobApi` is just a value, the same spec drives a fully typed
404client — no codegen, no string URLs. Combine `HttpApiClient` with
405the test harness to deploy the Stack and exercise the API
406end-to-end:
407
408```typescript
409// test/api.test.ts
410import * as AWS from "alchemy/AWS";
411import * as Test from "alchemy/Test/Bun";
412import { expect } from "bun:test";
413import * as Effect from "effect/Effect";
414import * as Schedule from "effect/Schedule";
415import * as HttpApiClient from "effect/http-api/HttpApiClient";
416import Stack from "../alchemy.run.ts";
417import { JobApi } from "../src/JobApi.ts";
418
419const { test, beforeAll, deploy } = Test.make({
420 providers: AWS.providers(),
421 state: AWS.state(),
422});
423
424const stack = beforeAll(deploy(Stack));
425
426test(
427 "create and fetch a job through the typed client",
428 Effect.gen(function* () {
429 const { url } = yield* stack;
430 const client = yield* HttpApiClient.make(JobApi, { baseUrl: url });
431
432 // a fresh Function URL can take a few seconds to start serving
433 const jobId = yield* client.Jobs.createJob({
434 payload: { content: "Ship the docs" },
435 }).pipe(
436 Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }),
437 );
438
439 const job = yield* client.Jobs.getJob({ query: { jobId } });
440 expect(job.content).toBe("Ship the docs");
441
442 const missing = yield* client.Jobs.getJob({
443 query: { jobId: "does-not-exist" },
444 }).pipe(Effect.flip);
445 expect(missing._tag).toBe("JobNotFound");
446 }),
447);
448```
449
450```sh
451bun test test/api.test.ts
452```
453
454`client.Jobs.getJob` returns
455`Effect<Job, JobNotFound | HttpClientError>` — the `JobNotFound`
456branch is a real typed value you can pattern-match on (here via
457`Effect.flip`), not an HTTP status code you have to interpret.
458
459## Go further
460
461The full
462[`examples/aws-lambda-httpapi`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-httpapi)
463example grows this app in a few directions: it hides DynamoDB behind
464a cloud-agnostic `JobStorage` service (with a drop-in S3
465implementation), publishes job-created notifications to SNS, fans
466table changes out to SQS via DynamoDB Streams, and adds a CloudWatch
467dashboard and alarm. The
468[Layers](/infrastructure-as-effects/layers) page
469explains the service-Layer pattern it uses.
470
471## Where next
472
473- [Effect HTTP](/apis/effect-http) — the concept home: endpoints,
474 groups, handlers, and typed clients, independent of any host.
475- [RPC](/apis) — deciding between schemaless RPC, Effect RPC, and
476 Effect HTTP.
477- [Lambda](/aws/compute/lambda) — how the Function class, Function URLs,
478 and the test harness work.
479- [Effect HTTP API on Workers](/cloudflare/apis/effect-http-api) —
480 the same `HttpApi` spec deployed to Cloudflare.
481- [DynamoDB](/aws/data/dynamodb) — the table resource and the rest of
482 its bindings.
483- [REST API (API Gateway v1)](/aws/apis/api-gateway) — put API
484 Gateway in front instead of a Function URL.