Cloudflare Durable Objects
Host your model on Cloudflare, and every aggregate instance, view key and policy gets its own Durable Object. You declare none of them yourself.
Host the model in a Worker
Section titled “Host the model in a Worker”Provide DomainCloudflare.layerDurableObjects() as the platform, and serve your Apis from the Worker.
import * as Cloudflare from "@alchemy.run/cloudflare";import * as DomainCloudflare from "@alchemy.run/cloudflare/Domain";import * as Stripe from "@alchemy.run/stripe";import { Bank, BankHandlers } from "@bank/domain/Bank.model";import { CustomerApi } from "@bank/domain/CustomerApi.api";import { SupportApi } from "@bank/domain/SupportApi.api";import * as Telemetry from "alchemy/Telemetry";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import { SiftFraudCheck } from "./Fraud/SiftFraudCheck.ts";import { D1LedgerStore } from "./Ledger/D1LedgerStore.ts";import { MfaChallengeLive } from "./MfaChallenge.ts";import { Observability } from "./Observability.ts";import { actorOf, SessionKey, SessionKeyLive, SessionLive, sessionRoute } from "./Session.ts";import { StripePayments } from "./Settlement/StripePayments.ts";import { StripeSettlements } from "./Settlement/StripeSettlements.ts";
/** * Everything the Worker serves: the middleware both Apis declare, and the * Bank hosted on Durable Objects, with the ledger in D1. Swap * `DomainCloudflare.layerDurableObjects()` for `Domain.InMemory` and nothing else changes. */const BankLive = Layer.mergeAll( SessionLive, MfaChallengeLive, Stripe.ConsumeEventsLive, Observability,).pipe( Layer.provideMerge(SessionKeyLive), Layer.provideMerge(Bank.layer(BankHandlers)), Layer.provideMerge(D1LedgerStore), Layer.provide( Layer.mergeAll( DomainCloudflare.layerDurableObjects(), SiftFraudCheck, StripePayments, Cloudflare.D1.QueryDatabaseBinding, ), ),);
export default class BankWorker extends Cloudflare.Worker<BankWorker>()( "BankWorker", { main: import.meta.url }, Effect.gen(function* () { const customerApi = yield* CustomerApi; const supportApi = yield* SupportApi; const sessionKey = yield* SessionKey; const signedIn = (request: HttpServerRequest) => actorOf(request.headers).pipe(Effect.provideService(SessionKey, sessionKey)); // The app's own telemetry, collected here and forwarded with the bank's // credentials, so no ingest token reaches a browser. Signed-in users only. const telemetry = Telemetry.relay({ authorize: HttpServerRequest.pipe(Effect.flatMap(signedIn), Effect.map(Option.isSome)), }); // Claims POST /webhooks/stripe and provisions the Stripe webhook endpoint. yield* StripeSettlements; return { // The foldkit app reaches /api through its own Worker, on its own origin, // so the session cookie needs no cross-origin allowances. fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const path = new URL(request.url, "http://localhost").pathname; if (path === "/api/session") { return yield* sessionRoute.pipe(Effect.provideService(SessionKey, sessionKey)); } if (path.startsWith("/api/telemetry/")) return yield* telemetry; if (path.startsWith("/api")) return yield* customerApi.fetch; if (path.startsWith("/support")) return yield* supportApi.fetch; return HttpServerResponse.text("Not Found", { status: 404 }); }), }; }).pipe(Effect.provide(BankLive)),) {}Each aggregate, view and policy becomes a Durable Object class on this Worker while it starts, so there are no bindings or classes to write. A query has no key, so no object owns it: it runs in the Worker, where the store it reads (here, D1) is bound.
Deploy it
Section titled “Deploy it”import BankWorker from "@bank/server/BankWorker";import * as Alchemy from "alchemy";import * as Axiom from "@alchemy.run/axiom";import * as Cloudflare from "@alchemy.run/cloudflare";import * as Stripe from "@alchemy.run/stripe";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "CloudflareDomainExample", { providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers(), Axiom.providers()), state: Alchemy.localState(), }, Effect.gen(function* () { const bank = yield* BankWorker; // The foldkit app, built with Vite. Its Worker serves the build and // forwards /api to the bank, so the app and the Api share one origin. const app = yield* Cloudflare.Website.Foldkit("BankApp", { rootDir: "packages/web", main: "src/worker.ts", env: { BANK: bank }, assets: { runWorkerFirst: ["/api", "/api/*"] }, }); return { url: bank.url.as<string>(), app: app.url.as<string>() }; }),);pnpm alchemy deploybun alchemy deploynpx alchemy deployyarn alchemy deployalchemy dev runs the same Worker and Durable Objects on your machine.
Know what runs where
Section titled “Know what runs where”| Your model | On Cloudflare |
|---|---|
An aggregate instance, like account a-1 |
One Durable Object, named by its id |
A view key, like the summary of a-1 |
One Durable Object per view and key |
| A policy | One Durable Object per policy and key (by default, per source instance) |
| A projection | One Durable Object per projection and key, with the key’s input log |
A stream key, like the card of a-1 |
One Durable Object per stream and key |
A saga run, like transfer t-1 |
One Durable Object per saga and run, holding its steps’ results |
Sagas run on Cloudflare.Workflows.layerDurableObjectEngine(), the same engine you can use for plain Effect workflows. See Effect workflows.
Keep the model in a jurisdiction
Section titled “Keep the model in a jurisdiction”Pass a jurisdiction to DomainCloudflare.layerDurableObjects in BankLive to create and store every Durable Object of the model, saga runs included, only inside a jurisdiction, for example the EU for personal data:
Layer.provide( Layer.mergeAll( DomainCloudflare.layerDurableObjects({ jurisdiction: "eu" }), SiftFraudCheck, StripePayments, Cloudflare.D1.QueryDatabaseBinding, ), ),A Durable Object in a jurisdiction is a different object from the one with the same name outside it. A model that already has data starts empty when you add a jurisdiction, and its old objects stay where they were. Other resources the Worker uses, like the ledger’s D1 database, take their own jurisdiction. alchemy dev doesn’t emulate jurisdictions: local workerd runs the model unrestricted.
Host several models in one Worker
Section titled “Host several models in one Worker”Each model’s Durable Object classes are named after the model: Billing.AccountAggregate and Audit.AccountAggregate when both list Account, and Billing.OpenAccountWorkflow for a saga. Their data never mixes, so several models can share one Worker and one layerDurableObjects(). Two different models with the same name in one Worker fail its deploy.
Renaming a model renames its classes, and the model starts empty under the new names.
Upgrade without losing data
Section titled “Upgrade without losing data”Each Worker records the Domain version it was deployed with. When a later Alchemy release lays models out differently, the next deploy moves the deployed data to the new layout: it renames classes in place, so every aggregate, view row and running saga carries over.
Models deployed before versions existed named classes after the definition alone, like AccountAggregate. If two models in one Worker listed the same aggregate, they shared that class and its data, and only one can keep it. The deploy fails until you choose:
import * as DomainCloudflare from "@alchemy.run/cloudflare/Domain";import * as Layer from "effect/Layer";import { Audit, AuditHandlers } from "./Audit.ts";import { Billing, BillingHandlers } from "./Billing.ts";
const Platform = DomainCloudflare.layerDurableObjects();// Billing keeps the accounts both models wrote; Audit starts empty.export const BillingLive = Billing.layer(BillingHandlers, { legacyData: true }).pipe( Layer.provide(Platform),);export const AuditLive = Audit.layer(AuditHandlers).pipe(Layer.provide(Platform));Count on these guarantees
Section titled “Count on these guarantees”Commands to one aggregate instance run one at a time. Its events, new state and reply commit together, or not at all.
Every event reaches the views, projections and policies that consume it, at least once and in order for each source, even if a Durable Object restarts in between. A projection key whose handler fails keeps its inputs and retries them. Scheduled commands fire when they’re due, even after a restart.
Watch without polling
Section titled “Watch without polling”With Domain.protocolWebSocket, each watch or events read is a WebSocket to the Durable Object that owns the key. Every change is pushed down it as it commits. Between changes the object hibernates, so a quiet watch doesn’t keep it running.
get, list and commands go over plain HTTP. See Api.
Deploy without dropping requests
Section titled “Deploy without dropping requests”During a deploy, some Durable Objects still run the previous version for a moment. Calls between them are retried for about a minute until the new version answers, and a client’s live read reconnects and carries on from its last change.
Add consumers to a running Domain
Section titled “Add consumers to a running Domain”A deploy that adds a view or a projection, or bumps a version, fills it from the history before it finishes. See History at scale.