ObjectStackObjectStack

Schema Design

Design robust object schemas in ObjectStack — object metadata, enable flags, field groups, and enterprise best practices

Schema Design

Complete guide to designing robust data models in ObjectStack following enterprise best practices.


Object Schema Design

Basic Object Structure

Every object definition follows this pattern:

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const MyObject = ObjectSchema.create({
  // Metadata
  name: 'my_object',              // Machine name (snake_case)
  label: 'My Object',             // Display name
  pluralLabel: 'My Objects',      // Plural form
  icon: 'briefcase',              // Icon identifier
  description: 'Description...',   // Help text
  
  // Display configuration
  nameField: 'field1',            // Canonical title field (ADR-0079)
  highlightFields: ['field1', 'field2', 'field3'],
  
  // Fields definition
  fields: {
    // ... field definitions
  },
  
  // Performance
  indexes: [...],
  
  // Capabilities
  enable: {...},
  
  // Business rules
  validations: [...],
});

Object Metadata

PropertyTypeDescriptionExample
namestringMachine name (snake_case)'account'
labelstringDisplay name'Account'
pluralLabelstringPlural display name'Accounts'
iconstringIcon identifier'building'
descriptionstringHelp text'Companies...'
nameFieldstringCanonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias displayNameField is still accepted'name'
titleFormatstringDeprecated (ADR-0079 → nameField). Render-only title template ({{record.field}} interpolation); an explicit nameField now takes precedence'{{record.name}} - {{record.id}}'
highlightFieldsstring[]Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly compactLayout; the old spelling was retired and is now rejected)['name', 'status']

Enable Features

Control which features are available for an object:

enable: {
  trackHistory: true,        // Track field changes over time
  searchable: true,          // Include in global search
  apiEnabled: true,          // Expose via REST/GraphQL
  apiMethods: [              // Whitelist API operations
    'get',
    'list',
    'create',
    'update',
    'delete',
    'search',
    'export'
  ],
  files: true,               // Allow file attachments
  feeds: true,               // Enable activity feed (Chatter-like)
  activities: true,          // Track tasks and events
  trash: true,               // Soft delete with recycle bin
  mru: true,                 // Track Most Recently Used
}

Global search — searchable + searchableFields

searchable: true includes an object's records in global search and the record picker. By default the search matches the name/title field plus short-text fields. To control exactly which fields are matched, set searchableFields on the object (ADR-0061):

{
  name: 'account',
  searchable: true,
  searchableFields: ['name', 'website', 'billing_city'],
}

Queries then use the $search filter operator to match across searchableFields (e.g. { filters: { $search: 'acme' } }); a view may narrow the set with $searchFields. When searchableFields is unset, search falls back to the name/title field plus short-text fields.


Field Types & Configuration

ObjectStack fields cover text, numeric, date & time, boolean, select, lookup, address, location, and other specialized data, each configured through Field.* factory options. See Field Metadata for how to configure fields and their common properties, and the Field Type Gallery for the complete per-type reference.

AutoNumber Field

Generates sequential numbers automatically:

Field.autonumber({
  label: 'Account Number',
  autonumberFormat: 'ACC-{0000}',   // ACC-0001, ACC-0002, ...
})

// The format string is literal text plus {…} tokens:
//   {0000}                            — the sequence counter, zero-padded (at most one)
//   {YYYY} {YY} {MM} {DD} {YYYYMMDD}  — generation date in the business timezone
//   {field_name}                      — another field on the same record
// The counter is scoped to whatever renders before the {0000} slot, so
// 'AD{YYYYMMDD}{0000}' resets daily and '{plan_no}{000}' counts per parent —
// no separate reset config. A fixed prefix like 'INV-{000}' keeps one global counter.

User Fields

Pick a person — the equivalent of Airtable's Collaborator or Salesforce's Lookup(User). A user field is a lookup specialized to the built-in sys_user object: it stores the user's id (a real foreign key), resolves to the user's name/avatar via $expand, and renders as a searchable people picker. You never reference sys_user by hand.

// Single assignee
Field.user({ label: 'Assignee' })

// Collaborators / watchers (multiple)
Field.user({ label: 'Watchers', multiple: true })

// Auto-fill the acting user on create (record owner / reporter)
Field.user({ label: 'Owner', defaultValue: 'current_user' })

defaultValue: 'current_user' stamps the authenticated user's id at insert time (the people-field counterpart to 'NOW()' for timestamps). Because a user field is stored exactly like a lookup, an existing Field.lookup('sys_user') is equivalent at the storage layer — adopt Field.user() with no data migration. Record ownership and row-level security continue to use the existing owner_id convention.


Validation Rules

Validation rules are CEL predicates that run against the record on save — when a script rule's condition evaluates to TRUE, validation fails and the configured message is raised at error, warning, or info severity. Uniqueness is enforced with a unique index or a field-level unique flag, not a validation type. See Validation Metadata for all validation types and Field Validation Rules for the built-in constraints of each field type.


Formula Fields

Formula fields calculate values automatically. Expressions are written in CEL, reference fields as record.<field>, and go in the expression property — the field type is already formula, so don't pass type. See Expressions (CEL) for the expression language, standard library, and cookbook.


Field Groups

Organize related fields into logical groups for forms, detail pages, and editors. The group protocol is intentionally minimal (MVP):

  • ObjectSchema.fieldGroups declares the groups; array order is the display order (no separate order property).
  • Field.group assigns a field to a group by referencing an ObjectFieldGroup.key. In-group display order equals the traversal order of fields.
  • Fields whose group is unset (or references an undeclared key) render in a default bucket after the declared groups.
import { ObjectSchema } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',

  fieldGroups: [
    { key: 'contact_info', label: 'Contact Information', icon: 'user' },
    { key: 'billing',      label: 'Billing', collapse: 'collapsed' },
    { key: 'system',       label: 'System' },
  ],

  fields: {
    name:            { type: 'text',  required: true, group: 'contact_info' },
    email:           { type: 'email',                  group: 'contact_info' },
    phone:           { type: 'phone',                  group: 'contact_info' },
    vat_id:          { type: 'text',                   group: 'billing' },
    billing_address: { type: 'address',                group: 'billing' },
    created_at:      { type: 'datetime', readonly: true, group: 'system' },
    created_by:      { type: 'user', reference: 'sys_user', readonly: true, group: 'system' },
  },
});

ObjectFieldGroup Properties

PropertyTypeDescription
keystring (snake_case)Group machine key; referenced by Field.group
labelstringHuman-readable group header
iconstring?Lucide/Material icon name for the group header
descriptionstring?Optional description under the header
collapse'none' | 'expanded' | 'collapsed' (default 'none')Collapse behaviour on every surface: 'none' = always open (no toggle), 'expanded' = collapsible and starts open, 'collapsed' = collapsible and starts closed (ADR-0085; replaces the deprecated defaultExpanded flag, which is still accepted as an alias)

Supported Migrations (MVP)

✅ Add / rename / delete / reorder groups — edit the fieldGroups array. ✅ Assign an existing field to a group — set Field.group.

⏳ Deferred (future iterations): explicit per-field in-group ordering, nested groups, group-level visibility predicates. Groups render identically on forms, modals, and detail pages; when a single page needs a bespoke layout beyond groups, assign it a custom Page schema instead of adding per-surface hints here (ADR-0085).


Best Practices

1. Naming Conventions

DO:

  • Use snake_case for field names: first_name, account_number
  • Use descriptive names: annual_revenue not rev
  • Use boolean prefixes: is_active, has_children

DON'T:

  • Use camelCase for field names
  • Use abbreviations: acc_num
  • Add suffixes to lookups: account_id (use account)

2. Field Design

DO:

  • Use appropriate field types
  • Set reasonable max lengths
  • Add help text for complex fields
  • Use picklists instead of text when values are fixed
  • Mark required fields

DON'T:

  • Store multiple values in one field
  • Use text fields for dates/numbers
  • Create too many fields (split into related objects)

3. Relationships

DO:

  • Use lookup fields for relationships
  • Set up proper cascade delete rules
  • Use filtered lookups to improve UX
  • Document relationship cardinality

DON'T:

  • Store IDs as text fields
  • Create circular references
  • Over-normalize (balance normalization vs. performance)

4. Performance

DO:

  • Add indexes on frequently queried fields
  • Use formula fields for calculations
  • Limit related list queries
  • Use compound indexes for multi-field filters

DON'T:

  • Create too many indexes (slows writes)
  • Put indexes on low-cardinality fields
  • Run ObjectQL queries inside loops (N+1 queries)

5. Validation

DO:

  • Validate at the field level when possible
  • Use validation rules for complex logic
  • Provide clear error messages
  • Test validation rules thoroughly

DON'T:

  • Duplicate validations
  • Create overly complex rules
  • Block legitimate data entry

Real-World Examples

Account Object

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',
  pluralLabel: 'Accounts',
  icon: 'building',
  
  fields: {
    account_number: Field.autonumber({
      label: 'Account Number',
      autonumberFormat: 'ACC-{0000}',
    }),
    
    name: Field.text({
      label: 'Account Name',
      required: true,
      searchable: true,
      maxLength: 255,
    }),
    
    type: Field.select({
      label: 'Type',
      options: [
        { label: 'Prospect', value: 'prospect', default: true },
        { label: 'Customer', value: 'customer' },
        { label: 'Partner', value: 'partner' },
      ]
    }),
    
    annual_revenue: Field.currency({
      label: 'Annual Revenue',
      scale: 2,
      min: 0,
    }),
    
    billing_address: Field.address({
      label: 'Billing Address',
    }),
    
    owner: Field.user({
      label: 'Account Owner',
      required: true,
    }),
    
    is_active: Field.boolean({
      label: 'Active',
      defaultValue: true,
    }),
  },
  
  indexes: [
    { fields: ['name'], unique: false },
    { fields: ['owner'], unique: false },
    { fields: ['type', 'is_active'], unique: false },
  ],
  
  enable: {
    trackHistory: true,
    searchable: true,
    apiEnabled: true,
    files: true,
    feeds: true,
  },
  
  validations: [
    {
      name: 'revenue_positive',
      type: 'script',
      severity: 'error',
      message: 'Revenue must be positive',
      condition: 'record.annual_revenue < 0',
    },
  ],
});

Next: Automation →

On this page