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
| Property | Type | Description | Example |
|---|---|---|---|
name | string | Machine name (snake_case) | 'account' |
label | string | Display name | 'Account' |
pluralLabel | string | Plural display name | 'Accounts' |
icon | string | Icon identifier | 'building' |
description | string | Help text | 'Companies...' |
nameField | string | Canonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias displayNameField is still accepted | 'name' |
titleFormat | string | Deprecated (ADR-0079 → nameField). Render-only title template ({{record.field}} interpolation); an explicit nameField now takes precedence | '{{record.name}} - {{record.id}}' |
highlightFields | string[] | 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.fieldGroupsdeclares the groups; array order is the display order (no separateorderproperty).Field.groupassigns a field to a group by referencing anObjectFieldGroup.key. In-group display order equals the traversal order offields.- Fields whose
groupis 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
| Property | Type | Description |
|---|---|---|
key | string (snake_case) | Group machine key; referenced by Field.group |
label | string | Human-readable group header |
icon | string? | Lucide/Material icon name for the group header |
description | string? | 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_casefor field names:first_name,account_number - Use descriptive names:
annual_revenuenotrev - Use boolean prefixes:
is_active,has_children
❌ DON'T:
- Use camelCase for field names
- Use abbreviations:
acc_num - Add suffixes to lookups:
account_id(useaccount)
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 →