HotCRM Logo
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 exist

2. 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 configuration

Examples:

account.object.ts           # Account object schema
opportunity.hook.ts         # Opportunity triggers
ai_smart_briefing.action.ts # AI action
sales_dashboard.dashboard.ts # Sales dashboard

Metadata 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"'
    }
  ]
}

Next Steps

On this page