Skip to main content

Extending the Business Rules Engine

The Business Rules module is designed for extensibility. Add custom condition operators, action types, value resolvers, and integrations without modifying core code.

Extension Points

1. Custom Action Types

Add new action types that execute custom logic.

Use case: Integrate with external systems, implement custom workflows, add proprietary business logic.

Implementation:

// In your module's di.ts or service file
import { registerActionHandler } from '@/modules/business_rules/lib/action-executor'

// Register custom action handler
registerActionHandler('SEND_SLACK_MESSAGE', async (config, context, em) => {
const { channel, message } = config

// Resolve interpolated values
const resolvedMessage = interpolate(message, context.data)

// Call Slack API
await slackClient.chat.postMessage({
channel,
text: resolvedMessage
})

return {
success: true,
output: { messageSent: true, channel }
}
})

Usage in rules:

{
"type": "SEND_SLACK_MESSAGE",
"config": {
"channel": "#alerts",
"message": "Work order {{workOrderId}} blocked: {{reason}}"
}
}

Handler Signature:

type ActionHandler = (
config: Record<string, any>, // Action configuration
context: ActionContext, // Execution context
em: EntityManager // Database access
) => Promise<ActionResult>

interface ActionResult {
success: boolean
output?: any
error?: string
}

2. Custom Condition Operators

Add new comparison operators for condition evaluation.

Use case: Domain-specific comparisons, custom validation logic, complex field checks.

Implementation:

// In your module's di.ts
import { registerOperator } from '@/modules/business_rules/lib/rule-evaluator'

// Register custom operator
registerOperator('WITHIN_RANGE', (actualValue, expectedValue) => {
const [min, max] = expectedValue // Expected: [10, 100]
return actualValue >= min && actualValue <= max
})

registerOperator('IS_BUSINESS_DAY', (actualValue, expectedValue) => {
const date = new Date(actualValue)
const day = date.getDay()
return day >= 1 && day <= 5 // Monday-Friday
})

registerOperator('MATCHES_PATTERN', (actualValue, expectedValue) => {
// expectedValue is a pattern name like "EMAIL", "PHONE"
const patterns = {
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
PHONE: /^\d{10}$/,
ZIP: /^\d{5}(-\d{4})?$/
}
return patterns[expectedValue]?.test(actualValue) ?? false
})

Usage in rules:

{
"field": "quantity",
"operator": "WITHIN_RANGE",
"value": [10, 100]
}

{
"field": "orderDate",
"operator": "IS_BUSINESS_DAY",
"value": null
}

{
"field": "email",
"operator": "MATCHES_PATTERN",
"value": "EMAIL"
}

Operator Signature:

type OperatorFunction = (
actualValue: any, // Value from entity data
expectedValue: any // Value from condition
) => boolean // True if condition passes

3. Custom Value Resolvers

Extend value resolution for special field paths.

Use case: Dynamic values, external data lookups, calculated fields.

Implementation:

// In your module's di.ts
import { registerValueResolver } from '@/modules/business_rules/lib/value-resolver'

// Register custom resolver for special field paths
registerValueResolver('weather', async (path, context, em) => {
// path = "weather.temperature"
const location = context.data.location
const weather = await fetchWeatherData(location)
return weather.temperature
})

registerValueResolver('inventory', async (path, context, em) => {
// path = "inventory.partNumber"
const partNumber = resolveValue('partNumber', context.data)
const inventory = await em.findOne(Inventory, { partNumber })
return inventory?.quantity ?? 0
})

Usage in conditions:

{
"field": "weather.temperature",
"operator": ">",
"value": 30
}

{
"field": "inventory.partNumber",
"operator": ">",
"value": 10
}

4. Rule Discovery Hooks

Customize which rules are discovered for execution.

Use case: Dynamic rule filtering, A/B testing, feature flags.

Implementation:

// In your module's di.ts
import { registerDiscoveryHook } from '@/modules/business_rules/lib/rule-engine'

registerDiscoveryHook(async (rules, context, em) => {
// Filter rules based on custom logic
const filteredRules = rules.filter(rule => {
// Example: Only include rules for user's tier
if (rule.ruleCategory === 'PREMIUM') {
return context.user?.tier === 'PREMIUM'
}
return true
})

return filteredRules
})

5. Execution Middleware

Hook into rule execution lifecycle.

Use case: Logging, metrics, caching, rate limiting.

Implementation:

// In your module's di.ts
import { registerExecutionMiddleware } from '@/modules/business_rules/lib/rule-engine'

registerExecutionMiddleware('beforeExecution', async (rule, context, em) => {
// Called before each rule executes
console.log(`Executing rule: ${rule.ruleId}`)

// Record metric
await metrics.increment('business_rules.executions', {
ruleId: rule.ruleId,
ruleType: rule.ruleType
})
})

registerExecutionMiddleware('afterExecution', async (rule, result, context, em) => {
// Called after each rule executes
if (result.error) {
await alerting.send({
message: `Rule ${rule.ruleId} failed: ${result.error}`
})
}
})

Common Extension Patterns

External System Integration

Integrate with external APIs via custom actions:

// Salesforce integration
registerActionHandler('SYNC_TO_SALESFORCE', async (config, context, em) => {
const { objectType, fields } = config

await salesforceClient.create(objectType, {
...fields,
ExternalId__c: context.entityId
})

return { success: true }
})

// Webhook with retry
registerActionHandler('RELIABLE_WEBHOOK', async (config, context, em) => {
const { url, payload } = config

const resolvedPayload = interpolate(payload, context.data)

await retry(
() => axios.post(url, JSON.parse(resolvedPayload)),
{ retries: 3, delay: 1000 }
)

return { success: true }
})

Domain-Specific Operators

Add operators for your domain:

// Manufacturing domain
registerOperator('HAS_CERTIFICATIONS', (actualValue, expectedValue) => {
// actualValue: operator's certifications array
// expectedValue: required certifications array
return expectedValue.every(cert => actualValue.includes(cert))
})

// Financial domain
registerOperator('CREDIT_RATING_ABOVE', (actualValue, expectedValue) => {
const ratings = { 'AAA': 10, 'AA': 9, 'A': 8, 'BBB': 7, /* ... */ }
return (ratings[actualValue] ?? 0) >= (ratings[expectedValue] ?? 0)
})

// Geographic domain
registerOperator('WITHIN_RADIUS', (actualValue, expectedValue) => {
const [lat, lng, radiusMiles] = expectedValue
return calculateDistance(actualValue, [lat, lng]) <= radiusMiles
})

Async Data Loading

Load related data during condition evaluation:

registerValueResolver('customer', async (path, context, em) => {
const customerId = resolveValue('customerId', context.data)
const customer = await em.findOne(Customer, { id: customerId })

// path might be "customer.creditLimit"
const field = path.split('.')[1]
return customer?.[field]
})

Now conditions can reference customer data:

{
"field": "customer.creditLimit",
"operator": ">",
"value": 10000
}

Calculated Field Resolvers

Provide calculated values:

registerValueResolver('calculated', (path, context) => {
switch (path) {
case 'calculated.orderAge':
const orderDate = new Date(context.data.orderDate)
return Math.floor((Date.now() - orderDate.getTime()) / (1000 * 60 * 60 * 24))

case 'calculated.orderTotal':
return context.data.items.reduce((sum, item) =>
sum + (item.quantity * item.unitPrice), 0
)

default:
return undefined
}
})

Testing Extensions

Test custom handlers in isolation:

describe('Custom Action: SEND_SLACK_MESSAGE', () => {
it('should send slack message with interpolation', async () => {
const handler = getActionHandler('SEND_SLACK_MESSAGE')

const result = await handler(
{
channel: '#alerts',
message: 'Order {{orderId}} total: {{total}}'
},
{
entityType: 'Order',
entityId: 'order-123',
data: { orderId: 'order-123', total: 1500 },
tenantId: 'tenant-1',
organizationId: 'org-1'
},
em
)

expect(result.success).toBe(true)
expect(slackClient.chat.postMessage).toHaveBeenCalledWith({
channel: '#alerts',
text: 'Order order-123 total: 1500'
})
})
})

Best Practices

Keep extensions simple: Complex logic should live in services, not handlers

Handle errors gracefully: Return { success: false, error: 'message' } instead of throwing

Use dependency injection: Resolve services from container instead of importing directly

Document custom extensions: Add comments explaining what custom handlers do

Version your extensions: If changing behavior, version the action type (e.g., SEND_SMS_V2)

Test thoroughly: Extensions run in production rule execution—test all edge cases

Monitor performance: Custom handlers can slow rule execution—add timeouts and metrics

Registry Pattern

Organize extensions in a registry:

// extensions/registry.ts
export function registerBusinessRuleExtensions(container: AwilixContainer) {
// Actions
registerActionHandler('SEND_SLACK_MESSAGE', createSlackHandler(container))
registerActionHandler('SYNC_TO_SALESFORCE', createSalesforceHandler(container))
registerActionHandler('CREATE_JIRA_TICKET', createJiraHandler(container))

// Operators
registerOperator('WITHIN_RANGE', withinRangeOperator)
registerOperator('HAS_CERTIFICATIONS', hasCertificationsOperator)

// Value resolvers
registerValueResolver('customer', createCustomerResolver(container))
registerValueResolver('inventory', createInventoryResolver(container))

// Middleware
registerExecutionMiddleware('beforeExecution', metricsMiddleware)
registerExecutionMiddleware('afterExecution', alertingMiddleware)
}

Then call in your DI setup:

// src/di.ts
import { registerBusinessRuleExtensions } from './extensions/registry'

export function setupContainer() {
const container = createContainer()

// ... other setup

registerBusinessRuleExtensions(container)

return container
}

Next Steps