DURABLE OBJECTS ✦ DESIGN
The Subscription DO
Every subscription is a Durable Object. The outcome chain lives in co-located SQLite. State is derived by replay. Transitions are serialized by the runtime. The billing clock runs on alarms.
In the reference implementation, a subscription is an SAO instance living in an in-memory Database. In production, the mapping is direct: one Durable Object per subscription, one SQLite database per DO, one outcome chain per database. The runtime's single-threaded guarantee replaces optimistic locking. Cold start replays the chain. There is no second source of truth.
The state machine, its outcome chain, and the derived value. One per subscription.
Globally unique address. Single-threaded execution. Co-located SQLite. In-memory state between requests. One per subscription.
The SAO instance IS the DO. The outcome chain is SQLite rows. Materialized state is in memory. Cold start is replay. No snapshot table.
The architecture is fully symmetric. Every billing resource is a Durable Object running the same infrastructure: outcomes table, SAO schema, replay on cold start, RPC methods that map to transitions. The schema varies per resource type. The machinery is identical.
active / deleted
active / past_due / unpaid / canceled
draft / open / paid / void / uncollectible
requires_pm / confirmation / processing / succeeded
active / deleted
active / deleted
A base class handles outcomes, replay, projection, and alarm multiplexing. Each resource type provides its SAO schema and applicators. Adding a new resource type means defining a schema and registering applicators.
Each DO's SQLite contains one table. The outcome chain is the database. No snapshot table, no materialized state table, no denormalized columns. On cold start, replay the chain through SAO to reconstruct the current state. A subscription with ten years of monthly billing has about 120 outcomes. Replaying 120 small objects through a state machine is sub-millisecond.
CREATE TABLE outcomes (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
outcome TEXT NOT NULL,
data TEXT,
ts INTEGER NOT NULL
);
A snapshot table is a second source of truth that can drift. Pure replay is simpler to understand, simpler to debug, and fast enough for any realistic chain length. If cold start latency ever shows up in metrics, add a snapshot then.
A subscription needs multiple time-based triggers: period advance, trial expiry, dunning retry, grace period end, scheduled cancellation. A Durable Object gets one alarm. The solution is a schedule table and the earliest-alarm pattern.
Create invoice, attempt payment, advance period.
Transition to active or incomplete_expired.
Retry failed payment on day 1, 3, 5, 7.
Escalate from past_due to unpaid.
Execute cancellation.
CREATE TABLE schedule (
id TEXT PRIMARY KEY,
fire_at INTEGER NOT NULL,
type TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX idx_schedule_fire ON schedule(fire_at);
The alarm handler queries the earliest due event, processes it, deletes it, and reschedules for the next one. When a new event is scheduled, if its fire time is earlier than the current alarm, the alarm is rescheduled. The alarm handler must be idempotent. SAO transition validation is the guard. Replaying an already-applied transition is rejected by the schema before any state is written.
When the period-end alarm fires, the subscription must create an invoice, finalize it, and attempt payment. Invoice creation is fast, a DO-to-DO RPC call. Payment is slow and unreliable, it hits an external gateway. The split: invoice inline, payment via Workflow.
Invoice creation is a pure internal operation with no external calls and no failure modes worth worrying about. Payment is where durability matters. The Workflow retries each step independently, can wait for async events like 3DS authentication, and reports the result back to the Subscription DO which makes the state transition decision.
Direct, typed, fast. Both DOs in the same colo. The right choice for creating and finalizing invoices during the billing cycle.
Durable execution with per-step retry. The right choice for anything that hits an external service. Payment attempts, 3DS flows, webhook waits.
Fire and forget. Eventually consistent. The right choice for projecting state into the read model. Transitions must never fail for read-model reasons.
Each DO's SQLite is private. You cannot query across DOs. But the business needs dashboards, reports, and batch queries. D1 is the shared read model, a materialized projection of every resource's current state. The DO is the write authority. D1 is the read replica.
If a customer's payment succeeds, the subscription must advance. If D1 is having a bad day, that cannot block the billing cycle. Queue decouples this cleanly.
If the D1 write fails, the message goes back on the queue or to the dead letter queue. No custom retry logic in the DO.
A dashboard showing past_due 200ms after the transition is indistinguishable from real-time for any business use case.
Validate transition, append outcome, update in-memory state, enqueue projection. Four steps, all fast, all local except the queue send.
The projection consumer is a simple Worker that receives batches of projection events from the queue and upserts them into D1 in a single batch round trip. Projection events include the outcome sequence number. The consumer only writes if the incoming seq is higher than what is stored, protecting against out-of-order delivery.
If the read drives a mutation or an access decision, go to the DO. If the read drives a display or a report, go to D1. Entitlement checks and payment decisions read from the DO. Dashboards, reports, and dunning cohorts read from D1.
The Subscription DO exposes RPC methods that map directly to SAO transitions. The transition method is the only write path. It validates against the schema, appends the outcome, updates in-memory state, and enqueues the projection. Four steps, always in that order.
class SubscriptionDO extends BaseBillingDO { // Core state machine async transition( action: string, outcomeName: string, data?: Record<string, unknown> ): Promise<TransitionResult>; // Reads async getState(): Promise<SubscriptionState>; async getHistory(): Promise<OutcomeRecord[]>; // Alarm-managed lifecycle async alarm(): Promise<void>; // Callback from Workflow async reportPaymentResult( invoiceId: string, success: boolean ): Promise<void>;}One DO per subscription. One table per DO. Replay on cold start. Alarms drive the billing clock. Workflows handle payment. D1 projects the read model. The state machine is the truth.