---
title: Schema Definition
description: The complete specification for defining objects, fields, and relationships in ObjectQL
---

import { FileCode, Database, Layers, Package } from 'lucide-react';

In ObjectStack, **data structure is configuration, not code**. The Schema Definition Protocol governs how you declare your data model using declarative `.object.yml` files.

> **Single Source of Truth:** This schema drives database DDL, API generation, UI form layouts, permission scopes, and validation rules.

## File Structure

### The .object.yml Convention

Each business entity is defined in a separate file:

```
my-app/
├── objects/
│   ├── customer.object.yml       # Customer entity
│   ├── order.object.yml          # Order entity
│   ├── product.object.yml        # Product entity
│   └── invoice.object.yml        # Invoice entity
├── objectstack.config.ts         # Package manifest
└── package.json
```

**Why separate files?**
- Version control: Git shows clear diffs when objects change
- Team collaboration: Developers can work on different objects simultaneously
- Code generation: Each file → Database table + API + UI
- Deployment: Selective schema deployment (only changed objects)

### Alternative Formats

ObjectStack supports multiple schema formats:

```yaml
# YAML (Recommended for readability)
name: customer
label: Customer

# JSON (Machine-generated)
{ "name": "customer", "label": "Customer" }
```

**TypeScript (Recommended for strict validation):**
```typescript
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Customer = ObjectSchema.create({
  name: 'customer',
  label: 'Customer',
  icon: 'building',
  
  fields: {
    name: Field.text({
      label: 'Company Name',
      required: true,
    }),
  },
});
```

> **📘 Best Practice**: Use `ObjectSchema.create()` with `Field.*` helpers in TypeScript for compile-time type checking and runtime validation.

## Object Definition

### Minimal Example

```yaml
name: project
label: Project
```

This 2-line definition creates:
- Database table `project` with system fields (`id`, `created_at`, `updated_at`)
- REST API: `GET/POST/PUT/DELETE /api/project`
- Admin UI: List view + Form
- TypeScript types

### Complete Example

```yaml
name: project
label: Project
pluralLabel: Projects
description: "A business project or initiative"
icon: standard:case
# color: "#4A90E2" # Removed or check if color exists in ObjectSchemaBase
# bucket: main # Removed or check
# datasource: postgres_primary # Removed or check
enable:
  trackHistory: true
  searchable: true
  apiEnabled: true
  activities: true # Replaces workflow?
  # offline_sync: true # Removed not in schema
fields:
  name:
    type: text
    label: Project Name
    required: true
    maxLength: 255
  status:
    type: select
    label: Status
    options:
      - { value: draft, label: Draft }
      - { value: active, label: Active }
      - { value: completed, label: Completed }
    defaultValue: draft
  budget:
    type: currency
    label: Budget
    scale: 2
    precision: 18
  account_id: # Snake case for field name
    type: lookup
    label: Account
    reference: account
    required: true
validations:
  - name: budget_positive
    condition: "budget < 0"
    message: "Budget must be positive"
```

**TypeScript Complete Example:**

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

export const Project = ObjectSchema.create({
  name: 'project',
  label: 'Project',
  pluralLabel: 'Projects',
  description: 'A business project or initiative',
  icon: 'folder',
  
  fields: {
    name: Field.text({
      label: 'Project Name',
      required: true,
      maxLength: 255,
      searchable: true,
    }),
    
    status: Field.select({
      label: 'Status',
      options: [
        { label: 'Draft', value: 'draft', default: true },
        { label: 'Active', value: 'active' },
        { label: 'Completed', value: 'completed' },
      ],
    }),
    
    budget: Field.currency({
      label: 'Budget',
      scale: 2,
      min: 0,
    }),
    
    account: Field.lookup('account', {
      label: 'Account',
      required: true,
    }),
  },
  
  enable: {
    trackHistory: true,
    searchable: true,
    apiEnabled: true,
    activities: true,
  },

  stateMachine: {
    id: 'project_lifecycle',
    initial: 'draft',
    states: {
      draft: { on: { ACTIVATE: 'active' } },
      active: { on: { COMPLETE: 'completed' } },
      completed: { type: 'final' }
    }
  },
  
  validations: [
    {
      name: 'budget_positive',
      type: 'script',
      severity: 'error',
      message: 'Budget must be positive',
      condition: 'budget < 0',
    },
  ],
});
```

### Object Properties Reference

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `name` | `string` | ✅ | **Machine name.** Must be `snake_case`, unique across the system. |
| `label` | `string` | ✅ | **Display name** for UI (singular form). |
| `pluralLabel` | `string` | ❌ | Plural form for lists. Defaults to `label + "s"`. |
| `description` | `string` | ❌ | Human-readable description for documentation. |
| `icon` | `string` | ❌ | Icon identifier (e.g., `standard:case`, `custom:project`). |
| `datasource` | `string` | ❌ | External datasource ID. If omitted, uses default database. |
| `enable` | `object` | ❌ | Feature flags (see [Capabilities](#capabilities)). |
| `fields` | `object` | ❌ | Field definitions (see [Field Definition](#field-definition)). |
| `stateMachine` | `object` | ❌ | Lifecycle state machine definition (see [State Machine](./state-machine.mdx)). |
| `validations` | `array` | ❌ | Business validation rules (see [Validation](#validation-rules)). |
| `triggers` | `array` | ❌ | Database triggers (before/after insert/update/delete). |
| `indexes` | `array` | ❌ | Composite indexes for query optimization. |

### Naming Conventions

**Object Names (Machine Identifiers):**
- **Format:** `snake_case` (lowercase, underscores)
- **Pattern:** `/^[a-z][a-z0-9_]*$/`
- **Examples:** ✅ `customer`, `project_task`, `sales_order`
- **Invalid:** ❌ `Customer`, `projectTask`, `123project`

**Object Labels (Display Names):**
- **Format:** Title Case, human-readable
- **Examples:** ✅ `Customer`, `Project Task`, `Sales Order`

### Capabilities

The `enable` object controls which features are active:

```yaml
enable:
  # Data Management
  trackHistory: true         # Track field history (who changed what, when)
  searchable: true           # Index for global search
  trash: true                # Enable soft-delete with restore capability
  
  # API & Integration
  apiEnabled: true           # Generate REST/GraphQL endpoints
  
  # Collaboration & Activities
  files: true                # Enable file attachments
  feeds: true                # Enable social feed, comments
  activities: true           # Enable tasks and events
  
  # User Experience
  mru: true                  # Track Most Recently Used list
  clone: true                # Allow record deep cloning
```

**Performance Impact:**
- `trackHistory: true` adds write overhead (additional inserts to audit table)
- `searchable: true` adds search indexes (storage overhead)

**When to enable:**
- **trackHistory:** Regulated industries, compliance requirements
- **searchable:** User-facing search features

## Field Definition

Fields are the columns/attributes of your object. Each field has a type and configuration.

### Basic Field Structure

```yaml
fields:
  field_name:
    type: text              # Field type (required)
    label: Field Label      # Display label (required)
    required: false         # Validation
    defaultValue: null      # Default when creating records
    description: "Helper text for users"
```

### Field Properties Reference

| Property | Type | Applies To | Description |
|----------|------|------------|-------------|
| `type` | `string` | All | **Required.** Field type (see [Types](/docs/protocols/objectql/types)). |
| `label` | `string` | All | **Required.** Display label in UI. |
| `required` | `boolean` | All | Validation: Field must have a value. |
| `unique` | `boolean` | All | Enforce uniqueness at database level. |
| `searchable` | `boolean` | All | Is searchable. |
| `defaultValue` | `any` | All | Default value when creating new records. |
| `description` | `string` | All | Tooltip/Help text. |
| `maxLength` | `number` | `text`, `textarea` | Maximum character length. |
| `minLength` | `number` | `text`, `textarea` | Minimum character length. |
| `min` | `number` | `number`, `currency` | Minimum numeric value. |
| `max` | `number` | `number`, `currency` | Maximum numeric value. |
| `scale` | `number` | `number`, `currency` | Decimal places (e.g., `2` for cents). |
| `precision` | `number` | `number`, `currency` | Total digits (including scale). |
| `options` | `array` | `select`, `multiselect` | List of valid values. |
| `multiple` | `boolean` | `select`, `lookup` | Allow multiple selections. |
| `reference` | `string` | `lookup`, `master_detail` | Target object for relationships. |
| `expression` | `string` | `formula` | Calculation expression. |
| `summaryOperations` | `object` | `summary` | Roll-up summary definition. |

### System Fields

Every object automatically includes system fields:

```yaml
# Auto-generated (not defined in .object.yml)
id:
  type: id
  label: Record ID
  read_only: true

created_at:
  type: datetime
  label: Created At
  read_only: true

updated_at:
  type: datetime
  label: Updated At
  read_only: true

created_by:
  type: lookup
  reference: user
  label: Created By
  # readonly: true # Managed by system

updated_by:
  type: lookup
  reference: user
  label: Updated By
  # readonly: true # Managed by system
```

**Customizing system fields:**
```yaml
# Override system field behavior
fields:
  created_at:
    searchable: true  # Add to search index
```

### Field Examples by Type

#### Text Fields

```yaml
company_name:
  type: text
  label: Company Name
  required: true
  maxLength: 255
  searchable: true

description:
  type: textarea
  label: Description
  maxLength: 5000
  # rows: 10 # UI hint, moved to UI protocol or check if supported

bio:
  type: html
  label: Biography
  # sanitize: true  # Remove dangerous HTML tags (Check security config)
```

#### Numeric Fields

```yaml
quantity:
  type: number
  label: Quantity
  min: 0
  max: 9999
  defaultValue: 1

discount_rate:
  type: percent
  label: Discount
  scale: 2
  min_value: 0
  max_value: 100

revenue:
  type: currency
  label: Annual Revenue
  scale: 2
  precision: 18
  default_value: { amount: 0, currency: 'USD' }
```

#### Date/Time Fields

```yaml
start_date:
  type: date
  label: Start Date
  required: true

due_datetime:
  type: datetime
  label: Due Date & Time
  default_value: "{{NOW() + 7}}"  # 7 days from now
```

#### Boolean Fields

```yaml
is_active:
  type: boolean
  label: Active
  default_value: true

email_opt_in:
  type: checkbox
  label: Subscribe to Newsletter
  default_value: false
```

#### Selection Fields

```yaml
priority:
  type: select
  label: Priority
  options:
    - { value: low, label: Low, color: green }
    - { value: medium, label: Medium, color: yellow }
    - { value: high, label: High, color: orange }
    - { value: critical, label: Critical, color: red }
  default_value: medium

tags:
  type: multi_select
  label: Tags
  options:
    - { value: customer, label: Customer }
    - { value: partner, label: Partner }
    - { value: vendor, label: Vendor }
  multiple: true
```

#### Relationship Fields

```yaml
account_id:
  type: lookup
  label: Account
  reference_to: account
  required: true
  reference_filters:
    - ['is_active', '=', true]  # Only show active accounts

project_id:
  type: master_detail
  label: Project
  reference_to: project
  cascade_delete: true  # Delete tasks when project is deleted
```

## Validation Rules

Validation rules enforce business logic at the data layer:

```yaml
validation_rules:
  # Simple comparison
  - name: end_after_start
    condition: "end_date < start_date"
    message: "End date must be after start date"
    severity: error
  
  # Cross-field validation
  - name: discount_requires_approval
    condition: "discount > 20 AND approved_by = null"
    message: "Discounts over 20% require manager approval"
    severity: error
  
  # Conditional validation
  - name: enterprise_contract_required
    condition: "account_type = 'Enterprise' AND contract_value = null"
    message: "Enterprise accounts must have a contract value"
    severity: error
    active: true
  
  # Warning (non-blocking)
  - name: budget_threshold_warning
    condition: "budget > 1000000"
    message: "Large budget. Please verify approval."
    severity: warning
```

### Validation Formula Syntax

Formulas use Excel-like syntax:

**Operators:**
- Comparison: `=`, `!=`, `>`, `<`, `>=`, `<=`
- Logical: `AND`, `OR`, `NOT`
- Arithmetic: `+`, `-`, `*`, `/`, `^` (power)
- String: `&` (concatenate)

**Functions:**
- `ISBLANK(field)`: Check if field is empty
- `LEN(field)`: String length
- `CONTAINS(field, "text")`: Substring check
- `REGEX(field, "pattern")`: Regular expression
- `TODAY()`: Current date
- `NOW()`: Current datetime

**Examples:**
```yaml
# Date validation
"start_date > TODAY()"

# String validation
"LEN(email) > 0 AND CONTAINS(email, '@')"

# Null check
"NOT(ISBLANK(manager_id))"

# Complex logic
"(status = 'Closed' AND close_date != null) OR status != 'Closed'"
```

## Indexes

Optimize query performance with indexes:

```yaml
indexes:
  # Single-field index (also via field.index: true)
  - fields: [email]
    unique: true
  
  # Composite index
  - fields: [account_id, status]
    name: idx_account_status
  
  # Full-text search index
  - fields: [name, description]
    type: fulltext
  
  # Geospatial index
  - fields: [location]
    type: geo
```

**Index Strategy:**
- **Add indexes for:**
  - Foreign keys (lookup fields)
  - Fields used in `WHERE` clauses
  - Fields used in `ORDER BY`
  - Unique constraints
- **Avoid indexes for:**
  - Low-cardinality fields (boolean, status with 2-3 values)
  - Fields that change frequently
  - Large text fields (use full-text search instead)

## Triggers

Execute code on data changes:

```yaml
triggers:
  # Before insert
  - event: before_insert
    handler: set_owner
    code: |
      record.owner_id = context.user.id;
  
  # After insert
  - event: after_insert
    handler: send_welcome_email
    async: true
    code: |
      await emailService.send({
        to: record.email,
        template: 'welcome'
      });
  
  # Before update
  - event: before_update
    handler: validate_status_change
    condition: "old.status != new.status"
    code: |
      if (old.status === 'Closed' && new.status !== 'Closed') {
        throw new Error('Cannot reopen closed records');
      }
```

**Event Types:**
- `before_insert`: Before creating a record
- `after_insert`: After creating a record
- `before_update`: Before updating a record
- `after_update`: After updating a record
- `before_delete`: Before deleting a record
- `after_delete`: After deleting a record

## Advanced Features

### Inheritance (Mixins)

Reuse field definitions across objects:

```yaml
# mixins/auditable.mixin.yml
fields:
  created_at:
    type: datetime
    label: Created At
    # readonly: true
  updated_at:
    type: datetime
    label: Updated At
    # readonly: true

# customer.object.yml
name: customer
extends: [auditable]  # Inherit fields from mixin
fields:
  company_name:
    type: text
```

### Polymorphic References

Reference multiple object types:

```yaml
# activity.object.yml
parent_id:
  type: polymorphic
  label: Related To
  reference: [account, contact, opportunity]
```

Query polymorphic fields:
```typescript
const activity = await ObjectQL.findOne('activity', id);
// activity.parent_id = { id: '123', _type: 'account' }
```

### Computed Fields (Virtual)

Fields calculated at query time (not stored):

```yaml
full_name:
  type: formula
  label: Full Name
  expression: "first_name & ' ' & last_name" # formula -> expression
  # virtual: true  # Not stored in database (Implied by formula type?)
```

### Multi-Tenant Schemas

Isolate data by tenant:

```yaml
name: customer
tenancy:
  enabled: true  # Automatically filter by tenant_id
fields:
  tenant_id:
    type: lookup
    reference: tenant
    required: true
    defaultValue: "{{CURRENT_TENANT()}}"
```

**Runtime behavior:**
```typescript
// User in Tenant A queries customers
const customers = await ObjectQL.query({ object: 'customer' });

// ObjectQL automatically adds: WHERE tenant_id = 'tenant_a'
// User CANNOT access Tenant B's data
```

## Schema Versioning

### Migration Strategy

When schemas change, ObjectQL generates migrations:

**Before:**
```yaml
# v1: customer.object.yml
fields:
  name:
    type: text
```

**After:**
```yaml
# v2: customer.object.yml
fields:
  first_name:
    type: text
    migration:
      from: name
      transform: "SPLIT(name, ' ')[0]"
  
  last_name:
    type: text
    migration:
      from: name
      transform: "SPLIT(name, ' ')[1]"
```

**Generated migration:**
```sql
-- PostgreSQL
ALTER TABLE customer ADD COLUMN first_name TEXT;
ALTER TABLE customer ADD COLUMN last_name TEXT;

UPDATE customer SET
  first_name = SPLIT_PART(name, ' ', 1),
  last_name = SPLIT_PART(name, ' ', 2);

ALTER TABLE customer DROP COLUMN name;
```

### Deployment Workflow

```bash
# 1. Detect schema changes
objectstack diff

# 2. Generate migration plan
objectstack migrate:plan

# 3. Review migration SQL
cat .objectstack/migrations/001_add_customer_name.sql

# 4. Apply migration
objectstack deploy --migrate

# 5. Rollback if needed
objectstack migrate:rollback
```

## Best Practices

### Naming Conventions

```yaml
# ✅ Good
name: project_task
fields:
  assigned_to_id:  # Suffix _id for lookups
    type: lookup
  is_active:       # Prefix is_ for booleans
    type: boolean
  total_amount:    # Descriptive, specific
    type: currency

# ❌ Bad
name: ProjectTask  # Not snake_case
fields:
  user:            # Ambiguous (assigned? created?)
    type: lookup
  active:          # Missing is_ prefix
    type: boolean
  amount:          # Too generic
    type: currency
```

### Field Organization

Group related fields:

```yaml
fields:
  # Identity
  name:
    type: text
  description:
    type: textarea
  
  # Relationships
  account_id:
    type: lookup
  owner_id:
    type: lookup
  
  # Status & Lifecycle
  status:
    type: select
  stage:
    type: select
  
  # Financial
  budget:
    type: currency
  actual_cost:
    type: currency
```

### Performance Optimization

```yaml
# Index frequently queried fields
email:
  type: text
  index: true
  unique: true

# Use appropriate field types
status:
  type: select  # Better than text for fixed values
  options: [...]

# Avoid large text in main table
file_content:
  type: text
  store: blob_storage  # Store in separate table/S3
```

## Examples: Real-World Schemas

### CRM: Account Object

```yaml
name: account
label: Account
pluralLabel: Accounts
icon: standard:account
enable:
  trackHistory: true
  searchable: true
  apiEnabled: true

fields:
  # Company Information
  company_name:
    type: text
    label: Company Name
    required: true
    maxLength: 255
    searchable: true
  
  website:
    type: url
    label: Website
  
  industry:
    type: select
    label: Industry
    options:
      - { value: tech, label: Technology }
      - { value: finance, label: Financial Services }
      - { value: healthcare, label: Healthcare }
      - { value: retail, label: Retail }
  
  # Contact Info
  billing_address:
    type: address
    label: Billing Address
  
  phone:
    type: phone
    label: Phone
  
  # Financial
  annual_revenue:
    type: currency
    label: Annual Revenue
    scale: 2
    precision: 18
  
  # Relationships
  owner_id:
    type: lookup
    label: Account Owner
    reference: user
    required: true
  
  parent_account_id:
    type: lookup
    label: Parent Account
    reference: account
  
  # Metrics (Computed)
  total_opportunities:
    type: summary
    label: Total Opportunities
    summaryOperations:
      object: opportunity
      function: count
  
  total_opportunity_value:
    type: summary
    label: Total Opportunity Value
    summaryOperations:
      object: opportunity
      field: amount
      function: sum

validations:
  - name: enterprise_revenue_required
    condition: "industry = 'finance' AND annual_revenue = null"
    message: "Financial services accounts must have revenue"
```

### E-Commerce: Product Object

```yaml
name: product
label: Product
icon: standard:product
datasource: mongodb_catalog  # Use MongoDB for flexible schema

fields:
  sku:
    type: text
    label: SKU
    required: true
    unique: true
    searchable: true
  
  name:
    type: text
    label: Product Name
    required: true
    maxLength: 255
  
  description:
    type: html
    label: Description
  
  category_id:
    type: lookup
    label: Category
    reference: category
  
  price:
    type: currency
    label: Price
    required: true
  
  inventory_qty:
    type: number
    label: Inventory Quantity
    min: 0
  
  attributes:
    type: json
    label: Product Attributes
    # schema: ... # Advanced JSON schema if supported
  
  is_active:
    type: boolean
    label: Active
    defaultValue: true

indexes:
  - fields: [category_id, is_active]
  - fields: [name, description]
    type: fulltext
```

## Next Steps

<Cards>
  <Card
    title="Type System"
    href="/docs/protocols/objectql/types"
    description="Learn all 20+ field types: scalars, relationships, formulas"
  />
  <Card
    title="Query Syntax"
    href="/docs/protocols/objectql/query-syntax"
    description="Write database-agnostic queries with filters and joins"
  />
  <Card
    title="Security"
    href="/docs/protocols/objectql/security"
    description="Implement row-level and field-level access control"
  />
</Cards>
