Patterns
Common patterns for building with z0.
Pattern 1: Extending EntityLedger
Section titled “Pattern 1: Extending EntityLedger”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); }}Pattern 2: Fact Taxonomy Design
Section titled “Pattern 2: Fact Taxonomy Design”When: Defining fact types for your domain.
Two-Level Taxonomy
Section titled “Two-Level Taxonomy”// 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: { ... } }Filtering by Taxonomy
Section titled “Filtering by Taxonomy”// All paymentsconst payments = ledger.getFacts({ type: 'payment' });
// Only completed paymentsconst completed = payments.filter(f => f.subtype === 'completed');
// Multiple typesconst financial = ledger.getFacts({ type: ['payment', 'refund', 'chargeback'] });Pattern 3: CachedState Invalidation
Section titled “Pattern 3: CachedState Invalidation”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 }); }};Pattern 5: Parent-Child Hierarchy
Section titled “Pattern 5: Parent-Child Hierarchy”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.
WebSocket Pattern
Section titled “WebSocket Pattern”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 }); }}SSE Pattern
Section titled “SSE Pattern”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' } });});Pattern 8: Schema-Based Validation
Section titled “Pattern 8: Schema-Based Validation”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 inferencetype AccountEntity = InferEntityFields<typeof Account>;type DepositData = InferFactData<typeof Account, 'deposit'>;Pattern 9: Idempotency with External IDs
Section titled “Pattern 9: Idempotency with External IDs”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-opawait ledger.appendFact({ type: 'payment', subtype: 'completed', external_source: 'stripe', external_id: 'evt_1234567890', // Same ID data: { amount: 100 }});// Returns existing fact, doesn't create duplicatePattern 10: Batch Operations
Section titled “Pattern 10: Batch Operations”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 SessionLedgerclass 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 IDsclass 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 DOsexport 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
Summary Table
Section titled “Summary Table”| Pattern | Use Case | Key Method |
|---|---|---|
| Extend EntityLedger | Custom domain logic | updateCachedState() |
| Fact Taxonomy | Structured events | type + subtype |
| CachedState | Derived views | getCachedState(), setCachedState() |
| LedgerClient | Worker → DO calls | client.get(), client.emit() |
| Hierarchy | Parent-child trees | parent_id field |
| Config | Time-based settings | effective_at, superseded_at |
| WebSocket/SSE | Real-time updates | afterFactAppended(), broadcast() |
| Schema Validation | Type safety | schemaBuilders.entity() |
| Idempotency | Retry safety | external_source, external_id |
| Batch Operations | Bulk processing | BatchExecutor |
| Single Source of Truth | Entity hierarchies | Facts in one ledger, query children directly |