Skip to content

Building Your First Domain

This guide walks you through designing and implementing a complete domain with z0. We’ll build a simple CRM (Contact Relationship Management) system to demonstrate the patterns.

A CRM domain with:

  • Contact entities (people we track)
  • Interaction facts (calls, emails, meetings)
  • Scoring config (rules for lead qualification)

Before writing code, map your business concepts to z0 primitives.

Ask: “What things have identity and accumulate history?”

Business Conceptz0 PrimitiveWhy
ContactEntityHas identity, accumulates interactions
CompanyEntityHas identity, relates to contacts
DealEntityHas identity, has lifecycle states
InteractionFactImmutable record of what happened
Lead ScoreCachedStateDerived from interaction Facts

Ask: “What events happen that we need to track?”

EventFact TypeFact Subtype
Call madeinteractioncall
Email sentinteractionemail
Meeting heldinteractionmeeting
Deal createdlifecyclecreated
Deal stage changedlifecyclestage_changed
Deal wonoutcomedeal_won
Deal lostoutcomedeal_lost

Ask: “What business rules might change over time?”

RuleConfig TypeExample
Lead scoring weightsscoring{ call: 10, email: 5, meeting: 20 }
Qualification thresholdqualification{ min_score: 50 }

The manifest declares your domain’s structure:

src/domain/manifest.ts
import { DomainManifest } from '@z0-app/sdk';
import { Contact } from './entities/Contact';
import { Company } from './entities/Company';
import { Deal } from './entities/Deal';
export const crmManifest: DomainManifest = {
name: 'crm',
version: '1.0.0',
entities: {
contact: {
ledger: Contact,
description: 'Person we are tracking',
fields: {
// Required fields
status: { type: 'string', required: true, storage: 'ix_s_1' },
// Contact info (mapped to index slots)
email: { type: 'string', storage: 'ix_s_2' },
phone: { type: 'string', storage: 'ix_s_3' },
name: { type: 'string', storage: 'ix_s_4' },
company_id: { type: 'string', storage: 'ix_s_5' },
// Numeric fields
lead_score: { type: 'number', storage: 'ix_n_1' },
interaction_count: { type: 'number', storage: 'ix_n_2' },
},
facts: ['interaction', 'lifecycle', 'qualification']
},
company: {
ledger: Company,
description: 'Organization',
fields: {
status: { type: 'string', required: true, storage: 'ix_s_1' },
name: { type: 'string', required: true, storage: 'ix_s_2' },
domain: { type: 'string', storage: 'ix_s_3' },
industry: { type: 'string', storage: 'ix_s_4' },
employee_count: { type: 'number', storage: 'ix_n_1' },
},
facts: ['lifecycle']
},
deal: {
ledger: Deal,
description: 'Sales opportunity',
fields: {
status: { type: 'string', required: true, storage: 'ix_s_1' },
stage: { type: 'string', required: true, storage: 'ix_s_2' },
contact_id: { type: 'string', storage: 'ix_s_3' },
company_id: { type: 'string', storage: 'ix_s_4' },
amount: { type: 'number', storage: 'ix_n_1' },
},
facts: ['lifecycle', 'outcome']
}
}
};

Each entity type gets a class extending EntityLedger.

src/domain/entities/Contact.ts
import { EntityLedger, Fact } from '@z0-app/sdk';
interface LeadScoreState {
score: number;
last_calculated: number;
interaction_counts: {
call: number;
email: number;
meeting: number;
};
}
export class Contact extends EntityLedger {
// Define cached state types for reconciliation
getCachedStateTypes(): string[] {
return ['LeadScoreState'];
}
// Calculate lead score from Facts
async calculateLeadScore(): Promise<number> {
// Try cache first
const cached = await this.getCachedState<LeadScoreState>('LeadScoreState');
if (cached && !this.isStale(cached.last_calculated)) {
return cached.score;
}
// Get scoring config
const scoringConfig = await this.getActiveConfig('scoring');
const weights = scoringConfig?.settings || {
call: 10,
email: 5,
meeting: 20
};
// Count interactions by type
const interactions = await this.getFactsByType('interaction');
const counts = { call: 0, email: 0, meeting: 0 };
for (const fact of interactions) {
const subtype = fact.subtype as keyof typeof counts;
if (subtype in counts) {
counts[subtype]++;
}
}
// Calculate score
const score =
counts.call * weights.call +
counts.email * weights.email +
counts.meeting * weights.meeting;
// Cache result
const lastFact = interactions[0];
await this.setCachedState('LeadScoreState', {
score,
last_calculated: Date.now(),
interaction_counts: counts,
last_fact_id: lastFact?.id,
computed_at: Date.now()
});
return score;
}
// Record an interaction
async recordInteraction(
type: 'call' | 'email' | 'meeting',
data: {
duration_minutes?: number;
subject?: string;
notes?: string;
}
): Promise<Fact> {
const fact = await this.appendFact({
type: 'interaction',
subtype: type,
data
});
// Update cached score inline
await this.updateScoreInline(type);
return fact;
}
private async updateScoreInline(interactionType: string): Promise<void> {
const scoringConfig = await this.getActiveConfig('scoring');
const weight = scoringConfig?.settings?.[interactionType] || 0;
// Increment score in cached state
await this.sql.exec(`
UPDATE cached_state
SET value = json_set(
value,
'$.score', json_extract(value, '$.score') + ?,
'$.interaction_counts.${interactionType}',
json_extract(value, '$.interaction_counts.${interactionType}') + 1
),
computed_at = ?,
updated_at = ?
WHERE key = 'LeadScoreState'
`, [weight, Date.now(), Date.now()]);
}
// Check if contact qualifies as a lead
async checkQualification(): Promise<boolean> {
const qualConfig = await this.getActiveConfig('qualification');
const threshold = qualConfig?.settings?.min_score || 50;
const score = await this.calculateLeadScore();
return score >= threshold;
}
// Reconciliation: verify cached state matches ledger
async calculateFromLedger(stateType: string): Promise<any> {
if (stateType !== 'LeadScoreState') {
throw new Error(`Unknown state type: ${stateType}`);
}
const scoringConfig = await this.getActiveConfig('scoring');
const weights = scoringConfig?.settings || {
call: 10,
email: 5,
meeting: 20
};
const interactions = await this.getFactsByType('interaction');
const counts = { call: 0, email: 0, meeting: 0 };
for (const fact of interactions) {
const subtype = fact.subtype as keyof typeof counts;
if (subtype in counts) {
counts[subtype]++;
}
}
return {
score: counts.call * weights.call +
counts.email * weights.email +
counts.meeting * weights.meeting,
last_calculated: Date.now(),
interaction_counts: counts,
last_fact_id: interactions[0]?.id,
computed_at: Date.now()
};
}
// HTTP endpoints
override async fetch(request: Request): Promise<Response> {
await this.ensureInitialized();
const url = new URL(request.url);
if (url.pathname === '/score' && request.method === 'GET') {
const score = await this.calculateLeadScore();
const qualified = await this.checkQualification();
return Response.json({ score, qualified });
}
if (url.pathname === '/interactions' && request.method === 'POST') {
const body = await request.json();
const fact = await this.recordInteraction(body.type, body.data);
return Response.json(fact, { status: 201 });
}
return super.fetch(request);
}
}

Create default configs for your domain:

src/domain/setup.ts
import { ConfigManager } from '@z0-app/sdk';
export async function setupDefaultConfigs(
configManager: ConfigManager,
tenantId: string
): Promise<void> {
// Scoring config
await configManager.create({
id: `scoring_${tenantId}`,
type: 'scoring',
category: 'logic',
name: 'Lead Scoring Weights',
applies_to: tenantId,
scope: 'account',
settings: {
call: 10,
email: 5,
meeting: 20,
demo: 30
}
});
// Qualification config
await configManager.create({
id: `qualification_${tenantId}`,
type: 'qualification',
category: 'logic',
name: 'Lead Qualification Threshold',
applies_to: tenantId,
scope: 'account',
settings: {
min_score: 50
}
});
}

Register your domain and expose endpoints:

src/index.ts
import { Hono } from 'hono';
import { LedgerRegistry, LedgerClient } from '@z0-app/sdk';
import { crmManifest } from './domain/manifest';
// Register domain
LedgerRegistry.register(crmManifest);
const app = new Hono();
// Create contact
app.post('/v1/contacts', async (c) => {
const body = await c.req.json();
const tenantId = c.req.header('X-Tenant-ID')!;
const namespace = LedgerRegistry.getNamespace(c.env, 'contact');
const client = new LedgerClient(namespace, tenantId);
const contact = await client.stub(body.id).upsertEntity({
type: 'contact',
data: body.data
});
return c.json(contact, 201);
});
// Record interaction
app.post('/v1/contacts/:id/interactions', async (c) => {
const contactId = c.req.param('id');
const tenantId = c.req.header('X-Tenant-ID')!;
const body = await c.req.json();
const namespace = LedgerRegistry.getNamespace(c.env, 'contact');
const client = new LedgerClient(namespace, tenantId);
const response = await client.stub(contactId).fetch(
new Request('https://internal/interactions', {
method: 'POST',
body: JSON.stringify(body)
})
);
return c.json(await response.json(), response.status);
});
// Get lead score
app.get('/v1/contacts/:id/score', async (c) => {
const contactId = c.req.param('id');
const tenantId = c.req.header('X-Tenant-ID')!;
const namespace = LedgerRegistry.getNamespace(c.env, 'contact');
const client = new LedgerClient(namespace, tenantId);
const response = await client.stub(contactId).fetch(
new Request('https://internal/score')
);
return c.json(await response.json());
});
export default app;
tests/contact.test.ts
import { describe, it, expect } from 'vitest';
import { MockSqlStorage } from '@z0-app/sdk/testing';
import { Contact } from '../src/domain/entities/Contact';
describe('Contact', () => {
it('calculates lead score from interactions', async () => {
const storage = new MockSqlStorage();
const contact = new Contact(storage, 'contact_123');
// Record interactions
await contact.recordInteraction('call', { duration_minutes: 15 });
await contact.recordInteraction('email', { subject: 'Follow up' });
await contact.recordInteraction('meeting', { duration_minutes: 60 });
// Check score (10 + 5 + 20 = 35)
const score = await contact.calculateLeadScore();
expect(score).toBe(35);
});
it('qualifies lead when score exceeds threshold', async () => {
const storage = new MockSqlStorage();
const contact = new Contact(storage, 'contact_123');
// Record enough interactions to qualify
await contact.recordInteraction('meeting', {});
await contact.recordInteraction('meeting', {});
await contact.recordInteraction('call', {});
// Score: 20 + 20 + 10 = 50 (meets threshold)
const qualified = await contact.checkQualification();
expect(qualified).toBe(true);
});
});
  1. Start with domain modeling - Map business concepts to Entity, Fact, Config before coding
  2. Facts are the source of truth - Cached state (like lead score) is always derived from Facts
  3. Configs enable flexibility - Scoring weights can change without code changes
  4. Inline updates for performance - Update cached state when appending Facts
  5. Reconciliation for safety - Periodically verify cached state matches ledger