Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 079 / 100

Rule engines for non-engineers: best practices explained

Give business users power without breaking production.

Expona Admin9 min read

Introduction

Non-technical stakeholders—product managers, compliance officers, marketing teams—often need to adjust business logic without waiting for engineering sprints. Rule engines for non-engineers: best practices explained is about building systems that let them do exactly that while keeping your production environment safe and predictable. When done well, a rule engine becomes a bridge between business intent and code execution, reducing friction and deployment cycles.

The challenge isn't just giving power; it's preventing chaos. A poorly designed rule builder can become a footgun where someone accidentally breaks pricing logic or ships contradictory shipping rules at 2 AM. This article covers the architectural patterns and UI/UX decisions that teams at scale have learned to implement.

Visual rule builders with validation

A text-based JSON editor is not a rule builder for business users. You need a visual interface that lets non-technical people compose rules using familiar language: "if customer spent more than $500 last month, apply 10% discount" should be drag-and-drop, not hand-coded JSON.

The foundation is a domain-specific language (DSL) that your rule builder translates into. We typically structure this as a tree of conditions and actions. Here's a simplified TypeScript schema:

typescript
interface RuleNode {
  type: 'condition' | 'action' | 'group';
  operator?: 'AND' | 'OR';
  conditions?: Array<{
    field: string;
    operator: 'equals' | 'greaterThan' | 'contains' | 'in';
    value: string | number | string[];
  }>;
  actions?: Array<{
    type: 'discount' | 'tag' | 'notify' | 'block';
    payload: Record<string, unknown>;
  }>;
}

interface Rule {
  id: string;
  name: string;
  description: string;
  enabled: boolean;
  priority: number;
  tree: RuleNode;
  createdAt: Date;
  createdBy: string;
}

Validation must happen at multiple levels. Client-side validation catches obvious mistakes immediately—missing required fields, value ranges, circular logic. Server-side validation is your safety net. It checks semantic correctness: does the field referenced in a condition actually exist in your data model? Is the discount percentage within acceptable bounds?

A real example: a loyalty team builds a rule "if member status equals 'vip' OR member status equals 'platinum', apply 20% discount." Your validator should catch that discounts over 15% require manager approval, or that you're about to test this rule against a production dataset. This feedback happens before publication, not after.

typescript
async function validateRule(rule: Rule, context: ValidationContext): Promise<ValidationResult> {
  const errors: string[] = [];
  const warnings: string[] = [];

  // Check field existence
  rule.tree.conditions?.forEach(cond => {
    if (!context.availableFields.includes(cond.field)) {
      errors.push(`Field '${cond.field}' does not exist in data model`);
    }
  });

  // Check business constraints
  rule.tree.actions?.forEach(action => {
    if (action.type === 'discount' && action.payload.percentage > 15) {
      warnings.push('Discount exceeds 15%; requires manager approval');
    }
  });

  // Test against sample dataset
  const testResults = await testRuleAgainstSamples(rule, context.sampleData);
  if (testResults.affectedRows > context.maxAffectedRows) {
    warnings.push(`Rule affects ${testResults.affectedRows} rows; expected max ${context.maxAffectedRows}`);
  }

  return { valid: errors.length === 0, errors, warnings };
}

The UI itself should be modular. Each condition type (text match, numeric range, list membership, date range) gets its own input component that understands the field's data type. When someone selects "customer age," the UI automatically constrains the operator to numeric comparisons and shows a number input, not a text field.

Preview before publishing

Never let a rule go live without previewing its impact. A preview environment runs the exact same rule evaluation logic as production, but against a safe dataset—either real data from a staging database or anonymized production data, read-only.

Build a "dry run" feature that shows what the rule would do if applied right now. For a discount rule, show affected orders and the revenue impact. For a tagging rule, show which customers would be tagged and how many. For a blocking rule (e.g., "prevent checkout if fraud score > 85"), show how many transactions would be blocked and sample transactions.

This is your feedback loop. A product manager can see that their rule is too broad ("blocks 12% of all checkouts") and tighten the conditions before publishing. No guessing, no surprises in production metrics an hour later.

typescript
async function previewRule(rule: Rule, previewConfig: PreviewConfig): Promise<PreviewResult> {
  const sampleData = await loadSampleData(previewConfig.dataSource, previewConfig.sampleSize);
  
  const matches = [];
  const actions = [];
  
  for (const record of sampleData) {
    const conditionsMet = evaluateConditions(rule.tree.conditions, record);
    if (conditionsMet) {
      matches.push(record);
      rule.tree.actions?.forEach(action => {
        actions.push({
          recordId: record.id,
          actionType: action.type,
          actionPayload: action.payload,
          estimatedImpact: calculateImpact(action, record)
        });
      });
    }
  }

  return {
    matchedCount: matches.length,
    matchedPercentage: (matches.length / sampleData.length) * 100,
    actionsSummary: summarizeActions(actions),
    samples: matches.slice(0, 10),
    estimatedRevenueDelta: calculateRevenueDelta(actions)
  };
}

We've found that showing a few concrete examples in the preview is crucial. Seeing that Sarah Jones and 47 other customers with $600+ annual spend would get the discount makes the impact tangible. Developers often skip this because it feels like extra work, but it prevents the rule that looked good in isolation from breaking business metrics in production.

Version and roll back rules

Rules change. A promotion ends, requirements shift, a bug is discovered. You need a complete audit trail and the ability to revert instantly.

Treat rules like code: every published rule is a version. Store the full rule definition, who published it, when, and optionally, what changed from the previous version. A rule editor shouldn't directly overwrite the live rule; it creates a new version that goes through the preview step, then publishes when approved.

Version Published By Published At Changes Status
v3 alice@company.com 2024-01-15 14:32 UTC Discount increased from 10% to 15% Live
v2 bob@company.com 2024-01-10 09:11 UTC Added VIP tier condition Superseded
v1 charlie@company.com 2024-01-05 16:45 UTC Initial creation Superseded

The rollback operation should be atomic and logged. If v3 is causing issues, you click "roll back to v2" and that takes effect immediately. The system records that a rollback occurred, who triggered it, and why. This is your paper trail for compliance and incident reviews.

typescript
async function publishRule(rule: Rule, publishedBy: string, changeReason: string): Promise<RuleVersion> {
  // Validate again before publishing
  const validation = await validateRule(rule, productionContext);
  if (!validation.valid) {
    throw new Error(`Cannot publish invalid rule: ${validation.errors.join('; ')}`);
  }

  // Create new version
  const version = {
    ruleId: rule.id,
    versionNumber: (await getLatestVersion(rule.id)).versionNumber + 1,
    ruleDefinition: rule,
    publishedAt: new Date(),
    publishedBy,
    changeReason,
    previousVersion: await getLatestVersion(rule.id),
    status: 'LIVE' as const
  };

  await db.ruleVersions.insert(version);
  await db.rules.update(rule.id, { currentVersionId: version.id });

  // Publish event for subscribers (evaluation engines, caches, etc.)
  await publishEvent('rule.published', {
    ruleId: rule.id,
    versionNumber: version.versionNumber,
    timestamp: new Date()
  });

  return version;
}

async function rollbackRule(ruleId: string, targetVersionNumber: number, rolledBackBy: string, reason: string): Promise<void> {
  const targetVersion = await db.ruleVersions.findOne({
    ruleId,
    versionNumber: targetVersionNumber
  });

  if (!targetVersion) {
    throw new Error(`Version ${targetVersionNumber} not found`);
  }

  // Create a new version that restores the target
  await publishRule(targetVersion.ruleDefinition, rolledBackBy, `Rollback to v${targetVersionNumber}: ${reason}`);

  // Log rollback event
  await db.rollbackLog.insert({
    ruleId,
    rolledBackFrom: (await getLatestVersion(ruleId)).versionNumber,
    rolledBackTo: targetVersionNumber,
    rolledBackAt: new Date(),
    rolledBackBy,
    reason
  });
}

In practice, we run parallel evaluation during early rollouts. Version v3 is evaluated against live traffic, but results are discarded—we're just measuring its behavior. If metrics look good after 30 minutes, we feel confident. If something smells wrong, rollback is one click.

Limit nesting to keep rules understandable

A rule with five levels of nested AND/OR groups becomes a logic puzzle. Non-technical users lose track of what the rule actually does, bugs slip in, and maintenance becomes a nightmare.

Set a maximum nesting depth—typically 2 or 3 levels is enough for 95% of real business rules. A rule like "if (customer is VIP AND order total > $200) OR (customer referred by loyalty program AND spent more than $500 lifetime)" is perfectly understandable at 2 levels. If you need deeper nesting, it's usually a sign that the rule should be split into multiple simpler rules with clear names and priorities.

Enforce this limit in your validator:

typescript
function checkNestingDepth(node: RuleNode, currentDepth: number = 0, maxDepth: number = 2): string[] {
  const errors: string[] = [];

  if (currentDepth > maxDepth) {
    errors.push(`Rule nesting exceeds maximum depth of ${maxDepth}`);
    return errors;
  }

  if (node.type === 'group' && node.conditions) {
    node.conditions.forEach(condition => {
      // Assuming conditions can contain sub-groups
      if ('children' in condition) {
        errors.push(...checkNestingDepth(condition.children, currentDepth + 1, maxDepth));
      }
    });
  }

  return errors;
}

Use rule priorities to replace complex nesting. Instead of a single rule with nested logic, define two rules: "High-value VIP discount" (priority 10) applies a 15% discount, and "Loyalty program bonus" (priority 20) applies 5% more to loyalty members. The evaluation engine applies them in priority order, and each rule is simple to understand independently. You can also disable one without touching the other.

Complexity compounds. A simple rule today becomes incomprehensible in 18 months. Enforce simplicity early; your future self will thank you.

Common mistakes

  • No validation on the server. Client-side validation is for UX; server-side validation is for security and correctness. Always validate before evaluation. A user could craft malicious JSON or the client code could have a bug.
  • Testing rules only on happy paths. Your preview should include edge cases: empty datasets, null values, boundary conditions. A discount rule that doesn't account for orders with $0 revenue can create weird accounting issues.
  • Skipping the dry-run stage. Publishing directly to production, even with good intentions, breaks things. Build dry-run into your workflow, not as an afterthought.
  • No audit trail. If a rule change correlates with a business metric drop, you need to know exactly what changed, when, and who did it. Without version history and change reasons, you're debugging blind.
  • Rules that reference deleted or renamed fields. When you refactor your data model, stale rules break silently. Implement cascading validation: if a field is renamed or removed, any rule referencing it should be flagged and require an explicit fix before publication.

Key takeaways

  • Visual builders lower the barrier to entry for non-technical users, but robust server-side validation is what keeps production safe.
  • Dry-run previews with concrete examples catch overly broad or narrow rules before they ship.
  • Version history and atomic rollback mean you can recover from mistakes in seconds, not hours.
  • Enforce simplicity through nesting limits and encourage rule decomposition; a 3-level rule is usually a sign you need two simpler rules with clear priorities.
  • Audit every change: who published it, when, and why. This trail is invaluable during incidents and compliance reviews.
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