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.

SAO Instance

The state machine, its outcome chain, and the derived value. One per subscription.

Durable Object

Globally unique address. Single-threaded execution. Co-located SQLite. In-memory state between requests. One per subscription.

The mapping

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.

CustomerDO
cus_{id}

active / deleted

SubscriptionDO
sub_{id}

active / past_due / unpaid / canceled

InvoiceDO
inv_{id}

draft / open / paid / void / uncollectible

PaymentDO
pay_{id}

requires_pm / confirmation / processing / succeeded

ProductDO
prod_{id}

active / deleted

PriceDO
price_{id}

active / deleted

BaseBillingDO

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.

sql
CREATE TABLE outcomes (
  seq     INTEGER PRIMARY KEY AUTOINCREMENT,
  action  TEXT NOT NULL,
  outcome TEXT NOT NULL,
  data    TEXT,
  ts      INTEGER NOT NULL
);
Why no snapshot?

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.

Period end
currentPeriodEnd

Create invoice, attempt payment, advance period.

Trial end
trialEnd

Transition to active or incomplete_expired.

Dunning retry
Configurable schedule

Retry failed payment on day 1, 3, 5, 7.

Grace period end
After dunning exhausted

Escalate from past_due to unpaid.

Scheduled cancel
cancelAt

Execute cancellation.

sql
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.

Alarm fires period_end TRIGGER
Subscription DO create invoice finalize kick off workflow ORCHESTRATION
Invoice DO invoice_created invoice_finalized DO-TO-DO RPC
Payment Workflow charge gateway report result DURABLE EXECUTION
Success path advance period schedule next alarm SUBSCRIPTION DO
Failure path past_due schedule dunning retry SUBSCRIPTION DO
Payment Gateway Stripe Braintree EXTERNAL
period_end create and finalize attempt payment charge succeeded failed

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.

DO-to-DO RPC
Subscription to Invoice

Direct, typed, fast. Both DOs in the same colo. The right choice for creating and finalizing invoices during the billing cycle.

Via Workflow
Subscription to Payment Gateway

Durable execution with per-step retry. The right choice for anything that hits an external service. Payment attempts, 3DS flows, webhook waits.

Via Queue
Any DO to D1 projection

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.

Transitions must never fail for read-model reasons

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.

Queue gives retry for free

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.

The latency is fine

A dashboard showing past_due 200ms after the transition is indistinguishable from real-time for any business use case.

It simplifies the DO

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.

The read rule

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.

typescript
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>;}