1---
2title: Bindings — one line, two phases
3date: 2026-04-30
4draft: true
5excerpt: A single `yield* S3.GetObject(Photos)` in your handler grants the IAM policy at deploy and calls the SDK at runtime. The IAM logic never lands in your bundle.
6---
7
8You're writing a Cloudflare Worker (or an AWS Lambda — same
9story). It needs to read an object from an S3 bucket. In
10Alchemy it looks like this:
11
12```typescript
13Effect.gen(function* () {
14 const getPhoto = yield* S3.GetObject(Photos);
15 return {
16 fetch: Effect.gen(function* () {
17 const photo = yield* getPhoto({ Key: "cover.jpg" });
18 return HttpServerResponse.raw(photo.Body);
19 }),
20 };
21});
22```
23
24The shape is "bind and use" — the standard
25[`Layer`](https://effect.website/docs/requirements-management/layers/)
26constructor pattern from Effect. `yield*` your dependencies
27at the top, then return a `fetch` Effect that uses them.
28Bind runs once; `fetch` runs per request.
29
30Focus on the `S3.GetObject(Photos)` call. Two things
31have to happen for it to work.
32
33- **At deploy time**, the Worker's IAM role needs
34 `s3:GetObject` on the `Photos` bucket. If it doesn't, the
35 call fails at runtime with `AccessDenied`.
36- **At runtime**, the Worker process needs a real S3 SDK
37 client that knows how to talk to AWS, signs the request,
38 and gets back bytes.
39
40These are very different jobs. The first is infrastructure
41authoring — IAM policy documents, role attachments, deploy
42orchestration. The second is application code — SDK calls,
43retries, response parsing. Most frameworks make you write
44them in two separate places: the IAM in your IaC config,
45the SDK call in your handler. You're responsible for
46keeping the two in sync.
47
48Alchemy lets you write *one* line for both, then arranges
49for each half to run in the right phase — and crucially,
50arranges for the *deploy-side* half to never end up in your
51runtime bundle.
52
53## Capabilities have two halves
54
55Every capability in Alchemy — `S3.GetObject`, `S3.PutObject`,
56`SQS.SendMessage`, `DynamoDB.GetItem`, and so on — is
57implemented as a pair of Effect Services:
58
59```typescript
60// AWS/S3/GetObject.ts
61export class GetObject extends Binding.Service<GetObject, ...>()
62 ("AWS.S3.GetObject") {}
63
64export class GetObjectPolicy extends Binding.Policy<GetObjectPolicy, ...>()
65 ("AWS.S3.GetObject") {}
66```
67
68The `Binding.Service` is the runtime half — an Effect
69service whose `Live` layer wraps the actual SDK client.
70When you `yield*` it, you get back a typed callable that
71takes a bucket and a request and gives you back the
72response.
73
74The `Binding.Policy` is the deploy-time half — an Effect
75service whose `Live` layer doesn't do any I/O at all. It
76takes the bucket your handler referenced and records a
77policy statement onto the calling Worker's role:
78
79```typescript
80yield* host.bind`Allow(${host}, AWS.S3.GetObject(${bucket}))`({
81 policyStatements: [{
82 Effect: "Allow",
83 Action: ["s3:GetObject", "s3:GetObjectVersion"],
84 Resource: [Output.interpolate`${bucket.bucketArn}/*`],
85 }, /* + s3:ListBucket so 404s aren't 403s */],
86});
87```
88
89Two services, one capability name (`AWS.S3.GetObject`). One
90gets bundled into your Worker; one runs only during plan.
91
92## The TypeScript trick
93
94The interesting bit is how Alchemy distinguishes "this
95service must be provided" from "this service is allowed to
96be absent."
97
98When you `yield* S3.GetObject(Photos)` inside a Worker,
99the Effect's `R` channel picks up `GetObject` (the runtime
100service) as a hard requirement. If you forget to attach the
101`GetObjectLive` layer to the Worker, the compiler tells you:
102
103```
104Argument of type 'Effect<…, …, GetObject>' is not assignable to
105parameter of type 'Effect<…, …, never>'.
106```
107
108`GetObjectPolicy` (the deploy-time service) is also
109referenced by the same line. But it doesn't show up in `R`,
110because the framework resolves it via
111`Effect.serviceOption` — it's *optional* at the type level.
112
113The relevant snippet is the comment right in
114[`Binding.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Binding.ts):
115
116```typescript
117// we use a service option because at runtime (e.g. in a Lambda
118// Function or Cloudflare Worker) the Policy Layer is not provided
119// and this becomes a no-op
120```
121
122The runtime layer must be provided or TypeScript breaks
123your build. The policy layer is allowed to be missing —
124and at runtime, it is. The same `S3.GetObject(...)`
125expression is a real SDK-client construction at runtime and
126a no-op at runtime *for the policy half*.
127
128## What happens if the policy *is* needed but missing
129
130If you forget to provide `GetObjectPolicyLive` at deploy
131time, you don't want a silent no-op — you want a loud
132failure that tells you the role won't get the right IAM.
133
134Alchemy checks the deploy phase explicitly. From
135`Binding.ts`:
136
137```typescript
138service
139 ? Effect.succeed(service)
140 : Effect.all([CurrentStack, ALCHEMY_PHASE]).pipe(
141 Effect.flatMap(([stack, phase]) =>
142 stack && phase === "plan"
143 ? Effect.die(
144 `Binding.Policy provider 'Policy<${Identifier}>' was not provided at Plan Time in Stack '${stack.name}'`,
145 )
146 : Effect.succeed((() => Effect.void) as any as Shape),
147 ),
148 ),
149```
150
151In plan phase + with a Stack in scope, missing the policy
152layer dies with a clear message. Outside plan (e.g. inside
153the deployed Worker), it falls through to `Effect.void` and
154is invisible. Same expression, different behavior, scoped
155to the phase that needs each half.
156
157## The Stack is where policies live
158
159A `Cloudflare.Worker` (or `Lambda.Function`) lets you
160provide the runtime layers it needs *on the Worker itself*:
161
162```typescript
163const Photos = yield* Cloudflare.R2.Bucket("Photos");
164const Api = yield* Cloudflare.Worker("Api", { main: "./src/api.ts" },
165 Effect.gen(function* () {
166 return { fetch: handler };
167 }).pipe(
168 Effect.provide(GetObjectLive), // ← Binding.Service: bundled
169 ),
170);
171```
172
173`GetObjectLive` (the runtime service) gets bundled into the
174Worker. Nothing else here mentions IAM.
175
176The Stack is where the *Policy* layers live:
177
178```typescript
179export default Alchemy.Stack(
180 "MyApp",
181 { providers: AWS.providers() }, // ← Binding.Policy layers: NOT bundled
182 Effect.gen(function* () {
183 const photos = yield* Photos;
184 const api = yield* Api;
185 return { url: api.url };
186 }),
187);
188```
189
190`AWS.providers()` is a Layer that bundles every
191`Binding.Policy` AWS exposes — `GetObjectPolicyLive`,
192`PutObjectPolicyLive`, `SendMessagePolicyLive`, etc. The
193Stack runs your generator in plan phase with those
194provided. When the planner walks into your Worker's
195generator and reaches `S3.GetObject(Photos)`, the
196policy lookup succeeds, the role gets the right statement,
197the deploy proceeds.
198
199Then the planner walks back out. Your Worker bundle is
200written. The bundle has `GetObjectLive` (runtime SDK) but
201*not* `GetObjectPolicyLive` (IAM logic) — because the
202Worker's `R` never required it.
203
204## What ends up in your bundle
205
206The mental model is: **the Worker bundle is whatever its
207Effect's `R` channel demands, and nothing else.**
208
209In the bundle:
210
211- Your handler.
212- `Binding.Service` runtime layers (SDK wrappers) — every
213 `getObject`/`putObject`/`sendMessage` you actually call.
214- Resource data structures — `{ LogicalId: "Photos", ... }`
215 is all the runtime needs to identify a bucket. The
216 *output values* (bucket name, ARN) get resolved at deploy
217 time and inlined as constants by the bundler.
218
219Not in the bundle:
220
221- `Binding.Policy` layers — they're optional services, so
222 the Worker's `R` never required them.
223- Provider implementations — the code that knows how to
224 `reconcile` a Bucket lives in `AWS.providers()`, which
225 is provided to the Stack, not the Worker.
226- Other Workers' handlers — same reasoning, different
227 resource type. Worker A binding Worker B only needs B's
228 Tag (its `LogicalId` and binding contract), not B's
229 handler code.
230
231The tree-shake property falls out of the type system. You
232didn't have to opt in; you'd have to opt *out* to leak
233deploy-time code into a runtime bundle, by explicitly
234providing the Policy layer where it isn't needed.
235
236## Inline and Tag forms produce the same bundle
237
238There are two ways to author a Worker. The inline form, for
239when nothing else references this Worker:
240
241```typescript
242export const Api = Cloudflare.Worker("Api", { main: "./src/api.ts" },
243 Effect.gen(function* () {
244 const getPhoto = yield* S3.GetObject(Photos);
245 return {
246 fetch: Effect.gen(function* () {
247 const photo = yield* getPhoto({ Key: "cover.jpg" });
248 /* ... */
249 }),
250 };
251 }),
252);
253```
254
255The Tag-and-Layer form, for when this Worker has to be
256referenced before its implementation is in scope (a cycle,
257self-reference, or just a Worker exported across files):
258
259```typescript
260export class Api extends Cloudflare.Worker<Api>()("Api", {
261 main: import.meta.filename,
262}) {}
263
264export default Api.make(
265 Effect.gen(function* () {
266 const getPhoto = yield* S3.GetObject(Photos);
267 return {
268 fetch: Effect.gen(function* () {
269 const photo = yield* getPhoto({ Key: "cover.jpg" });
270 /* ... */
271 }),
272 };
273 }),
274);
275```
276
277Both produce the same runtime bundle. The Tag form just
278splits identity (the class declaration) from implementation
279(the `.make()` call), so other files can import the Tag
280without pulling in the handler. See
281[Circular references, without the deadlock](/blog/2026-04-25-circular-references)
282for the full story on when each form pays off.
283
284## Composable units of infrastructure
285
286The same shape — Binding.Service required, Binding.Policy
287optional — is what makes pieces of infrastructure composable
288as Effect Layers. A "feature" can be a Layer that brings
289its own Workers, Tables, and bindings, with all the
290internal cross-references already wired. The Stack that
291consumes the feature provides `providers()` and gets the
292IAM right for free; the Workers inside the feature bundle
293exactly their own runtime code, nothing else.
294
295We'll get into that pattern in a separate post. For now,
296the takeaway is: the line you write — `yield* S3.GetObject(Photos)`
297— is a complete specification of what your Worker needs.
298The framework handles the rest, in the right phase, without
299mixing the two phases' code together.
300
301## Where to go next
302
303- [`Binding.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Binding.ts)
304 — the `Binding.Service` / `Binding.Policy` machinery.
305- [`AWS/S3/GetObject.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/S3/GetObject.ts)
306 — a canonical capability: runtime layer + policy layer
307 in one file.
308- [Circular references, without the deadlock](/blog/2026-04-25-circular-references)
309 — the Tag-and-Layer pattern for cyclic and self-referential
310 bindings.