1---
2title: Bindings
3description: A Binding connects a Resource to a Worker or Lambda. One line declares the capability and generates the permissions, the configuration, and a typed client.
4---
5
6A **Binding** connects a [Resource](/infrastructure-as-code/resource)
7to a Worker, Lambda Function, Container, or Server — any
8[Runtime](/infrastructure-as-effects/runtime). Inside the Runtime's
9constructor, you yield the resource and get back a typed client:
10
11```typescript
12import * as AWS from "alchemy/AWS";
13import * as Effect from "effect/Effect";
14import * as HttpServerResponse from "effect/http/HttpServerResponse";
15
16export const Jobs = AWS.DynamoDB.Table("Jobs", { partitionKey: "pk" });
17
18export default AWS.Lambda.Function(
19 "Api",
20 { main: import.meta.url, functionUrl: true },
21 Effect.gen(function* () {
22 const getItem = yield* AWS.DynamoDB.GetItem(Jobs);
23
24 return {
25 fetch: Effect.gen(function* () {
26 const job = yield* getItem({ Key: { pk: { S: "job-1" } } });
27 return HttpServerResponse.json(job.Item);
28 }),
29 };
30 }).pipe(Effect.provide(AWS.DynamoDB.GetItemHttp)),
31);
32```
33
34`yield* GetItem(Jobs)` is the binding. `getItem` is the client.
35`GetItemHttp` is the implementation.
36
37A Function that reads a table needs three things: permission to call
38the API, the table's name at runtime, and a client to call it with. In
39traditional IaC those live in three places. A policy in the
40infrastructure code, an environment variable wired between the two, and
41an SDK call in the handler that reads it back:
42
43```typescript
44// infrastructure: grant access and pass the name
45policy: { Action: ["dynamodb:GetItem"], Resource: [table.arn] },
46environment: { JOBS_TABLE: table.name },
47```
48
49```typescript
50// handler: hope the two above agree
51const client = new DynamoDBClient();
52await client.send(
53 new GetItemCommand({ TableName: process.env.JOBS_TABLE, Key }),
54);
55```
56
57Nothing checks that they agree. Rename the table, widen the policy,
58forget the env var, and you find out at runtime. The binding is all
59three. Let's follow what that one line produces.
60
61## Permissions
62
63At deploy time the binding attaches an IAM statement to the Function's
64role. It grants the one action the client can make, on the one table
65it was bound to:
66
67```json
68{
69 "Effect": "Allow",
70 "Action": ["dynamodb:GetItem"],
71 "Resource": ["arn:aws:dynamodb:us-east-1:123456789012:table/Jobs-a1b2c3"]
72}
73```
74
75Least privilege falls out of the shape. Bind `PutItem` as well and the
76role gains a second statement for `dynamodb:PutItem`, nothing more:
77
78```typescript
79const getItem = yield* AWS.DynamoDB.GetItem(Jobs);
80const putItem = yield* AWS.DynamoDB.PutItem(Jobs);
81```
82
83Bind several tables at once and the statement lists each of their ARNs:
84
85```typescript
86const batchGet = yield* AWS.DynamoDB.BatchGetItem(Jobs, Audit);
87```
88
89## Configuration
90
91The client needs the table's physical name. The binding serializes the
92`Jobs.tableName` [Output](/infrastructure-as-code/outputs) into the
93Function's environment:
94
95```sh
96Jobs_tableName=Jobs-a1b2c3
97```
98
99`getItem` reads it back and fills in `TableName` on every request. You
100never touch `process.env`, and the name can't drift from the policy.
101Both come from the same `Jobs` declaration.
102
103## The client
104
105`getItem` is the DynamoDB operation with the table already filled in.
106It returns an `Effect` with a typed error channel, so retries, timeouts,
107and error handling compose onto it:
108
109```typescript
110const job = yield* getItem({ Key: { pk: { S: "job-1" } } }).pipe(
111 Effect.retry({ times: 3, schedule: Schedule.exponential("100 millis") }),
112 Effect.timeout("5 seconds"),
113 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
114);
115```
116
117## A contract and a Layer
118
119`GetItem` names the capability. `GetItemHttp` decides how it's
120satisfied. Cloudflare shows why that split matters. A Worker binds an
121R2 bucket with the same shape:
122
123```typescript
124import * as Cloudflare from "alchemy/Cloudflare";
125
126export const Uploads = Cloudflare.R2.Bucket("Uploads");
127
128export default Cloudflare.Worker(
129 "Api",
130 { main: import.meta.url },
131 Effect.gen(function* () {
132 const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads);
133
134 return {
135 fetch: Effect.gen(function* () {
136 yield* bucket.put("hello.txt", "world");
137 return HttpServerResponse.text("ok");
138 }),
139 };
140 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
141);
142```
143
144There is no IAM on Cloudflare. `ReadWriteBucketBinding` registers a
145native `r2_bucket` binding on the Worker instead, and the access level
146is in the name: `ReadBucket`, `WriteBucket`, or `ReadWriteBucket`.
147
148The contract is a `Binding.Service`. It names the capability and the
149client it returns, and nothing about the platform:
150
151```typescript
152export interface ReadWriteBucket extends Binding.Service<
153 ReadWriteBucket,
154 "Cloudflare.R2.ReadWriteBucket",
155 (bucket: Bucket) => Effect.Effect<ReadWriteBucketClient>
156> {}
157```
158
159So the same contract can be satisfied a different way. Swap the Layer
160and `bucket` calls R2 over HTTP with a scoped API token minted at deploy
161time:
162
163```diff lang="typescript"
164- }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
165+ }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),
166```
167
168The handler doesn't change. And a Layer that needs a platform the host
169can't supply is a compile error:
170
171```typescript
172export default AWS.Lambda.Function(
173 "Api",
174 { main: import.meta.url },
175 handler.pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
176 // ✗ Type error: the Layer requires `WorkerEnvironment` and `Worker`,
177 // which a Lambda Function can't provide
178);
179```
180
181[Layers](/infrastructure-as-effects/layers) takes this split one level
182up, so your own services can carry their bindings with them.
183
184## Plan-time invokes
185
186A binding can also run before anything is deployed. Terraform calls
187this a data source, Pulumi an invoke. Call it directly and you get back
188an [Output](/infrastructure-as-code/outputs) to pass into resource
189props:
190
191```typescript
192// Output<ec2.Image | undefined>
193const image = AWS.EC2.getAmi({ owners: ["amazon"], name: ["al2023-ami-2023.*"] });
194
195// a helper built on it. Output<string>, fails when nothing matches
196imageId: AWS.EC2.amazonLinux2023(),
197```
198
199The same capability bound inside a Function with
200`yield* AWS.EC2.GetAmi(...)` still grants its IAM and runs at runtime.
201One contract, both phases.
202
203## Event Sources and Sinks
204
205An [Event Source](/infrastructure-as-effects/event-sources) is a
206Binding that runs an Effect or `Stream` in the background, whenever
207something happens on the resource:
208
209```typescript
210yield* AWS.SQS.consumeQueueMessages(Inbound, (records) =>
211 Stream.runForEach(records, (record) => Effect.log(record.body)),
212);
213```
214
215A [Sink](/infrastructure-as-effects/sinks) is the converse, a Binding
216you sink a `Stream` into:
217
218```typescript
219const sink = yield* AWS.SQS.QueueSink(Outbound);
220```
221
222Both generate their permissions and configuration the same way.
223
224## How it works under the hood
225
226A binding declaration says nothing about how it is implemented. The
227Layer you provide decides that:
228
229```typescript
230Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)
231```
232
233Inside that Layer, the deploy-time work sits behind a guard. The
234**Construction phase** runs twice, and the guard is what tells the two
235runs apart:
236
237```typescript
238if (!globalThis.__ALCHEMY_RUNTIME__) {
239 // deploy time only: register IAM / native bindings / env on the host
240 yield* host.bind`${resource}`(/* … */);
241}
242// always: return the typed runtime client
243```
244
245When Alchemy bundles the Runtime it defines that global as `true`, so
246the bundler drops the whole branch. Only the client ships:
247
248```typescript
249// folded into every bundle as a rolldown define
250"globalThis.__ALCHEMY_RUNTIME__": "true"
251```
252
253The Resource Provider, the code that actually creates the bucket or
254the table, is never imported by the binding at all. Declaring a
255resource only records a requirement for its Provider, and that
256requirement is satisfied once, at the Stack:
257
258```typescript
259export default Alchemy.Stack(
260 "MyApp",
261 { providers: Cloudflare.providers(), state: Cloudflare.state() },
262 Effect.gen(function* () {
263 yield* Api;
264 }),
265);
266```
267
268This is what lets you declare resources and bindings inside runtime
269code with little to no effect on bundle size. The heavy infrastructure
270code stays at the Stack, and the guard erases the rest.
271[Phases](/infrastructure-as-effects/phases#the-__alchemy_runtime__-guard)
272covers the guard in depth.
273
274The granularity pays off twice. Every binding has its own Layer, and
275each Layer carries exactly one client and registers exactly one grant.
276Provide the two you use and you get two clients in the bundle and two
277statements on the role, nothing more:
278
279```typescript
280Effect.provide(
281 Layer.mergeAll(
282 AWS.DynamoDB.GetItemHttp, // ships getItem, grants dynamodb:GetItem
283 AWS.DynamoDB.PutItemHttp, // ships putItem, grants dynamodb:PutItem
284 ),
285)
286```
287
288The same choice that keeps the bundle small keeps the permissions and
289environment minimal.
290
291## Where next
292
293- [Layers](/infrastructure-as-effects/layers) — hide bindings behind a
294 service interface. Next page.
295- [Event Sources](/infrastructure-as-effects/event-sources) — bindings
296 that trigger your Function.
297- [Sinks](/infrastructure-as-effects/sinks) — bindings you write
298 Streams into.
299- [Circular Bindings](/infrastructure-as-effects/circular-bindings) —
300 two Functions that bind each other.