---
title: Business Logic Guide
description: Complete guide to implementing business rules, validations, and automated processes in ObjectStack
---

# Business Logic Guide

Complete guide to implementing business rules, validations, and automated processes in ObjectStack.

## Table of Contents

1. [Validation Rules](#validation-rules)
2. [Flows](#flows)
3. [Triggers](#triggers)
4. [Approval Nodes](#approval-nodes)
5. [Formula Logic](#formula-logic)
6. [Best Practices](#best-practices)

---

## Validation Rules

Validation rules ensure data quality by preventing invalid data from being saved.

### Script Validation

Use JavaScript-like expressions to validate data:

```typescript
validations: [
  {
    name: 'close_date_future',
    type: 'script',
    severity: 'error',
    message: 'Close Date must be in the future',
    condition: 'close_date <= TODAY()',
  },
  
  {
    name: 'discount_limit',
    type: 'script',
    severity: 'warning',
    message: 'Discount exceeds 20%',
    condition: 'discount > 20',
  },
]
```

### Unique Validation

Ensure field values are unique across records:

```typescript
{
  name: 'email_unique',
  type: 'unique',
  severity: 'error',
  message: 'Email address must be unique',
  fields: ['email'],
  caseSensitive: false,
}

// Compound unique constraint
{
  name: 'account_product_unique',
  type: 'unique',
  severity: 'error',
  message: 'Product already exists for this account',
  fields: ['account', 'product'],
}
```

### Required Field Validation

Mark fields as required at the field level:

```typescript
Field.text({
  label: 'Name',
  required: true,
})

// Conditional required (use validation rule)
{
  name: 'contact_required_for_customer',
  type: 'script',
  severity: 'error',
  message: 'Contact is required for customer accounts',
  condition: 'type = "customer" AND ISBLANK(primary_contact)',
}
```

### Validation Functions

| Function | Description | Example |
|----------|-------------|---------|
| `ISBLANK(field)` | Check if field is empty | `ISBLANK(phone)` |
| `ISCHANGED(field)` | Check if field changed | `ISCHANGED(stage)` |
| `ISNEW()` | Check if record is new | `ISNEW()` |
| `PRIORVALUE(field)` | Get previous value | `PRIORVALUE(status)` |
| `AND(expr1, expr2)` | Logical AND | `AND(is_active, amount > 0)` |
| `OR(expr1, expr2)` | Logical OR | `OR(status="new", status="pending")` |
| `NOT(expr)` | Logical NOT | `NOT(is_deleted)` |
| `TODAY()` | Current date | `TODAY()` |
| `NOW()` | Current datetime | `NOW()` |

---

## Flows

Flows automate business processes as explicit node graphs. Use them for field
updates, notifications, record creation, HTTP calls, decisions, waits, screens,
subflows, and approval pauses. The old standalone Workflow Rule authoring model
is retired; model the same logic as a Flow.

### Record-change flow

```typescript
import type { Flow } from '@objectstack/spec/automation';

export const hotLeadFollowUp: Flow = {
  name: 'hot_lead_follow_up',
  label: 'Hot Lead Follow Up',
  type: 'record_change',
  status: 'active',
  trigger: {
    objectName: 'lead',
    operations: ['create'],
    condition: "record.rating == 'hot'",
  },
  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'create_task',
      type: 'create_record',
      label: 'Create Follow-up Task',
      config: {
        object: 'task',
        data: {
          subject: 'Follow up on hot lead',
          related_to: '${record.id}',
          priority: 'high',
        },
      },
    },
    {
      id: 'notify_owner',
      type: 'notify',
      label: 'Notify Owner',
      config: {
        channel: 'in_app',
        template: 'hot_lead_created',
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'create_task' },
    { id: 'e2', source: 'create_task', target: 'notify_owner' },
    { id: 'e3', source: 'notify_owner', target: 'end' },
  ],
};
```

### Scheduled flow

```typescript
export const contractExpirationCheck: Flow = {
  name: 'contract_expiration_check',
  label: 'Contract Expiration Check',
  type: 'scheduled',
  status: 'active',
  schedule: {
    type: 'cron',
    expression: '0 0 * * *',
    timezone: 'UTC',
  },
  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    { id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
    { id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'find_expiring' },
    { id: 'e2', source: 'find_expiring', target: 'notify_owners' },
    { id: 'e3', source: 'notify_owners', target: 'end' },
  ],
};
```

### Built-in node types

| Node | Use case |
|------|----------|
| `decision` | Branch based on CEL predicates |
| `assignment` | Set variables |
| `create_record`, `update_record`, `delete_record`, `get_record` | CRUD |
| `http` | Outbound HTTP call |
| `notify` | In-app/email-style notification dispatch |
| `wait`, `screen`, `approval` | Durable pauses |
| `subflow` | Invoke another flow |
| `parallel_gateway`, `join_gateway`, `boundary_event` | BPMN-style orchestration |

---

## Triggers

Advanced event-driven automation with custom logic.

### Before Trigger

Modify records before they're saved:

```typescript
import { Trigger } from '@objectstack/spec/data';

export const AccountBeforeTrigger: Trigger = {
  name: 'account_before_insert_update',
  objectName: 'account',
  timing: 'before',
  operations: ['insert', 'update'],
  
  handler: async (context) => {
    for (const record of context.records) {
      // Normalize phone numbers
      if (record.phone) {
        record.phone = normalizePhone(record.phone);
      }
      
      // Auto-populate from website
      if (!record.industry && record.website) {
        record.industry = await lookupIndustry(record.website);
      }
    }
  },
};
```

### After Trigger

Perform actions after records are saved:

```typescript
export const OpportunityAfterTrigger: Trigger = {
  name: 'opportunity_after_update',
  objectName: 'opportunity',
  timing: 'after',
  operations: ['update'],
  
  handler: async (context) => {
    const wonOpps = context.records.filter(
      r => r.stage === 'closed_won' && 
           context.oldRecords[r.id].stage !== 'closed_won'
    );
    
    for (const opp of wonOpps) {
      // Create contract
      await context.create('contract', {
        account: opp.account,
        opportunity: opp.id,
        contract_value: opp.amount,
        start_date: new Date(),
      });
      
      // Send notification
      await context.sendEmail({
        to: opp.owner.email,
        template: 'opportunity_won',
        data: { opportunity: opp },
      });
    }
  },
};
```

### Trigger Context

Available in trigger handlers:

```typescript
context = {
  records: Record[],        // New records
  oldRecords: Map<id, Record>, // Original records (update/delete)
  operation: 'insert' | 'update' | 'delete',
  timing: 'before' | 'after',
  user: User,              // Current user
  
  // Operations
  create(objectName, data),
  update(objectName, id, data),
  delete(objectName, id),
  query(objectName, filter),
  
  // Utilities
  sendEmail(options),
  callAPI(url, options),
  log(message),
}
```

---

## Approval Nodes

Approvals are authored as Flow nodes with `type: 'approval'`. The
`@objectstack/plugin-approvals` package owns the durable approval state
(`sys_approval_request` and `sys_approval_action`), record locking, approver
resolution, and resume-on-decision behavior.

```typescript
export const opportunityApproval: Flow = {
  name: 'opportunity_approval',
  label: 'Opportunity Approval',
  type: 'record_change',
  status: 'active',
  trigger: {
    objectName: 'opportunity',
    operations: ['update'],
    condition: "record.amount >= 50000 && record.stage == 'proposal'",
  },
  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'manager_approval',
      type: 'approval',
      label: 'Manager Approval',
      config: {
        approvers: [{ type: 'user', value: '${record.owner_manager_id}' }],
        behavior: 'all',
        approvalStatusField: 'approval_status',
        lockRecord: true,
      },
    },
    { id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
    { id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'manager_approval' },
    { id: 'approved', source: 'manager_approval', target: 'mark_approved', condition: "approval.status == 'approved'" },
    { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', condition: "approval.status == 'rejected'" },
    { id: 'e4', source: 'mark_approved', target: 'end' },
    { id: 'e5', source: 'mark_rejected', target: 'end' },
  ],
};
```

For multi-step approvals, chain multiple `approval` nodes. For parallel
approvals, combine `parallel_gateway`, multiple `approval` nodes, and
`join_gateway`.

---

## Formula Logic

Complex calculations and logic in formula fields.

### Conditional Logic

```typescript
// Tiered pricing
Field.formula({
  label: 'Discount Tier',
  type: 'text',
  formula: `
    IF(amount > 1000000, "Platinum",
      IF(amount > 500000, "Gold",
        IF(amount > 100000, "Silver", "Bronze")
      )
    )
  `,
})

// Status indicator
Field.formula({
  label: 'Health Score',
  type: 'text',
  formula: `
    IF(AND(is_active, days_since_contact < 30), "Healthy",
      IF(days_since_contact < 90, "At Risk", "Critical")
    )
  `,
})
```

### Date Calculations

```typescript
// Days until close
Field.formula({
  label: 'Days to Close',
  type: 'number',
  formula: 'DAYS_DIFF(close_date, TODAY())',
})

// Contract end in 30 days
Field.formula({
  label: 'Expiring Soon',
  type: 'boolean',
  formula: 'AND(status = "active", DAYS_DIFF(end_date, TODAY()) <= 30)',
})

// Age in months
Field.formula({
  label: 'Age (Months)',
  type: 'number',
  formula: 'MONTH_DIFF(TODAY(), created_date)',
})
```

### Financial Calculations

```typescript
// Gross margin
Field.formula({
  label: 'Gross Margin %',
  type: 'percent',
  formula: 'IF(revenue > 0, ((revenue - cost) / revenue) * 100, 0)',
  scale: 2,
})

// Weighted pipeline
Field.formula({
  label: 'Weighted Amount',
  type: 'currency',
  formula: 'amount * (probability / 100)',
  scale: 2,
})

// Total with tax
Field.formula({
  label: 'Total with Tax',
  type: 'currency',
  formula: 'subtotal * 1.0825', // 8.25% tax
  scale: 2,
})
```

### Text Manipulation

```typescript
// Full name
Field.formula({
  label: 'Full Name',
  type: 'text',
  formula: 'CONCAT(first_name, " ", last_name)',
})

// Email domain
Field.formula({
  label: 'Email Domain',
  type: 'text',
  formula: 'SPLIT(email, "@")[1]',
})

// Uppercase
Field.formula({
  label: 'Code',
  type: 'text',
  formula: 'UPPER(sku)',
})
```

---

## Best Practices

### 1. Validation Rules

✅ **DO:**
- Validate at the lowest level possible (field > validation rule > trigger)
- Provide clear, actionable error messages
- Use appropriate severity levels
- Test with real-world data

❌ **DON'T:**
- Duplicate validations
- Create overly complex conditions
- Block legitimate edge cases
- Use triggers for simple validations

### 2. Flows

✅ **DO:**
- Keep flows simple and focused
- Document the business logic
- Test recursion and retry behavior
- Use scheduled flows for batch updates

❌ **DON'T:**
- Create uncontrolled loops
- Update too many fields in one node
- Use triggers when a declarative flow is enough
- Mix unrelated concerns in one flow

### 3. Triggers

✅ **DO:**
- Bulkify all trigger logic
- Use before triggers for field updates
- Use after triggers for related records
- Handle errors gracefully
- Add logging for debugging

❌ **DON'T:**
- Query in loops
- Create in loops
- Perform complex calculations in triggers
- Ignore governor limits

### 4. Approval Nodes

✅ **DO:**
- Define clear entry criteria
- Set reasonable timeout periods
- Allow recall when appropriate
- Notify all stakeholders
- Track approval history

❌ **DON'T:**
- Create too many approval steps
- Make approvals too complex
- Forget rejection paths
- Hard-code approvers

### 5. Formula Fields

✅ **DO:**
- Keep formulas simple and readable
- Add comments for complex logic
- Test edge cases (null, zero, empty)
- Use appropriate data types

❌ **DON'T:**
- Create circular references
- Use formulas for frequently changing data
- Nest too many IF statements
- Ignore null handling

---

## Real-World Examples

### Lead Qualification Automation

```typescript
// Validation: Ensure valid data
validations: [
  {
    name: 'valid_email',
    type: 'script',
    severity: 'error',
    message: 'Invalid email address',
    condition: 'email != null AND !REGEX(email, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")',
  },
],

// Flow: Auto-assign hot leads
flows: [
  {
    name: 'assign_hot_leads',
    label: 'Assign Hot Leads',
    type: 'record_change',
    status: 'active',
    trigger: {
      objectName: 'lead',
      operations: ['create'],
      condition: "record.rating == 'hot' && record.lead_source == 'web'",
    },
    nodes: [
      { id: 'start', type: 'start', label: 'Start' },
      { id: 'assign_owner', type: 'assignment', label: 'Assign Owner' },
      { id: 'create_task', type: 'create_record', label: 'Create Follow-up Task' },
      { id: 'end', type: 'end', label: 'End' },
    ],
    edges: [
      { id: 'e1', source: 'start', target: 'assign_owner' },
      { id: 'e2', source: 'assign_owner', target: 'create_task' },
      { id: 'e3', source: 'create_task', target: 'end' },
    ],
  },
],

// Formula: Lead score
Field.formula({
  label: 'Lead Score',
  type: 'number',
  formula: `
    (IF(rating = "hot", 30, IF(rating = "warm", 20, 10))) +
    (IF(annual_revenue > 1000000, 25, 0)) +
    (IF(number_of_employees > 500, 20, 0)) +
    (IF(industry IN ("technology", "finance"), 15, 0))
  `,
})
```

### Opportunity Management

```typescript
// Validation: Stage progression
validations: [
  {
    name: 'valid_stage_change',
    type: 'script',
    severity: 'error',
    message: 'Cannot skip stages',
    condition: `
      AND(
        ISCHANGED(stage),
        NOT(ISNEW()),
        NOT(VALID_TRANSITION(PRIORVALUE(stage), stage))
      )
    `,
  },
],

// Flow: Update probability
flows: [
  {
    name: 'update_probability',
    label: 'Update Probability',
    type: 'record_change',
    status: 'active',
    trigger: {
      objectName: 'opportunity',
      operations: ['update'],
      condition: 'record.stage != previous.stage',
    },
    nodes: [
      { id: 'start', type: 'start', label: 'Start' },
      { id: 'update_probability', type: 'update_record', label: 'Update Probability' },
      { id: 'end', type: 'end', label: 'End' },
    ],
    edges: [
      { id: 'e1', source: 'start', target: 'update_probability' },
      { id: 'e2', source: 'update_probability', target: 'end' },
    ],
  },
],

// Approval: Large deals
// (See Approval Nodes section above)
```

---

**Next:** [Security Model →](./security)
