Skip to content

Anti-Patterns

Common mistakes and their corrections.


When building applications ON the z0 platform (w1, analytics, etc.), you MUST use z0 primitives. No exceptions.

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)
  1. Consistency - One pattern across all systems
  2. Auditability - Complete history via Facts
  3. Testability - Mock infrastructure works everywhere
  4. Refactoring - No “special” legacy code to migrate later

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 entities
export 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 entities
export 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;
}
}

Before writing ANY stateful code in a z0 application, ask:

  1. Is this an Entity? → Use EntityLedger
  2. Does state change? → Record as Fact
  3. Is this configuration? → Use Config with effective_at
  4. Is this derived data? → Use CachedState
  5. 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.

For queries spanning multiple entities:

  1. Query child DOs directly - Parent tracks child IDs in CachedState, calls each DO as needed
  2. 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 UI
app.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 projections
app.get('/orders/:id', async (c) => {
const entity = await client.get(orderId);
return c.json(entity); // Clean domain object
});
// ✅ DO: Return derived views
app.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 });
});

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 facts
const fact = await ledger.getFact(factId);
fact.data.amount = 200; // Won't work, facts are immutable
await ledger.updateFact(fact); // Method doesn't exist

Correct:

// ✅ DO: Append correction facts
await ledger.appendFact({
type: 'payment',
subtype: 'corrected',
data: {
original_fact_id: 'fact_xyz',
corrected_amount: 200,
reason: 'data_entry_error'
}
});
// ✅ DO: Interpret corrections in cached state
protected 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 cache
async 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 cache
async 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 entities
class 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 analytics
export 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 facts
await parentLedger.appendFact({
type: 'child_registered',
data: { child_id: 'acct_child_123' }
});

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 checks
async 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 authentication
import { 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
}

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 tables
async 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 CachedState
getBalance(): 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 parent
async 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 breaker
import { 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 unconditionally
override async afterFactAppended(fact: Fact): Promise<void> {
// Always broadcasts, even during replay!
this.subscriptionManager.broadcast('facts', { fact });
}

Correct:

// ✅ DO: Check context flags
override 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 calls
override 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:

  • metadata is for platform use
  • Not type-safe
  • Confuses platform and domain concerns

Wrong:

// ❌ DON'T: Store domain data in metadata
await 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 field
await 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 migrate
await 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 check
const 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 locking
const 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 parent
class 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 SessionLedger
class 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:

  1. D1 replication queries - For analytics/reporting
  2. Stats/counters - Lightweight signals (not fact copies)
  3. Child references - Parent knows child IDs, queries them directly when needed

Anti-PatternProblemSolution
Custom Manager classesNo audit trail, legacy debtAlways use EntityLedger
Expose Facts to UILeaks internalsReturn Entity or projections
Mutate FactsBreaks immutabilityAppend correction facts
CachedState as sourceNot durableFacts are source, cache is derived
Cross-entity queriesViolates DO isolationUse D1 replication
Hardcoded tenant checksDuplicate logicUse authenticateRequest()
Side tablesUnnecessary complexityUse CachedState
Unprotected parent callsCascading failuresUse ParentDOClient
Ignore fact contextDuplicate notificationsCheck isReplay, isImport
Block on slow opsHigh latencyUse async hooks/queues
Domain data in metadataWrong abstractionUse data field
No schema migrationBreaking changesUse schema_version
Skip version checkLost updatesUse optimistic locking
Duplicate facts up hierarchyDestroys scalabilityQuery via D1, send stats not facts