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.DurableObjects as the platform, and serve your Apis from the Worker.
import { Account } from "@bank/domain/Account/Account.aggregate";import { AccountSummary } from "@bank/domain/Account/AccountSummary.view";import { Bank, BankHandlers } from "@bank/domain/Bank.model";import { CustomerApi } from "@bank/domain/CustomerApi.api";import { LedgerStore } from "@bank/domain/Ledger/LedgerStore";import { SupportApi } from "@bank/domain/SupportApi.api";import * as Cloudflare from "alchemy/Cloudflare";import * as DomainCloudflare from "alchemy/Domain/Cloudflare";import * as Stripe from "alchemy/Stripe";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, 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.DurableObjects` for `Domain.InMemory` and nothing else changes. */const BankLive = Layer.mergeAll( SessionLive, MfaChallengeLive, Stripe.ConsumeEventsLive, Observability,).pipe( Layer.provideMerge(Bank.layer(BankHandlers)), Layer.provideMerge(D1LedgerStore), Layer.provide( Layer.mergeAll( DomainCloudflare.DurableObjects, SiftFraudCheck, StripePayments, Cloudflare.D1.QueryDatabaseBinding, ), ),);
/** `/api/ledger/<account>`: the ledger is a store the bank owns, so it is served by a plain route. */const LEDGER = /^\/api\/ledger\/([^/]+)$/;
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 ledger = yield* LedgerStore; const summaries = yield* AccountSummary; const ledgerRoute = (accountId: string) => Effect.gen(function* () { const request = yield* HttpServerRequest; const actor = actorOf(request.headers); if (Option.isNone(actor)) return HttpServerResponse.empty({ status: 401 }); const summary = yield* summaries.query(Account.ref(accountId)).pipe(Effect.orDie); const owner = Option.getOrNull(summary)?.customerId; if (actor.value._tag === "Customer" && owner !== actor.value.customerId) { return HttpServerResponse.empty({ status: 404 }); } return yield* HttpServerResponse.json(yield* ledger.forAccount(accountId)); }); // 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: Effect.map(HttpServerRequest, (request) => Option.isSome(actorOf(request.headers)), ), }); // 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; const ledgerPath = LEDGER.exec(path); if (ledgerPath) return yield* ledgerRoute(decodeURIComponent(ledgerPath[1]!)); 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.
Deploy it
Section titled “Deploy it”import BankWorker from "@bank/server/BankWorker";import * as Alchemy from "alchemy";import * as Axiom from "alchemy/Axiom";import * as Cloudflare from "alchemy/Cloudflare";import * as Stripe from "alchemy/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>() }; }),);bun alchemy deploybun alchemy 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.DurableObjectEngine, the same engine you can use for plain Effect workflows. See Effect workflows.
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.