Skip to content

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.

Provide DomainCloudflare.layerDurableObjects() as the platform, and serve your Apis from the Worker.

packages/server/src/BankWorker.ts
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.

alchemy.run.ts
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>() };
}),
);
Terminal window
pnpm alchemy deploy

alchemy dev runs the same Worker and Durable Objects on your machine.

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.

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.

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.

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));

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.

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.

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.

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.