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
- Services - Core services to extend
- Architecture - Overall system design