Architecture
Metadata-Driven Architecture
Learn how HotCRM uses metadata to define business objects
Metadata-Driven Architecture
HotCRM uses a metadata-driven architecture where all business objects, fields, relationships, and behaviors are defined through TypeScript schemas. This approach provides type safety, flexibility, and a superior developer experience.
What is Metadata-Driven Architecture?
Instead of hardcoding business logic and database schemas, HotCRM defines everything through declarative metadata:
import type { ObjectSchema } from '@objectstack/spec/data';
export default const Account: ObjectSchema = {
name: 'Account',
label: 'Account',
pluralLabel: 'Accounts',
description: 'Companies and organizations',
fields: [
{
name: 'Name',
type: 'text',
label: 'Account Name',
required: true,
maxLength: 255
},
{
name: 'Industry',
type: 'picklist',
label: 'Industry',
options: ['Technology', 'Finance', 'Healthcare', 'Retail']
},
{
name: 'AnnualRevenue',
type: 'currency',
label: 'Annual Revenue',
currencyCode: 'USD'
}
],
relationships: [
{
name: 'Contacts',
type: 'hasMany',
relatedObject: 'Contact',
foreignKey: 'AccountId'
}
],
listViews: [
{
name: 'All Accounts',
filters: [],
columns: ['Name', 'Industry', 'AnnualRevenue']
}
]
};Benefits
1. Type Safety
TypeScript provides compile-time type checking:
// ✅ Type-safe field access
const account: Account = await db.findOne('Account', '123');
console.log(account.Name); // OK
console.log(account.Industry); // OK
// ❌ Compile error
console.log(account.InvalidField); // Error: Property doesn't exist2. Single Source of Truth
The schema definition is the single source of truth for:
- Database table structure
- API contract
- UI form rendering
- Validation rules
- Documentation
3. Easy Customization
Add new fields without touching the database:
// Add a new field
{
name: 'Website',
type: 'url',
label: 'Website',
required: false
}The system automatically:
- Updates the database schema
- Adds field to API responses
- Renders field in UI forms
- Applies validation rules
4. Version Control
Metadata is code, so you get:
- Git history and diffs
- Code review process
- Rollback capability
- Branch management
Object Schema Structure
Basic Information
{
name: 'Lead', // API name (singular)
label: 'Lead', // Display name (singular)
pluralLabel: 'Leads', // Display name (plural)
description: 'Potential customers',
icon: 'user-plus' // UI icon
}Field Definitions
Text Field
{
name: 'FirstName',
type: 'text',
label: 'First Name',
required: true,
maxLength: 50,
minLength: 1,
pattern: '^[A-Za-z ]+$' // Regex validation
}Number Field
{
name: 'AnnualRevenue',
type: 'number',
label: 'Annual Revenue',
min: 0,
max: 999999999999,
precision: 2 // Decimal places
}Currency Field
{
name: 'Amount',
type: 'currency',
label: 'Amount',
currencyCode: 'USD',
required: true
}Picklist (Dropdown)
{
name: 'Status',
type: 'picklist',
label: 'Status',
options: [
'New',
'Working',
'Nurturing',
'Converted',
'Dead'
],
defaultValue: 'New'
}Multi-Select Picklist
{
name: 'Interests',
type: 'multipicklist',
label: 'Interests',
options: [
'Cloud Computing',
'AI/ML',
'Security',
'Analytics'
]
}Date/DateTime
{
name: 'BirthDate',
type: 'date',
label: 'Date of Birth'
}
{
name: 'CreatedAt',
type: 'datetime',
label: 'Created Date',
defaultValue: 'NOW()'
}Boolean (Checkbox)
{
name: 'IsActive',
type: 'boolean',
label: 'Active',
defaultValue: true
}Rich Text
{
name: 'Description',
type: 'richtext',
label: 'Description',
maxLength: 5000
}Relationships
One-to-Many (Parent-Child)
// In Account object
{
relationships: [
{
name: 'Contacts',
type: 'hasMany',
relatedObject: 'Contact',
foreignKey: 'AccountId'
}
]
}
// In Contact object
{
relationships: [
{
name: 'Account',
type: 'belongsTo',
relatedObject: 'Account',
foreignKey: 'AccountId'
}
]
}Many-to-Many
// Through a junction object
{
name: 'CampaignMember',
fields: [
{ name: 'CampaignId', type: 'reference', relatedObject: 'Campaign' },
{ name: 'LeadId', type: 'reference', relatedObject: 'Lead' }
]
}Validation Rules
{
validationRules: [
{
name: 'Amount_Must_Be_Positive',
errorMessage: 'Amount must be greater than zero',
expression: 'Amount > 0'
},
{
name: 'CloseDate_In_Future',
errorMessage: 'Close date must be in the future',
expression: 'CloseDate > TODAY()'
}
]
}List Views
{
listViews: [
{
name: 'All Leads',
filters: [],
columns: ['Name', 'Company', 'Status', 'LeadScore'],
orderBy: { field: 'CreatedAt', direction: 'desc' }
},
{
name: 'Hot Leads',
filters: [['LeadScore', '>', 70]],
columns: ['Name', 'Company', 'LeadScore'],
orderBy: { field: 'LeadScore', direction: 'desc' }
}
]
}Page Layouts
{
pageLayouts: [
{
name: 'Standard Layout',
sections: [
{
label: 'Lead Information',
columns: 2,
fields: ['FirstName', 'LastName', 'Company', 'Title']
},
{
label: 'Contact Information',
columns: 2,
fields: ['Email', 'Phone', 'Mobile']
},
{
label: 'Additional Details',
columns: 1,
fields: ['Description', 'LeadSource']
}
]
}
]
}Triggers (Hooks)
{
triggers: [
{
name: 'BeforeInsert',
when: 'before',
operation: 'insert',
handler: 'calculateLeadScore'
},
{
name: 'AfterUpdate',
when: 'after',
operation: 'update',
handler: 'sendNotification'
}
]
}File Suffix Protocol
All metadata files follow the File Suffix Protocol:
{object_name}.object.ts # Object schema
{object_name}.hook.ts # Business logic (triggers)
{object_name}.action.ts # Custom actions
{page_name}.page.ts # UI page configuration
{dashboard_name}.dashboard.ts # Dashboard configurationExamples:
account.object.ts # Account object schema
opportunity.hook.ts # Opportunity triggers
ai_smart_briefing.action.ts # AI action
sales_dashboard.dashboard.ts # Sales dashboardMetadata Registry
At runtime, all metadata is loaded into a registry:
class MetadataRegistry {
private objects: Map<string, ObjectSchema> = new Map();
register(schema: ObjectSchema) {
this.objects.set(schema.name, schema);
}
getObject(name: string): ObjectSchema {
return this.objects.get(name);
}
getAllObjects(): ObjectSchema[] {
return Array.from(this.objects.values());
}
}Best Practices
1. Use Descriptive Names
// ✅ Good
{ name: 'AnnualRevenue', label: 'Annual Revenue' }
// ❌ Bad
{ name: 'AR', label: 'AR' }2. Add Help Text
{
name: 'LeadScore',
type: 'number',
label: 'Lead Score',
helpText: 'AI-calculated score (0-100) indicating lead quality'
}3. Set Sensible Defaults
{
name: 'Status',
type: 'picklist',
options: ['New', 'Working', 'Converted'],
defaultValue: 'New' // ✅ Provide default
}4. Use Relationships Over Denormalization
// ✅ Good - Use relationship
{
name: 'AccountId',
type: 'reference',
relatedObject: 'Account'
}
// ❌ Bad - Duplicate account data
{
name: 'AccountName',
type: 'text'
}5. Document Complex Logic
{
validationRules: [
{
name: 'Discount_Limit',
// Good: Clear error message
errorMessage: 'Discounts over 20% require manager approval',
expression: 'Discount <= 20 || ApprovalStatus === "Approved"'
}
]
}