| 1 | --- |
| 2 | title: Circular references, without the deadlock |
| 3 | date: 2026-04-25 |
| 4 | draft: true |
| 5 | excerpt: Two Workers can call each other. A Lambda's role and the KMS key it uses can grant each other access. Alchemy plans these as graphs with cycles — at both the runtime layer and the type system layer. |
| 6 | --- |
| 7 | |
| 8 | Say you have two services that need to talk to each other. |
| 9 | A web app handles login, an internal service handles |
| 10 | billing, and each one calls the other. |
| 11 | |
| 12 | To deploy them, you need to tell each service where to |
| 13 | find the other. Easy enough if you're deploying by hand — |
| 14 | deploy the first, copy its URL, paste it into the second, |
| 15 | deploy that, copy its URL back into the first. Done. |
| 16 | |
| 17 | But you want this in your infra code, so it's repeatable. |
| 18 | And now you have a problem: which one does the deployer |
| 19 | create first? Whichever you pick, you don't yet have the |
| 20 | other one's URL to give it. |
| 21 | |
| 22 | This is a **circular dependency**, and most infrastructure |
| 23 | tools refuse to handle it. They'll plan your resources in |
| 24 | order, and the moment they detect a cycle they stop and |
| 25 | ask you to break it manually. |
| 26 | |
| 27 | Alchemy doesn't. It plans the cycle in two passes. |
| 28 | |
| 29 | ## The observation that makes it work |
| 30 | |
| 31 | When two resources depend on each other, they almost never |
| 32 | need the *whole* other resource. They need one piece of |
| 33 | identifying information — a URL, an ARN, a name. Something |
| 34 | short and stable that's known the moment the resource |
| 35 | exists, even before it's fully configured. |
| 36 | |
| 37 | That's the wedge. If we can produce identifiers before we |
| 38 | do the rest of the work, we can plan the cycle as two |
| 39 | passes: |
| 40 | |
| 41 | 1. Reserve the identifiers for both resources. |
| 42 | 2. Now that both identifiers exist, finish configuring each |
| 43 | one — including the cross-reference to the other side. |
| 44 | |
| 45 | Alchemy gives providers a lifecycle hook for step 1 called |
| 46 | **`precreate`**, and a regular lifecycle hook for step 2 |
| 47 | called **`reconcile`**. |
| 48 | |
| 49 | ## `precreate` — reserving identifiers before reconcile |
| 50 | |
| 51 | When the planner sees a cycle, it asks each participating |
| 52 | resource: *can you produce your stable identifiers without |
| 53 | needing the other side?* If the provider implements |
| 54 | `precreate`, the answer is yes. |
| 55 | |
| 56 | Here's the Cloudflare `Worker` provider's `precreate` |
| 57 | (abbreviated from [`Worker.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Cloudflare/Workers/Worker.ts)): |
| 58 | |
| 59 | ```typescript |
| 60 | precreate: Effect.fn(function* ({ id, news, session }) { |
| 61 | const name = yield* createWorkerName(id, news.name); |
| 62 | |
| 63 | // Upload a placeholder script. Just enough to reserve the |
| 64 | // worker name and any Durable Object class IDs the cycle |
| 65 | // might reference. |
| 66 | yield* putScript({ |
| 67 | accountId, |
| 68 | scriptName: name, |
| 69 | metadata: { mainModule: "main.js", /* ...DO stubs... */ }, |
| 70 | files: [new File([placeholderScript], "main.js")], |
| 71 | }); |
| 72 | |
| 73 | return { |
| 74 | workerId: name, |
| 75 | workerName: name, |
| 76 | url: undefined, // not yet — filled in by reconcile |
| 77 | durableObjectNamespaces, |
| 78 | // ... |
| 79 | } satisfies Worker["Attributes"]; |
| 80 | }), |
| 81 | ``` |
| 82 | |
| 83 | The placeholder script is a no-op. It returns |
| 84 | `"Alchemy worker is being deployed..."`. That's fine — its |
| 85 | job isn't to handle traffic, it's to reserve a script name |
| 86 | and (if there are Durable Objects) generate stable namespace |
| 87 | IDs that other resources can refer to. |
| 88 | |
| 89 | In alchemy v1 this was an explicit `WorkerStub` resource you |
| 90 | had to declare in your stack. In v2 it's invisible — the |
| 91 | planner detects the cycle, calls `precreate` on the |
| 92 | participating resources first, and then runs `reconcile` on |
| 93 | all of them with the cross-references resolved. |
| 94 | |
| 95 | For [`AWS/S3/Bucket.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/S3/Bucket.ts) |
| 96 | the `precreate` is even shorter — it just calls |
| 97 | `ensureBucketExists`, since for the BucketPolicy → Lambda |
| 98 | cycle all the other side needs is the bucket ARN, which is |
| 99 | known the moment the bucket exists. |
| 100 | |
| 101 | If a provider *doesn't* implement `precreate`, the planner |
| 102 | still works — it just can't participate in a cycle. You'd |
| 103 | get the same DAG-only behavior as Terraform. `precreate` is |
| 104 | an opt-in for the resources where the cycle pattern matters. |
| 105 | |
| 106 | ## The type-system problem |
| 107 | |
| 108 | Engine support is half the battle. The other half is |
| 109 | authoring code that *expresses* the cycle without the |
| 110 | TypeScript compiler exploding. |
| 111 | |
| 112 | The naive thing to write looks like this: |
| 113 | |
| 114 | ```typescript |
| 115 | // src/A.ts |
| 116 | import { B } from "./B.ts"; |
| 117 | |
| 118 | export const A = Cloudflare.Worker("A", { main: import.meta.filename }, |
| 119 | Effect.gen(function* () { |
| 120 | const b = yield* Cloudflare.Worker.bind(B); |
| 121 | return { fetch: ... }; |
| 122 | }), |
| 123 | ); |
| 124 | |
| 125 | // src/B.ts |
| 126 | import { A } from "./A.ts"; |
| 127 | |
| 128 | export const B = Cloudflare.Worker("B", { main: import.meta.filename }, |
| 129 | Effect.gen(function* () { |
| 130 | const a = yield* Cloudflare.Worker.bind(A); |
| 131 | return { fetch: ... }; |
| 132 | }), |
| 133 | ); |
| 134 | ``` |
| 135 | |
| 136 | Three things go wrong: |
| 137 | |
| 138 | 1. **Bundle bloat.** Each Worker's `import` pulls in the |
| 139 | other Worker's entire implementation. When you bundle `A` |
| 140 | for deployment, you also bundle `B`'s handler code — and |
| 141 | vice versa. The Cloudflare Worker that's supposed to be |
| 142 | small now contains both implementations. |
| 143 | |
| 144 | 2. **Circular type inference.** TypeScript needs `B`'s type |
| 145 | to type-check `A`, but `B`'s type depends on `A`'s type |
| 146 | (because the Effect inside `B` references `A`). The |
| 147 | compiler bails with: |
| 148 | |
| 149 | > `'A' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.ts(7023)` |
| 150 | |
| 151 | 3. **Runtime initialization order.** Even if the types |
| 152 | resolved, the module loader has to execute *something* |
| 153 | first. Whichever module loads first sees `undefined` on |
| 154 | the other side. |
| 155 | |
| 156 | ## Tag-based design |
| 157 | |
| 158 | The fix is to separate **identity** from **implementation**. |
| 159 | The class is the Tag — it carries the type, the resource ID, |
| 160 | the binding contract — and it's cheap to import. The |
| 161 | implementation is attached via a separate `.make()` call |
| 162 | that runs only when the Stack provides it. |
| 163 | |
| 164 | ```typescript |
| 165 | // src/A.ts — just the Tag |
| 166 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 167 | |
| 168 | export class A extends Cloudflare.Worker<A>()("A", { |
| 169 | main: import.meta.filename, |
| 170 | }) {} |
| 171 | ``` |
| 172 | |
| 173 | That's the whole identity declaration. Importing `A` from |
| 174 | another file gets you the type and the resource ID. It does |
| 175 | *not* pull in any handler code — there is no handler code |
| 176 | yet. The class is the Tag. |
| 177 | |
| 178 | The runtime piece is a second file-level export: |
| 179 | |
| 180 | ```diff lang="typescript" |
| 181 | // src/A.ts |
| 182 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 183 | + import * as Effect from "effect/Effect"; |
| 184 | + import { B } from "./B.ts"; |
| 185 | |
| 186 | export class A extends Cloudflare.Worker<A>()("A", { |
| 187 | main: import.meta.filename, |
| 188 | }) {} |
| 189 | |
| 190 | + export default A.make( |
| 191 | + Effect.gen(function* () { |
| 192 | + const b = yield* Cloudflare.Worker.bind(B); |
| 193 | + return { |
| 194 | + fetch: Effect.gen(function* () { |
| 195 | + return yield* b.fetch(new Request("https://b/work")); |
| 196 | + }), |
| 197 | + }; |
| 198 | + }), |
| 199 | + ); |
| 200 | ``` |
| 201 | |
| 202 | The `import { B }` here imports B's Tag only — its identity |
| 203 | and binding contract. `Worker.bind(B)` returns a typed |
| 204 | callable; at plan time the URL is an `Output<URL>` |
| 205 | placeholder, at runtime it's a real `fetch` stub. |
| 206 | |
| 207 | `B.ts` mirrors the pattern: imports `A`'s Tag, binds it |
| 208 | inside its own `.make()`. Each file's `Effect.gen` references |
| 209 | the *other* class as a Tag, not as a value that has to be |
| 210 | initialized. The bundler sees the Tag import as type-only |
| 211 | and tree-shakes it from the runtime bundle. The TypeScript |
| 212 | compiler can resolve the types because each Tag is a single |
| 213 | class declaration with no dependencies on the other side's |
| 214 | implementation. |
| 215 | |
| 216 | ### Why `.make()` and not the inline form |
| 217 | |
| 218 | For the non-cyclic case Alchemy lets you write the resource |
| 219 | and its implementation in a single expression: |
| 220 | |
| 221 | ```typescript |
| 222 | export default Cloudflare.Worker("MyWorker", { main: import.meta.filename }, |
| 223 | Effect.gen(function* () { /* ... */ }), |
| 224 | ); |
| 225 | ``` |
| 226 | |
| 227 | This is the convenient default — one file, one expression, |
| 228 | type inference flows in one direction. But the moment the |
| 229 | generator references *itself*'s tag (`yield* MyWorker`), TS |
| 230 | trips on the self-reference. The class-form-plus-`.make()` |
| 231 | splits the declaration from the inferred body, which is |
| 232 | what unblocks both self-references and cross-references. |
| 233 | |
| 234 | The rule of thumb: use the inline form for DAG-shaped |
| 235 | resources, use the class form when something else |
| 236 | references the resource before its implementation is in |
| 237 | scope — including the resource itself. |
| 238 | |
| 239 | ## How the two pieces compose |
| 240 | |
| 241 | At plan time, the engine builds a dependency graph from the |
| 242 | Stack expression. When it sees a cycle, it routes the |
| 243 | participating resources through a two-pass lifecycle: |
| 244 | |
| 245 | ``` |
| 246 | 1. precreate — reserve identifiers (placeholder Worker scripts, |
| 247 | empty Buckets, KMS keys without policies). |
| 248 | Cross-references now have valid Output<…> values. |
| 249 | 2. reconcile — every resource runs with the resolved bindings. |
| 250 | For Worker A this means a real putScript with |
| 251 | the binding to B's reserved URL; for the KMS |
| 252 | key, the full resource policy referencing the |
| 253 | role's ARN. |
| 254 | ``` |
| 255 | |
| 256 | The Tag/Layer split is what makes step 2 *authorable* — the |
| 257 | generator inside `A.make(...)` can `yield* Worker.bind(B)` |
| 258 | without anyone needing to evaluate `B`'s `.make()` body. The |
| 259 | `precreate` hook is what makes step 2 *executable* — by the |
| 260 | time the generator runs, `B` already has a reserved URL. |
| 261 | |
| 262 | You need both layers. Engine support without the Tag/Layer |
| 263 | split leaves you writing un-bundle-able, un-type-checkable |
| 264 | code. The Tag/Layer split without engine support leaves you |
| 265 | with code that type-checks but deadlocks at deploy time. |
| 266 | |
| 267 | ## When you don't need any of this |
| 268 | |
| 269 | If your services form a DAG, write them inline. The |
| 270 | machinery exists for the cases where the natural shape of |
| 271 | your system has cycles — bidirectional resource policies, |
| 272 | mutually-recursive services, queue-mediated callbacks. For |
| 273 | everything else, the simple form is still simple: |
| 274 | |
| 275 | ```typescript |
| 276 | export default Cloudflare.Worker("Web", { main: import.meta.filename }, |
| 277 | Effect.gen(function* () { |
| 278 | const db = yield* Database; |
| 279 | return { fetch: /* ... */ }; |
| 280 | }), |
| 281 | ); |
| 282 | ``` |
| 283 | |
| 284 | The interesting design choice is that the *engine* |
| 285 | capability — `precreate` and cycle-aware planning — is |
| 286 | opt-in per provider, not part of the framework's core |
| 287 | contract. Most providers don't need it. The ones that do |
| 288 | (Cloudflare `Worker`, AWS `Bucket`, KMS `Key`, IAM `Role`, |
| 289 | Lambda `Function`) implement it explicitly, and the planner |
| 290 | only invokes it when a cycle is actually present. |
| 291 | |
| 292 | ## Where to go next |
| 293 | |
| 294 | - [Guides › Circular Bindings](/infrastructure-as-effects/circular-bindings) — |
| 295 | the step-by-step Worker A ↔ Worker B walkthrough. |
| 296 | - [`Cloudflare/Workers/Worker.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Cloudflare/Workers/Worker.ts) |
| 297 | — the canonical `precreate` reference, including Durable |
| 298 | Object namespace reservation. |
| 299 | - [`AWS/S3/Bucket.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/S3/Bucket.ts) |
| 300 | — `precreate` for the BucketPolicy ↔ Lambda Role cycle. |