Skip to content

Patterns

Common patterns for building with z0.


When: Creating domain-specific entities with custom behavior.

import { EntityLedger, Fact, type EntityLedgerEnv } from '@z0-app/sdk';
export class AccountLedger extends EntityLedger<EntityLedgerEnv> {
// 1. Define cached state getters
getBalance(): number {
const cached = this.getCachedState<{ balance: number }>('balance');
if (cached) return cached.balance;
return this.recomputeBalance();
}
// 2. Implement state derivation from Facts
private recomputeBalance(): number {
const facts = this.getFacts({ type: ['deposit', 'withdrawal'] });
const balance = facts.reduce((sum, fact) => {
if (fact.type === 'deposit') return sum + (fact.data.amount as number);
if (fact.type === 'withdrawal') return sum - (fact.data.amount as number);
return sum;
}, 0);
this.setCachedState('balance', { balance }, facts[facts.length - 1]?.id);
return balance;
}
// 3. Override updateCachedState hook
protected async updateCachedState(fact: Fact): Promise<void> {
if (fact.type === 'deposit' || fact.type === 'withdrawal') {
this.recomputeBalance();
}
}
// 4. Add domain-specific methods
async deposit(amount: number): Promise<Fact> {
return this.appendFact({
type: 'deposit',
subtype: 'manual',
data: { amount }
});
}
// 5. Add custom HTTP endpoints
override async fetch(request: Request): Promise<Response> {
await this.ensureInitialized();
const url = new URL(request.url);
if (url.pathname === '/balance' && request.method === 'GET') {
return Response.json({ balance: this.getBalance() });
}
return super.fetch(request);
}
}

When: Defining fact types for your domain.

// Good: Use type + subtype
{ type: 'payment', subtype: 'completed', data: { amount: 100 } }
{ type: 'payment', subtype: 'failed', data: { reason: 'insufficient_funds' } }
{ type: 'payment', subtype: 'refunded', data: { original_payment_id: 'pay_xyz' } }
// Bad: Single-level, no taxonomy
{ type: 'payment_completed', data: { ... } }
{ type: 'payment_failed', data: { ... } }
// All payments
const payments = ledger.getFacts({ type: 'payment' });
// Only completed payments
const completed = payments.filter(f => f.subtype === 'completed');
// Multiple types
const financial = ledger.getFacts({ type: ['payment', 'refund', 'chargeback'] });

When: Derived state depends on specific fact types.

class OrderLedger extends EntityLedger {
protected async updateCachedState(fact: Fact): Promise<void> {
// Invalidate total when items change
if (fact.type === 'order_item_added' || fact.type === 'order_item_removed') {
this.deleteCachedState('order_total');
}
// Invalidate status when state changes
if (fact.type === 'order_status_changed') {
this.deleteCachedState('order_status');
}
}
getTotal(): number {
const cached = this.getCachedState<{ total: number }>('order_total');
if (cached) return cached.total;
const items = this.getFacts({ type: 'order_item_added' });
const total = items.reduce((sum, f) => sum + (f.data.price as number), 0);
this.setCachedState('order_total', { total }, items[items.length - 1]?.id);
return total;
}
}

Pattern 4: Using LedgerClient (Typed Wrapper)

Section titled “Pattern 4: Using LedgerClient (Typed Wrapper)”

When: Calling Durable Objects from Workers.

import { LedgerClient } from '@z0-app/sdk';
export default {
async fetch(request: Request, env: Env) {
const client = new LedgerClient(env.ACCOUNT_LEDGER, 'tenant_123');
// Get entity
const entity = await client.get('acct_abc');
// Append fact
await client.emit('acct_abc', 'deposit', { amount: 100 });
// Get stub for custom methods
const stub = client.stub('acct_abc');
const response = await stub.fetch(new Request('http://fake/balance'));
const { balance } = await response.json();
return Response.json({ balance });
}
};

When: Building organization trees, account structures.

class OrganizationLedger extends EntityLedger {
async createTeam(teamId: string, teamData: Record<string, unknown>): Promise<void> {
const client = new LedgerClient(this.env.TEAM_LEDGER);
// Create child entity with parent_id
await client.stub(teamId).upsertEntity({
id: teamId,
type: 'team',
tenant_id: this.entity!.tenant_id,
parent_id: this.entity!.id, // This org is the parent
data: teamData
});
// Record fact on parent
await this.appendFact({
type: 'team_created',
data: { team_id: teamId }
});
}
}

Pattern 6: Config with Time-Based Activation

Section titled “Pattern 6: Config with Time-Based Activation”

When: Pricing changes, scheduled feature rollouts.

class SubscriptionLedger extends EntityLedger {
async getPricing(): Promise<Config> {
const now = Date.now();
// Get active config as of now
const configs = this.getConfigs({ type: 'pricing' });
const active = configs
.filter(c => c.effective_at <= now && (!c.superseded_at || c.superseded_at > now))
.sort((a, b) => b.version - a.version)[0];
return active;
}
async schedulePricingChange(newPricing: Record<string, unknown>, effectiveAt: number): Promise<void> {
await this.upsertConfig({
id: 'pricing_standard',
type: 'pricing',
category: 'subscription',
name: 'Standard Plan',
applies_to: 'subscription',
scope: 'tenant',
settings: newPricing,
effective_at: effectiveAt
});
}
}

Pattern 7: Real-Time Subscriptions (0.8.0+)

Section titled “Pattern 7: Real-Time Subscriptions (0.8.0+)”

When: Broadcasting entity changes to connected clients.

class DashboardLedger extends EntityLedger {
override initializeRoutes(): void {
super.initializeRoutes();
this.router.get('/ws', async (req) => {
if (req.headers.get('upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 400 });
}
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
});
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const data = JSON.parse(message as string);
if (data.type === 'subscribe') {
for (const channel of data.channels) {
this.subscriptionManager.subscribe(ws, channel);
}
}
}
override async afterFactAppended(fact: Fact, context: FactContext): Promise<void> {
if (context.isReplay || context.isImport) return;
// Broadcast to subscribers
this.subscriptionManager.broadcast('facts', {
type: 'fact_appended',
fact
});
}
}
this.router.get('/sse', async (req) => {
const url = new URL(req.url);
const channels = url.searchParams.get('channels')?.split(',') || ['facts'];
const stream = new ReadableStream({
start: (controller) => {
const connId = generateId('conn');
// Subscribe to channels
for (const channel of channels) {
this.subscriptionManager.subscribe(connId, channel);
}
// Send initial event
controller.enqueue(new TextEncoder().encode('event: connected\ndata: {}\n\n'));
},
cancel: () => {
// Cleanup on disconnect
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' }
});
});

When: Enforcing fact structure with TypeScript or Zod.

import { schemaBuilders } from '@z0-app/sdk';
const Account = schemaBuilders.entity('account')
.field('status', 'string').required()
.field('balance', 'number')
.fact('deposit', { amount: 'number', currency: 'string' })
.fact('withdrawal', { amount: 'number', currency: 'string' })
.build();
// TypeScript inference
type AccountEntity = InferEntityFields<typeof Account>;
type DepositData = InferFactData<typeof Account, 'deposit'>;

When: Preventing duplicate fact creation from retries.

await ledger.appendFact({
type: 'payment',
subtype: 'completed',
external_source: 'stripe',
external_id: 'evt_1234567890', // Stripe event ID
data: { amount: 100 }
});
// Retry with same external_id is a no-op
await ledger.appendFact({
type: 'payment',
subtype: 'completed',
external_source: 'stripe',
external_id: 'evt_1234567890', // Same ID
data: { amount: 100 }
});
// Returns existing fact, doesn't create duplicate

When: Processing many entities with resumability.

import { BatchExecutor } from '@z0-app/sdk';
const executor = new BatchExecutor({
batchSize: 100,
onProgress: (progress) => {
console.log(`Processed ${progress.processed}/${progress.total}`);
}
});
const result = await executor.execute(
entityIds,
async (entityId) => {
const stub = client.stub(entityId);
await stub.fetch(new Request('http://fake/process'));
}
);

Pattern 11: Single Source of Truth for Facts

Section titled “Pattern 11: Single Source of Truth for Facts”

When: Designing entity hierarchies (parent-child relationships).

The Rule: Each fact belongs to exactly ONE ledger. Never duplicate facts up the hierarchy.

// ✅ CORRECT: Facts stay in their owning ledger
// Session facts live in SessionLedger
class SessionLedger extends EntityLedger {
async recordPageView(url: string): Promise<Fact> {
return this.appendFact({
type: 'page_view',
data: { url, timestamp: Date.now() }
});
}
}
// Query child DOs directly - parent tracks child IDs
class WebsiteLedger extends EntityLedger {
async getRecentPageViews(limit: number = 100): Promise<Fact[]> {
const children = this.getCachedState<{ session_ids: string[] }>('children');
if (!children) return [];
const allFacts: Fact[] = [];
// Query recent sessions (not all 10,000)
for (const sessionId of children.session_ids.slice(-10)) {
const stub = this.env.SESSION_LEDGER.get(this.id(sessionId));
const response = await stub.fetch(new Request('http://fake/facts?type=page_view&limit=10'));
const facts = await response.json();
allFacts.push(...facts);
}
return allFacts.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit);
}
}
// Or use projection endpoints - Worker aggregates from multiple DOs
export default {
async fetch(request: Request, env: Env) {
const websiteId = new URL(request.url).searchParams.get('website_id');
const client = createLedgerClient(env.WEBSITE_LEDGER);
// Get website entity which has child session IDs
const website = await client.get(websiteId);
const sessionIds = website.data.recent_session_ids || [];
// Query each session DO for page views
const sessionClient = createLedgerClient(env.SESSION_LEDGER);
const results = await Promise.all(
sessionIds.slice(0, 20).map(id => sessionClient.get(id))
);
return Response.json({ sessions: results });
}
};

For real-time parent updates, send stats (not facts):

class SessionLedger extends EntityLedger {
override async afterFactAppended(fact: Fact, context: FactContext): Promise<void> {
if (context.isReplay) return;
// ✅ Lightweight stat signal to parent
await this.env.STATS_QUEUE.send({
website_id: this.entity.parent_id,
event: 'session_activity',
delta: { page_views: 1 }
});
}
}
class WebsiteLedger extends EntityLedger {
// Parent maintains its OWN stats fact, not copies of child facts
async incrementStats(delta: { page_views: number }): Promise<Fact> {
return this.appendFact({
type: 'stats',
subtype: 'increment',
data: delta
});
}
}

Why this scales:

  • Website with 10,000 sessions: each session has its own bounded fact stream
  • Parent ledger stays small: only its own facts (configs, aggregates)
  • Cross-entity queries: query child DOs directly or use projection endpoints
  • No duplication: single source of truth for every fact

PatternUse CaseKey Method
Extend EntityLedgerCustom domain logicupdateCachedState()
Fact TaxonomyStructured eventstype + subtype
CachedStateDerived viewsgetCachedState(), setCachedState()
LedgerClientWorker → DO callsclient.get(), client.emit()
HierarchyParent-child treesparent_id field
ConfigTime-based settingseffective_at, superseded_at
WebSocket/SSEReal-time updatesafterFactAppended(), broadcast()
Schema ValidationType safetyschemaBuilders.entity()
IdempotencyRetry safetyexternal_source, external_id
Batch OperationsBulk processingBatchExecutor
Single Source of TruthEntity hierarchiesFacts in one ledger, query children directly