Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 081 / 100

The rules engine pattern: a hands-on guide for engineers

Business rules that business users can change.

Expona Admin8 min read

Introduction

You've probably faced a situation where business requirements change faster than your deployment pipeline can handle: a pricing rule shifts, fraud detection logic evolves, or a new market demands different shipping eligibility. The rules engine pattern: a hands-on guide for engineers addresses exactly this friction by decoupling business logic from code. Instead of redeploying your application every time a product manager wants to adjust a rule, you externalize conditions and actions as data, evaluate them through a generic engine, and let non-technical stakeholders modify behaviour safely.

In this article, we'll walk through building and deploying a rules engine in production, drawing from real experience across e-commerce, SaaS, and trading systems.

Rules defined as data: conditions and actions

The core idea is simple: represent business rules as structured data objects rather than hardcoded conditionals. Each rule consists of a condition (when to apply) and one or more actions (what to do).

Consider an e-commerce platform offering tiered discounts. Instead of writing:

typescript
if (order.totalAmount > 100 && order.country === 'US' && order.customer.isVIP) {
  order.discount = order.totalAmount * 0.15;
}

You define a rule as data:

typescript
interface Rule {
  id: string;
  name: string;
  enabled: boolean;
  priority: number;
  condition: Condition;
  actions: Action[];
  createdAt: Date;
  updatedAt: Date;
  version: number;
}

interface Condition {
  type: 'all' | 'any'; // logical AND or OR
  operators: Operator[];
}

interface Operator {
  field: string;           // e.g., 'order.totalAmount'
  comparison: string;      // e.g., 'greaterThan', 'equals', 'in'
  value: any;
}

interface Action {
  type: string;            // e.g., 'applyDiscount', 'flagForReview'
  params: Record<string, any>;
}

const vipDiscountRule: Rule = {
  id: 'rule-vip-discount-001',
  name: 'VIP tier discount',
  enabled: true,
  priority: 10,
  condition: {
    type: 'all',
    operators: [
      { field: 'order.totalAmount', comparison: 'greaterThan', value: 100 },
      { field: 'order.country', comparison: 'equals', value: 'US' },
      { field: 'customer.isVIP', comparison: 'equals', value: true }
    ]
  },
  actions: [
    {
      type: 'applyDiscount',
      params: { percentage: 15 }
    }
  ],
  createdAt: new Date(),
  updatedAt: new Date(),
  version: 1
};

This representation lets you store rules in a database, version them like code, and modify them without deploying. A product manager can update the discount threshold or eligibility criteria through an admin UI while engineers handle the engine logic.

Evaluated by a generic engine

The rules engine is the runtime that evaluates all rules against a context object and executes matching actions. Here's a practical implementation:

typescript
class RulesEngine {
  private rules: Rule[] = [];

  constructor(rules: Rule[]) {
    // Sort by priority descending
    this.rules = rules
      .filter(r => r.enabled)
      .sort((a, b) => b.priority - a.priority);
  }

  evaluate(context: Record<string, any>): Action[] {
    const matchedActions: Action[] = [];

    for (const rule of this.rules) {
      if (this.evaluateCondition(rule.condition, context)) {
        matchedActions.push(...rule.actions);
      }
    }

    return matchedActions;
  }

  private evaluateCondition(condition: Condition, context: Record<string, any>): boolean {
    const results = condition.operators.map(op => this.evaluateOperator(op, context));

    if (condition.type === 'all') {
      return results.every(r => r);
    }
    return results.some(r => r);
  }

  private evaluateOperator(operator: Operator, context: Record<string, any>): boolean {
    const fieldValue = this.getNestedValue(context, operator.field);

    switch (operator.comparison) {
      case 'equals':
        return fieldValue === operator.value;
      case 'greaterThan':
        return fieldValue > operator.value;
      case 'lessThan':
        return fieldValue < operator.value;
      case 'in':
        return Array.isArray(operator.value) && operator.value.includes(fieldValue);
      case 'contains':
        return String(fieldValue).includes(operator.value);
      default:
        return false;
    }
  }

  private getNestedValue(obj: Record<string, any>, path: string): any {
    return path.split('.').reduce((current, key) => current?.[key], obj);
  }
}

In use, you'd load rules from your database and evaluate an order or user context:

typescript
const rules = await ruleRepository.findActive();
const engine = new RulesEngine(rules);

const orderContext = {
  order: { totalAmount: 250, country: 'US' },
  customer: { isVIP: true, id: 'cust-123' }
};

const matchedActions = engine.evaluate(orderContext);
// matchedActions includes the applyDiscount action

matchedActions.forEach(action => executeAction(action, orderContext));

Great for routing, pricing and personalization

Rules engines shine in three domains where business requirements change frequently and benefit from separation between logic and policy.

Routing and workflow

In a ride-hailing or logistics platform, rules determine which orders go to which handler, vehicle type, or queue. A rule might say: "If pickup distance is under 2km and the driver has a high cancellation rate, assign a supervisor review." Without a rules engine, you'd hardcode these conditions and redeploy to change them. With a rules engine, operations can adjust thresholds in real time.

Pricing and promotions

E-commerce and SaaS teams use rules engines to manage discount tiers, surge pricing, and region-specific costs. A travel platform might define: "If booking is within 48 hours and seat occupancy is above 80%, apply 1.2x multiplier." Rule versioning means you can A/B test pricing strategies, roll them back instantly if metrics degrade, and track which rule version was active during each transaction.

Personalization

Content and feature flags follow the same pattern. A rule might state: "If user signed up after 2024-01-01 and is in the 'pro' plan, show the new dashboard." You avoid feature flag sprawl and maintain a single source of truth for user segmentation logic.

Version and test rules like code

Treating rules as versioned artifacts—not unlike configuration or code—gives you safety and traceability in production.

Versioning

Always include a version field in your rule. When a rule is modified, you increment the version and optionally preserve the previous version in an archive table. This allows you to:

  • Audit which rule version was active when a transaction occurred.
  • Roll back a problematic rule change by reactivating an older version.
  • Compare performance metrics between rule versions.
typescript
// Updating a rule
async function updateRule(id: string, updates: Partial<Rule>): Promise<Rule> {
  const current = await ruleRepository.findById(id);
  
  // Archive the old version
  await ruleArchive.save({
    ...current,
    archivedAt: new Date()
  });

  // Create the new version
  const updated = {
    ...current,
    ...updates,
    version: current.version + 1,
    updatedAt: new Date()
  };

  return ruleRepository.update(id, updated);
}

Testing

Because rules are data, testing is straightforward. You can write unit tests that evaluate rules against known contexts, snapshot test rule definitions, and run integration tests against production data exports.

typescript
describe('RulesEngine', () => {
  it('should apply discount for VIP orders over $100', () => {
    const rule = vipDiscountRule;
    const engine = new RulesEngine([rule]);

    const context = {
      order: { totalAmount: 250, country: 'US' },
      customer: { isVIP: true }
    };

    const actions = engine.evaluate(context);
    expect(actions).toContainEqual(
      expect.objectContaining({
        type: 'applyDiscount',
        params: { percentage: 15 }
      })
    );
  });

  it('should not apply discount for non-VIP customers', () => {
    const rule = vipDiscountRule;
    const engine = new RulesEngine([rule]);

    const context = {
      order: { totalAmount: 250, country: 'US' },
      customer: { isVIP: false }
    };

    const actions = engine.evaluate(context);
    expect(actions).not.toContainEqual(
      expect.objectContaining({ type: 'applyDiscount' })
    );
  });
});

Common mistakes

  • Overloading the engine: A rules engine is not a substitute for a full programming language. Complex logic with branching, loops, or external API calls belongs in custom action handlers, not rule conditions.
  • Ignoring rule conflicts: When multiple rules can match, priority ordering prevents unexpected behaviour. Document priority tiers clearly and test rule interactions.
  • Poor observability: Log which rules matched, which actions executed, and how long evaluation took. In production, this debugging data is invaluable when a business metric suddenly changes.
  • Weak schema validation: Validate rule definitions on save. A typo in a field name or invalid comparison operator can silently fail to match orders, leading to revenue loss.
  • Not versioning rules: Without versioning, you lose the ability to audit and roll back. Always treat a rule change as a new version.

Key takeaways

  • A rules engine externalizes business logic as structured data, letting non-technical stakeholders change behaviour without code deployments.
  • Rules consist of conditions (when to apply) and actions (what to do), evaluated against a context object by a generic engine.
  • Rules engines excel in high-change domains like routing, pricing, and personalization where you need rapid iteration and A/B testing.
  • Version rules like code: archive old versions, test rule changes before activating, and log rule matches for auditing.
  • Keep the engine simple and lean on action handlers for complex logic; avoid reimplementing a Turing-complete language inside your rule engine.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles