Anti-Patterns
Common mistakes and their corrections.
CRITICAL: Dogfooding Requirement
Section titled “CRITICAL: Dogfooding Requirement”When building applications ON the z0 platform (w1, analytics, etc.), you MUST use z0 primitives. No exceptions.
The Rule
Section titled “The Rule”Every stateful component in a z0 application MUST be built with:
- EntityLedger for domain objects (not custom DO classes)
- Facts for all state changes (not direct SQLite writes)
- Config for versioned settings (not KV or env vars)
- CachedState for derived views (not side tables)
Why This Matters
Section titled “Why This Matters”- Consistency - One pattern across all systems
- Auditability - Complete history via Facts
- Testability - Mock infrastructure works everywhere
- Refactoring - No “special” legacy code to migrate later
Anti-Pattern: Custom Manager Classes
Section titled “Anti-Pattern: Custom Manager Classes”Problem: Building stateful Durable Objects without EntityLedger.
Why it’s bad:
- Loses audit trail
- No CachedState rebuilding
- Different patterns = maintenance burden
- “Manager” classes become legacy debt
Wrong:
// ❌ DON'T: Build custom DO classes for stateful entitiesexport class AnalyticsManager extends DurableObject { private state: Map<string, number> = new Map();
async recordEvent(event: Event): Promise<void> { // Direct state mutation - no Facts, no audit trail const count = this.state.get(event.type) || 0; this.state.set(event.type, count + 1); await this.ctx.storage.put('state', Object.fromEntries(this.state)); }}Correct:
// ✅ DO: Use EntityLedger for all stateful entitiesexport class AnalyticsLedger extends EntityLedger<EntityLedgerEnv> { async recordEvent(event: Event): Promise<Fact> { return this.appendFact({ type: 'event', subtype: event.type, data: event }); }
getEventCount(type: string): number { const cached = this.getCachedState<{ count: number }>(`count:${type}`); if (cached) return cached.count;
const facts = this.getFacts({ type: 'event', subtype: type }); const count = facts.length; this.setCachedState(`count:${type}`, { count }, facts[facts.length - 1]?.id); return count; }}Checklist Before Building
Section titled “Checklist Before Building”Before writing ANY stateful code in a z0 application, ask:
- Is this an Entity? → Use EntityLedger
- Does state change? → Record as Fact
- Is this configuration? → Use Config with
effective_at - Is this derived data? → Use CachedState
- Need cross-entity queries? → Use D1 replication
If you’re tempted to write a custom DO class, custom SQLite schema, or direct KV storage for domain logic — STOP and reconsider.
Cross-Entity Queries
Section titled “Cross-Entity Queries”For queries spanning multiple entities:
- Query child DOs directly - Parent tracks child IDs in CachedState, calls each DO as needed
- Use projection endpoints - Workers aggregate state from multiple DOs via LedgerClient
Facts stay in their owning ledger. No need for external replication for most use cases.
Anti-Pattern 1: Exposing Facts Directly to UI
Section titled “Anti-Pattern 1: Exposing Facts Directly to UI”Problem: Returning raw Facts to clients exposes internal event structure.
Why it’s bad:
- Leaks implementation details
- Makes refactoring difficult
- Violates encapsulation
- No backward compatibility guarantees
Wrong:
// ❌ DON'T: Return raw facts to UIapp.get('/orders/:id/history', async (c) => { const facts = await ledger.getFacts({ entity_id: orderId }); return c.json({ facts }); // Exposes internal events});Correct:
// ✅ DO: Return entity state or projectionsapp.get('/orders/:id', async (c) => { const entity = await client.get(orderId); return c.json(entity); // Clean domain object});
// ✅ DO: Return derived viewsapp.get('/orders/:id/timeline', async (c) => { const facts = await ledger.getFacts({ entity_id: orderId }); const timeline = facts.map(f => ({ timestamp: f.timestamp, event: `${f.type}.${f.subtype}`, description: formatEvent(f) })); return c.json({ timeline });});Anti-Pattern 2: Mutating Facts
Section titled “Anti-Pattern 2: Mutating Facts”Problem: Attempting to update or delete existing Facts.
Why it’s bad:
- Violates immutability guarantees
- Breaks audit trail
- Loses temporal accuracy
Wrong:
// ❌ DON'T: Try to update factsconst fact = await ledger.getFact(factId);fact.data.amount = 200; // Won't work, facts are immutableawait ledger.updateFact(fact); // Method doesn't existCorrect:
// ✅ DO: Append correction factsawait ledger.appendFact({ type: 'payment', subtype: 'corrected', data: { original_fact_id: 'fact_xyz', corrected_amount: 200, reason: 'data_entry_error' }});
// ✅ DO: Interpret corrections in cached stateprotected async updateCachedState(fact: Fact): Promise<void> { if (fact.type === 'payment' && fact.subtype === 'corrected') { // Rebuild state considering correction this.recomputeBalance(); }}Anti-Pattern 3: Storing Source Data in CachedState
Section titled “Anti-Pattern 3: Storing Source Data in CachedState”Problem: Using CachedState as the source of truth.
Why it’s bad:
- CachedState can be deleted/rebuilt
- Not replicated
- No audit trail
- Breaks point-in-time reconstruction
Wrong:
// ❌ DON'T: Store source data in cacheasync recordPayment(amount: number): Promise<void> { const cached = this.getCachedState<{ payments: Payment[] }>('payments') || { payments: [] }; cached.payments.push({ amount, timestamp: Date.now() }); this.setCachedState('payments', cached); // No fact recorded!}Correct:
// ✅ DO: Store source data as facts, derive cacheasync recordPayment(amount: number): Promise<Fact> { return this.appendFact({ type: 'payment', data: { amount, timestamp: Date.now() } });}
protected async updateCachedState(fact: Fact): Promise<void> { if (fact.type === 'payment') { const facts = this.getFacts({ type: 'payment' }); const total = facts.reduce((sum, f) => sum + (f.data.amount as number), 0); this.setCachedState('payment_total', { total }, fact.id); }}Anti-Pattern 4: Cross-Entity Queries in EntityLedger
Section titled “Anti-Pattern 4: Cross-Entity Queries in EntityLedger”Problem: Attempting to query multiple entities from within an entity.
Why it’s bad:
- DOs are single-entity isolated
- No cross-entity queries by design
- Violates DO boundaries
Wrong:
// ❌ DON'T: Try to query other entitiesclass AccountLedger extends EntityLedger { async getRelatedAccounts(): Promise<Entity[]> { // Can't query other DOs from here! const accounts = await db.query('SELECT * FROM entities WHERE parent_id = ?', [this.entity.id]); return accounts; }}Correct:
// ✅ DO: Use D1 replication for analyticsexport default { async fetch(request: Request, env: Env) { // Query replicated data in D1 const result = await env.DB.prepare( 'SELECT * FROM entities WHERE parent_id = ? AND tenant_id = ?' ).bind(parentId, tenantId).all();
return Response.json(result.results); }};
// ✅ DO: Track relationships via factsawait parentLedger.appendFact({ type: 'child_registered', data: { child_id: 'acct_child_123' }});Anti-Pattern 5: Hardcoding Tenant Checks
Section titled “Anti-Pattern 5: Hardcoding Tenant Checks”Problem: Writing custom tenant isolation logic.
Why it’s bad:
- Already handled by SDK
- Risk of security bypass
- Duplicated code
Wrong:
// ❌ DON'T: Manual tenant checksasync fetch(request: Request): Promise<Response> { const tenantId = request.headers.get('X-Tenant-ID'); if (this.entity.tenant_id !== tenantId) { return new Response('Forbidden', { status: 403 }); } // ... rest of logic}Correct:
// ✅ DO: Use built-in authenticationimport { authenticateRequest } from '@z0-app/sdk';
async fetch(request: Request): Promise<Response> { const auth = authenticateRequest(request); if (!auth.success) { return new Response('Unauthorized', { status: 401 }); } // auth.context.tenantId automatically validated}Anti-Pattern 6: Creating Side Tables
Section titled “Anti-Pattern 6: Creating Side Tables”Problem: Creating separate SQLite tables for derived state.
Why it’s bad:
- CachedState already provides this
- More schema to manage
- Harder to invalidate
Wrong:
// ❌ DON'T: Create side tablesasync initializeSchema(): Promise<void> { await this.sql.exec(` CREATE TABLE IF NOT EXISTS balances ( entity_id TEXT PRIMARY KEY, balance REAL, updated_at INTEGER ) `);}
async getBalance(): Promise<number> { const row = await this.sql.get('SELECT balance FROM balances WHERE entity_id = ?', [this.entity.id]); return row?.balance || 0;}Correct:
// ✅ DO: Use CachedStategetBalance(): number { const cached = this.getCachedState<{ balance: number }>('balance'); if (cached) return cached.balance;
const facts = this.getFacts({ type: ['deposit', 'withdrawal'] }); const balance = facts.reduce((sum, f) => { return f.type === 'deposit' ? sum + (f.data.amount as number) : sum - (f.data.amount as number); }, 0);
this.setCachedState('balance', { balance }, facts[facts.length - 1]?.id); return balance;}Anti-Pattern 7: Unprotected Parent DO Calls
Section titled “Anti-Pattern 7: Unprotected Parent DO Calls”Problem: Calling parent DOs without circuit breaker or timeout.
Why it’s bad:
- Cascading failures
- No retry logic
- Can deadlock
Wrong:
// ❌ DON'T: Raw fetch to parentasync reserveBudget(amount: number): Promise<boolean> { const parentStub = this.env.ACCOUNT_LEDGER.get(this.id(this.entity.parent_id!)); const response = await parentStub.fetch(new Request('http://fake/reserve', { method: 'POST', body: JSON.stringify({ amount }) })); return response.ok;}Correct:
// ✅ DO: Use ParentDOClient with circuit breakerimport { ParentDOClient } from '@z0-app/sdk';
async reserveBudget(amount: number): Promise<boolean> { const parentClient = new ParentDOClient( this.env.ACCOUNT_LEDGER, this.entity.parent_id!, { timeout: 5000, retries: 2 } );
const response = await parentClient.fetch('/reserve', { method: 'POST', body: JSON.stringify({ amount }) });
return response.ok;}Anti-Pattern 8: Ignoring Fact Context Flags
Section titled “Anti-Pattern 8: Ignoring Fact Context Flags”Problem: Broadcasting notifications during replay or import.
Why it’s bad:
- Sends duplicate events
- Triggers side effects during hydration
- Wastes resources
Wrong:
// ❌ DON'T: Broadcast unconditionallyoverride async afterFactAppended(fact: Fact): Promise<void> { // Always broadcasts, even during replay! this.subscriptionManager.broadcast('facts', { fact });}Correct:
// ✅ DO: Check context flagsoverride async afterFactAppended(fact: Fact, context: FactContext): Promise<void> { if (context.isReplay || context.isImport) return;
// Only broadcast for live facts this.subscriptionManager.broadcast('facts', { fact });}Anti-Pattern 9: Blocking Fact Append with Slow Operations
Section titled “Anti-Pattern 9: Blocking Fact Append with Slow Operations”Problem: Running expensive operations synchronously during fact append.
Why it’s bad:
- Slows down fact recording
- Increases latency
- Can timeout
Wrong:
// ❌ DON'T: Block on external callsoverride async afterFactAppended(fact: Fact): Promise<void> { // Blocks fact append! await fetch('https://external-api.com/webhook', { method: 'POST', body: JSON.stringify(fact) });}Correct:
// ✅ DO: Use async hooks (queues)override async afterFactAppended(fact: Fact, context: FactContext): Promise<void> { if (context.isReplay) return;
// Non-blocking: queue for async processing await this.env.WEBHOOK_QUEUE.send({ fact_id: fact.id, url: 'https://external-api.com/webhook' });}Anti-Pattern 10: Using Entity.metadata for Domain Data
Section titled “Anti-Pattern 10: Using Entity.metadata for Domain Data”Problem: Storing domain-specific data in metadata field.
Why it’s bad:
metadatais for platform use- Not type-safe
- Confuses platform and domain concerns
Wrong:
// ❌ DON'T: Store domain data in metadataawait ledger.upsertEntity({ id: 'acct_123', type: 'account', data: {}, metadata: { balance: 1000, // Domain data! account_type: 'premium' // Domain data! }});Correct:
// ✅ DO: Store domain data in data fieldawait ledger.upsertEntity({ id: 'acct_123', type: 'account', data: { balance: 1000, account_type: 'premium' }, metadata: { // Platform metadata only last_accessed: Date.now() }});Anti-Pattern 11: Not Handling Schema Migrations
Section titled “Anti-Pattern 11: Not Handling Schema Migrations”Problem: Changing fact structure without versioning.
Why it’s bad:
- Breaks existing facts
- No backward compatibility
- Hard to debug
Wrong:
// ❌ DON'T: Change fact structure without versioning// v1 (old){ type: 'payment', data: { amount: 100 } }
// v2 (new) - breaks old facts!{ type: 'payment', data: { amount: 100, currency: 'USD' } }Correct:
// ✅ DO: Use schema_version and migrateawait ledger.appendFact({ type: 'payment', schema_version: 2, data: { amount: 100, currency: 'USD' }});
protected async updateCachedState(fact: Fact): Promise<void> { if (fact.type === 'payment') { // Handle both versions const amount = fact.data.amount as number; const currency = (fact.schema_version || 1) >= 2 ? fact.data.currency as string : 'USD'; // Default for v1 }}Anti-Pattern 12: Forgetting Optimistic Concurrency Control
Section titled “Anti-Pattern 12: Forgetting Optimistic Concurrency Control”Problem: Updating entities without checking version.
Why it’s bad:
- Lost updates
- Race conditions
- Data corruption
Wrong:
// ❌ DON'T: Update without version checkconst entity = await client.get('acct_123');entity.data.balance = 1000;await client.stub('acct_123').upsertEntity(entity);// Another request might have updated in between!Correct:
// ✅ DO: Use version for optimistic lockingconst entity = await client.get('acct_123');const updatedEntity = { ...entity, version: entity.version + 1, data: { balance: 1000 }};
try { await client.stub('acct_123').upsertEntity(updatedEntity);} catch (error) { if (error.message.includes('version mismatch')) { // Retry with fresh entity }}Anti-Pattern 13: Duplicating Facts Up the Hierarchy
Section titled “Anti-Pattern 13: Duplicating Facts Up the Hierarchy”Problem: Copying child facts to parent ledgers for “convenience” or “visibility.”
Why it’s bad:
- Destroys scalability - If Website has 10,000 Sessions, copying all Session facts to Website creates unbounded growth
- Violates single source of truth - Same fact exists in two places, no canonical version
- Breaks consistency - Which copy is authoritative? What if they diverge?
- Wastes storage - Exponential data duplication up the chain
- Complicates queries - Which ledger do you query?
Wrong:
// ❌ DON'T: Copy child facts to parentclass SessionLedger extends EntityLedger { override async afterFactAppended(fact: Fact): Promise<void> { // Duplicating to parent - NEVER DO THIS const websiteStub = this.env.WEBSITE_LEDGER.get(this.id(this.entity.parent_id!)); await websiteStub.fetch(new Request('http://fake/facts', { method: 'POST', body: JSON.stringify({ type: 'session_event', data: { session_id: this.entity.id, ...fact.data } }) })); }}Correct:
// ✅ DO: Keep facts in their owning ledger, query via D1 replication// Session facts stay in SessionLedgerclass SessionLedger extends EntityLedger { async recordPageView(url: string): Promise<Fact> { return this.appendFact({ type: 'page_view', data: { url, timestamp: Date.now() } }); }}
// Query aggregated data from D1 (facts auto-replicate)export default { async fetch(request: Request, env: Env) { const websiteId = new URL(request.url).searchParams.get('website_id');
// Query all session facts for this website via D1 const result = await env.DB.prepare(` SELECT f.* FROM facts f JOIN entities e ON f.entity_id = e.id WHERE e.parent_id = ? AND e.type = 'session' ORDER BY f.timestamp DESC LIMIT 100 `).bind(websiteId).all();
return Response.json(result.results); }};
// ✅ DO: Send aggregated stats to parent (not facts)class SessionLedger extends EntityLedger { override async afterFactAppended(fact: Fact, context: FactContext): Promise<void> { if (context.isReplay) return;
// Send lightweight stat update, NOT the fact await this.env.STATS_QUEUE.send({ website_id: this.entity.parent_id, event: 'session_activity', session_id: this.entity.id }); }}The Rule: Facts belong to ONE ledger. Parents aggregate via:
- D1 replication queries - For analytics/reporting
- Stats/counters - Lightweight signals (not fact copies)
- Child references - Parent knows child IDs, queries them directly when needed
Summary Table
Section titled “Summary Table”| Anti-Pattern | Problem | Solution |
|---|---|---|
| Custom Manager classes | No audit trail, legacy debt | Always use EntityLedger |
| Expose Facts to UI | Leaks internals | Return Entity or projections |
| Mutate Facts | Breaks immutability | Append correction facts |
| CachedState as source | Not durable | Facts are source, cache is derived |
| Cross-entity queries | Violates DO isolation | Use D1 replication |
| Hardcoded tenant checks | Duplicate logic | Use authenticateRequest() |
| Side tables | Unnecessary complexity | Use CachedState |
| Unprotected parent calls | Cascading failures | Use ParentDOClient |
| Ignore fact context | Duplicate notifications | Check isReplay, isImport |
| Block on slow ops | High latency | Use async hooks/queues |
| Domain data in metadata | Wrong abstraction | Use data field |
| No schema migration | Breaking changes | Use schema_version |
| Skip version check | Lost updates | Use optimistic locking |
| Duplicate facts up hierarchy | Destroys scalability | Query via D1, send stats not facts |