| 1 | --- |
| 2 | title: Bindings — one line, two phases |
| 3 | date: 2026-04-30 |
| 4 | draft: true |
| 5 | excerpt: 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 | |
| 8 | You're writing a Cloudflare Worker (or an AWS Lambda — same |
| 9 | story). It needs to read an object from an S3 bucket. In |
| 10 | Alchemy it looks like this: |
| 11 | |
| 12 | ```typescript |
| 13 | Effect.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 | |
| 24 | The shape is "bind and use" — the standard |
| 25 | [`Layer`](https://effect.website/docs/requirements-management/layers/) |
| 26 | constructor pattern from Effect. `yield*` your dependencies |
| 27 | at the top, then return a `fetch` Effect that uses them. |
| 28 | Bind runs once; `fetch` runs per request. |
| 29 | |
| 30 | Focus on the `S3.GetObject(Photos)` call. Two things |
| 31 | have 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 | |
| 40 | These are very different jobs. The first is infrastructure |
| 41 | authoring — IAM policy documents, role attachments, deploy |
| 42 | orchestration. The second is application code — SDK calls, |
| 43 | retries, response parsing. Most frameworks make you write |
| 44 | them in two separate places: the IAM in your IaC config, |
| 45 | the SDK call in your handler. You're responsible for |
| 46 | keeping the two in sync. |
| 47 | |
| 48 | Alchemy lets you write *one* line for both, then arranges |
| 49 | for each half to run in the right phase — and crucially, |
| 50 | arranges for the *deploy-side* half to never end up in your |
| 51 | runtime bundle. |
| 52 | |
| 53 | ## Capabilities have two halves |
| 54 | |
| 55 | Every capability in Alchemy — `S3.GetObject`, `S3.PutObject`, |
| 56 | `SQS.SendMessage`, `DynamoDB.GetItem`, and so on — is |
| 57 | implemented as a pair of Effect Services: |
| 58 | |
| 59 | ```typescript |
| 60 | // AWS/S3/GetObject.ts |
| 61 | export class GetObject extends Binding.Service<GetObject, ...>() |
| 62 | ("AWS.S3.GetObject") {} |
| 63 | |
| 64 | export class GetObjectPolicy extends Binding.Policy<GetObjectPolicy, ...>() |
| 65 | ("AWS.S3.GetObject") {} |
| 66 | ``` |
| 67 | |
| 68 | The `Binding.Service` is the runtime half — an Effect |
| 69 | service whose `Live` layer wraps the actual SDK client. |
| 70 | When you `yield*` it, you get back a typed callable that |
| 71 | takes a bucket and a request and gives you back the |
| 72 | response. |
| 73 | |
| 74 | The `Binding.Policy` is the deploy-time half — an Effect |
| 75 | service whose `Live` layer doesn't do any I/O at all. It |
| 76 | takes the bucket your handler referenced and records a |
| 77 | policy statement onto the calling Worker's role: |
| 78 | |
| 79 | ```typescript |
| 80 | yield* 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 | |
| 89 | Two services, one capability name (`AWS.S3.GetObject`). One |
| 90 | gets bundled into your Worker; one runs only during plan. |
| 91 | |
| 92 | ## The TypeScript trick |
| 93 | |
| 94 | The interesting bit is how Alchemy distinguishes "this |
| 95 | service must be provided" from "this service is allowed to |
| 96 | be absent." |
| 97 | |
| 98 | When you `yield* S3.GetObject(Photos)` inside a Worker, |
| 99 | the Effect's `R` channel picks up `GetObject` (the runtime |
| 100 | service) as a hard requirement. If you forget to attach the |
| 101 | `GetObjectLive` layer to the Worker, the compiler tells you: |
| 102 | |
| 103 | ``` |
| 104 | Argument of type 'Effect<…, …, GetObject>' is not assignable to |
| 105 | parameter of type 'Effect<…, …, never>'. |
| 106 | ``` |
| 107 | |
| 108 | `GetObjectPolicy` (the deploy-time service) is also |
| 109 | referenced by the same line. But it doesn't show up in `R`, |
| 110 | because the framework resolves it via |
| 111 | `Effect.serviceOption` — it's *optional* at the type level. |
| 112 | |
| 113 | The 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 | |
| 122 | The runtime layer must be provided or TypeScript breaks |
| 123 | your build. The policy layer is allowed to be missing — |
| 124 | and at runtime, it is. The same `S3.GetObject(...)` |
| 125 | expression is a real SDK-client construction at runtime and |
| 126 | a no-op at runtime *for the policy half*. |
| 127 | |
| 128 | ## What happens if the policy *is* needed but missing |
| 129 | |
| 130 | If you forget to provide `GetObjectPolicyLive` at deploy |
| 131 | time, you don't want a silent no-op — you want a loud |
| 132 | failure that tells you the role won't get the right IAM. |
| 133 | |
| 134 | Alchemy checks the deploy phase explicitly. From |
| 135 | `Binding.ts`: |
| 136 | |
| 137 | ```typescript |
| 138 | service |
| 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 | |
| 151 | In plan phase + with a Stack in scope, missing the policy |
| 152 | layer dies with a clear message. Outside plan (e.g. inside |
| 153 | the deployed Worker), it falls through to `Effect.void` and |
| 154 | is invisible. Same expression, different behavior, scoped |
| 155 | to the phase that needs each half. |
| 156 | |
| 157 | ## The Stack is where policies live |
| 158 | |
| 159 | A `Cloudflare.Worker` (or `Lambda.Function`) lets you |
| 160 | provide the runtime layers it needs *on the Worker itself*: |
| 161 | |
| 162 | ```typescript |
| 163 | const Photos = yield* Cloudflare.R2.Bucket("Photos"); |
| 164 | const 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 |
| 174 | Worker. Nothing else here mentions IAM. |
| 175 | |
| 176 | The Stack is where the *Policy* layers live: |
| 177 | |
| 178 | ```typescript |
| 179 | export 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 |
| 193 | Stack runs your generator in plan phase with those |
| 194 | provided. When the planner walks into your Worker's |
| 195 | generator and reaches `S3.GetObject(Photos)`, the |
| 196 | policy lookup succeeds, the role gets the right statement, |
| 197 | the deploy proceeds. |
| 198 | |
| 199 | Then the planner walks back out. Your Worker bundle is |
| 200 | written. The bundle has `GetObjectLive` (runtime SDK) but |
| 201 | *not* `GetObjectPolicyLive` (IAM logic) — because the |
| 202 | Worker's `R` never required it. |
| 203 | |
| 204 | ## What ends up in your bundle |
| 205 | |
| 206 | The mental model is: **the Worker bundle is whatever its |
| 207 | Effect's `R` channel demands, and nothing else.** |
| 208 | |
| 209 | In 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 | |
| 219 | Not 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 | |
| 231 | The tree-shake property falls out of the type system. You |
| 232 | didn't have to opt in; you'd have to opt *out* to leak |
| 233 | deploy-time code into a runtime bundle, by explicitly |
| 234 | providing the Policy layer where it isn't needed. |
| 235 | |
| 236 | ## Inline and Tag forms produce the same bundle |
| 237 | |
| 238 | There are two ways to author a Worker. The inline form, for |
| 239 | when nothing else references this Worker: |
| 240 | |
| 241 | ```typescript |
| 242 | export 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 | |
| 255 | The Tag-and-Layer form, for when this Worker has to be |
| 256 | referenced before its implementation is in scope (a cycle, |
| 257 | self-reference, or just a Worker exported across files): |
| 258 | |
| 259 | ```typescript |
| 260 | export class Api extends Cloudflare.Worker<Api>()("Api", { |
| 261 | main: import.meta.filename, |
| 262 | }) {} |
| 263 | |
| 264 | export 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 | |
| 277 | Both produce the same runtime bundle. The Tag form just |
| 278 | splits identity (the class declaration) from implementation |
| 279 | (the `.make()` call), so other files can import the Tag |
| 280 | without pulling in the handler. See |
| 281 | [Circular references, without the deadlock](/blog/2026-04-25-circular-references) |
| 282 | for the full story on when each form pays off. |
| 283 | |
| 284 | ## Composable units of infrastructure |
| 285 | |
| 286 | The same shape — Binding.Service required, Binding.Policy |
| 287 | optional — is what makes pieces of infrastructure composable |
| 288 | as Effect Layers. A "feature" can be a Layer that brings |
| 289 | its own Workers, Tables, and bindings, with all the |
| 290 | internal cross-references already wired. The Stack that |
| 291 | consumes the feature provides `providers()` and gets the |
| 292 | IAM right for free; the Workers inside the feature bundle |
| 293 | exactly their own runtime code, nothing else. |
| 294 | |
| 295 | We'll get into that pattern in a separate post. For now, |
| 296 | the takeaway is: the line you write — `yield* S3.GetObject(Photos)` |
| 297 | — is a complete specification of what your Worker needs. |
| 298 | The framework handles the rest, in the right phase, without |
| 299 | mixing 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. |