Complete guide to implementing business rules, validations, and automated processes in ObjectStack.
Validation rules ensure data quality by preventing invalid data from being saved.
Use JavaScript-like expressions to validate data:
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',
},
]Ensure field values are unique across records:
{
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'],
}Mark fields as required at the field level:
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)',
}| 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() |
Workflow rules automate standard internal procedures and processes to save time.
Automatically update fields when conditions are met:
workflows: [
{
name: 'update_last_activity',
objectName: 'account',
triggerType: 'on_update',
criteria: 'ISCHANGED(owner) OR ISCHANGED(type)',
actions: [
{
name: 'set_activity_date',
type: 'field_update',
field: 'last_activity_date',
value: 'TODAY()',
}
],
active: true,
}
]Send emails when conditions are met:
{
name: 'notify_high_value_opportunity',
objectName: 'opportunity',
triggerType: 'on_create',
criteria: 'amount > 100000',
actions: [
{
name: 'notify_manager',
type: 'email_alert',
template: 'high_value_opportunity',
recipients: ['{owner.manager}'],
}
],
active: true,
}Automatically create tasks:
{
name: 'create_follow_up_task',
objectName: 'lead',
triggerType: 'on_create',
criteria: 'rating = "hot"',
actions: [
{
name: 'create_task',
type: 'task_create',
subject: 'Follow up on hot lead: {name}',
relatedTo: '{id}',
assignedTo: '{owner}',
dueDate: '{TODAY() + 1}',
priority: 'high',
}
],
active: true,
}Run workflows on a schedule:
{
name: 'contract_expiration_check',
objectName: 'contract',
triggerType: 'scheduled',
schedule: '0 0 * * *', // Daily at midnight
criteria: 'end_date <= TODAY() AND status = "activated"',
actions: [
{
name: 'mark_expired',
type: 'field_update',
field: 'status',
value: '"expired"',
},
{
name: 'notify_owner',
type: 'email_alert',
template: 'contract_expired',
recipients: ['{owner}'],
}
],
active: true,
}| Type | When It Fires | Use Case |
|---|---|---|
on_create |
Record created | Welcome emails, initial tasks |
on_update |
Record updated | Status changes, notifications |
on_delete |
Record deleted | Cleanup, archival |
on_read |
Record viewed | Lazy updates, calculations |
scheduled |
Cron schedule | Batch processing, cleanup |
Advanced event-driven automation with custom logic.
Modify records before they're saved:
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);
}
}
},
};Perform actions after records are saved:
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 },
});
}
},
};Available in trigger handlers:
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),
}Multi-step approval workflows for business processes.
Single-step approval:
{
name: 'opportunity_approval',
objectName: 'opportunity',
triggerType: 'manual',
entryConditions: 'amount > 50000',
steps: [
{
name: 'manager_approval',
approver: '{owner.manager}',
approverType: 'user',
emailTemplate: 'approval_request',
requiresComments: true,
approvalActions: [
{
type: 'field_update',
field: 'approval_status',
value: '"approved"',
}
],
rejectionActions: [
{
type: 'field_update',
field: 'approval_status',
value: '"rejected"',
},
{
type: 'email_alert',
template: 'approval_rejected',
recipients: ['{owner}'],
}
],
}
],
finalApprovalActions: [
{
type: 'field_update',
field: 'stage',
value: '"proposal"',
}
],
finalRejectionActions: [
{
type: 'field_update',
field: 'stage',
value: '"qualification"',
}
],
}Sequential approval with multiple approvers:
{
name: 'contract_approval',
objectName: 'contract',
triggerType: 'manual',
steps: [
// Step 1: Sales Manager
{
name: 'sales_manager',
approver: '{owner.manager}',
emailTemplate: 'contract_approval_manager',
},
// Step 2: Legal Review
{
name: 'legal_review',
approver: '{legal_team}',
approverType: 'queue',
emailTemplate: 'contract_approval_legal',
requiresComments: true,
},
// Step 3: Finance Approval
{
name: 'finance_approval',
approver: '{finance_director}',
condition: 'contract_value > 100000',
emailTemplate: 'contract_approval_finance',
},
],
}Multiple approvers must approve:
{
name: 'discount_approval',
objectName: 'quote',
steps: [
{
name: 'dual_approval',
approverType: 'all',
approvers: [
'{sales_manager}',
'{pricing_manager}',
],
emailTemplate: 'discount_approval_request',
}
],
}Complex calculations and logic in formula fields.
// 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")
)
`,
})// 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)',
})// 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,
})// 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)',
})✅ 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
✅ DO:
- Keep workflows simple and focused
- Document the business logic
- Test workflow recursion
- Use scheduled workflows for batch updates
❌ DON'T:
- Create workflow loops
- Update too many fields in one workflow
- Use workflows for complex logic (use triggers)
- Mix concerns in one workflow
✅ 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
✅ 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
✅ 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
// 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,}$")',
},
],
// Workflow: Auto-assign hot leads
workflows: [
{
name: 'assign_hot_leads',
triggerType: 'on_create',
criteria: 'rating = "hot" AND lead_source = "web"',
actions: [
{
type: 'field_update',
field: 'owner',
value: 'GET_ROUND_ROBIN_USER("sales_team")',
},
{
type: 'task_create',
subject: 'Contact hot lead: {name}',
dueDate: '{NOW() + 2 hours}',
},
],
},
],
// 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))
`,
})// 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))
)
`,
},
],
// Workflow: Update probability
workflows: [
{
name: 'update_probability',
triggerType: 'on_update',
criteria: 'ISCHANGED(stage)',
actions: [
{
type: 'field_update',
field: 'probability',
value: 'GET_STAGE_PROBABILITY(stage)',
},
],
},
],
// Approval: Large deals
// (See Approval Processes section above)Next: UI Design →