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.
Prerequisites
Section titled “Prerequisites”- Completed Getting Started
- Understand Core Concepts (Entity, Fact, Config)
What We’re Building
Section titled “What We’re Building”A CRM domain with:
- Contact entities (people we track)
- Interaction facts (calls, emails, meetings)
- Scoring config (rules for lead qualification)
Step 1: Design Your Domain Model
Section titled “Step 1: Design Your Domain Model”Before writing code, map your business concepts to z0 primitives.
Identify Entities
Section titled “Identify Entities”Ask: “What things have identity and accumulate history?”
| Business Concept | z0 Primitive | Why |
|---|---|---|
| Contact | Entity | Has identity, accumulates interactions |
| Company | Entity | Has identity, relates to contacts |
| Deal | Entity | Has identity, has lifecycle states |
| Interaction | Fact | Immutable record of what happened |
| Lead Score | CachedState | Derived from interaction Facts |
Identify Facts
Section titled “Identify Facts”Ask: “What events happen that we need to track?”
| Event | Fact Type | Fact Subtype |
|---|---|---|
| Call made | interaction | call |
| Email sent | interaction | |
| Meeting held | interaction | meeting |
| Deal created | lifecycle | created |
| Deal stage changed | lifecycle | stage_changed |
| Deal won | outcome | deal_won |
| Deal lost | outcome | deal_lost |
Identify Configs
Section titled “Identify Configs”Ask: “What business rules might change over time?”
| Rule | Config Type | Example |
|---|---|---|
| Lead scoring weights | scoring | { call: 10, email: 5, meeting: 20 } |
| Qualification threshold | qualification | { min_score: 50 } |
Step 2: Define the Domain Manifest
Section titled “Step 2: Define the Domain Manifest”The manifest declares your domain’s structure:
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'] } }};Step 3: Implement Entity Ledgers
Section titled “Step 3: Implement Entity Ledgers”Each entity type gets a class extending EntityLedger.
Contact Ledger
Section titled “Contact Ledger”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); }}Step 4: Set Up Configs
Section titled “Step 4: Set Up Configs”Create default configs for your domain:
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 } });}Step 5: Wire Up the Domain
Section titled “Step 5: Wire Up the Domain”Register your domain and expose endpoints:
import { Hono } from 'hono';import { LedgerRegistry, LedgerClient } from '@z0-app/sdk';import { crmManifest } from './domain/manifest';
// Register domainLedgerRegistry.register(crmManifest);
const app = new Hono();
// Create contactapp.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 interactionapp.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 scoreapp.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;Step 6: Test Your Domain
Section titled “Step 6: Test Your Domain”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); });});Key Takeaways
Section titled “Key Takeaways”- Start with domain modeling - Map business concepts to Entity, Fact, Config before coding
- Facts are the source of truth - Cached state (like lead score) is always derived from Facts
- Configs enable flexibility - Scoring weights can change without code changes
- Inline updates for performance - Update cached state when appending Facts
- Reconciliation for safety - Periodically verify cached state matches ledger
Next Steps
Section titled “Next Steps”- Core Concepts - Deep dive into Entity, Fact, Config
- Deployment - Deploy your domain to Cloudflare
- Tenancy - Multi-tenant isolation patterns