Decision Tables, Rules Engines & DMN

9 min read Module 6 of 9 Lesson 16 of 25

What you'll learn

  • Model eligibility and policy logic as decision tables with explicit hit policies
  • Describe the DMN standard: decision requirements diagrams, decision tables and FEEL expressions
  • Find gaps and overlaps in rule sets, and test rules with table-driven test cases
  • Govern rules as versioned data with owners, effective dates and reason codes
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every decision system has a rules layer, whether anyone designed it or not. Sometimes it’s a clean module. More often it’s if statements scattered across three services, a SQL WHERE clause in a batch job, and a spreadsheet the operations team edits by hand. That rules layer decides what’s legal, what’s allowed and who’s eligible. It’s the part regulators ask about first.

This lesson is about making that layer explicit, maintainable and testable.

What belongs in rules

Recall the division of labour from Lesson 3. Rules handle what is allowed:

  • Legal and regulatory requirements: minimum age, jurisdictions where a product can’t be offered, mandatory cooling-off periods.
  • Contractual and policy limits: one discount per customer per year, no offers to accounts in collections.
  • Eligibility for actions: account-manager calls only for annual plans above $2,000.
  • Safety stops: block if the customer has an open fraud investigation.

Rules should not decide what is best among allowed actions. When rule sets start approximating risk with thresholds on dozens of variables (“if tenure < 6 and logins < 3 and plan = monthly and region in (…)…”), they’re doing a model’s job badly. That logic belongs in a calibrated model; rules should constrain its output.

Decision tables

A decision table is the most readable format for rules. Each row is a rule; input columns hold conditions; output columns hold results. Business owners can read and review them directly.

In collectionsDiscounts last 12mPlan & annual value→ Eligible offersReason
trueanyanynoneR01 collections hold
false≥ 1anynone, free_upgradeR02 discount cap reached
false0annual and > 2000allR03 full eligibility
false0anything elseall except callR04 no call tier

The hit policy says what happens when several rows match. The DMN standard defines them:

  • UNIQUE: no overlaps allowed; any input matches at most one row. Overlap is an error.
  • FIRST: rows are evaluated in order; the first match wins. Easy, but order becomes hidden logic.
  • PRIORITY: multiple matches allowed; the output with the highest priority wins.
  • ANY: overlaps allowed only if they produce the same output.
  • COLLECT: return all matching outputs (optionally aggregated: sum, min, max, count).
  • RULE ORDER / OUTPUT ORDER: return all matches, sorted by rule order or output priority.

Prefer UNIQUE for policy tables. It forces you to make rows mutually exclusive, which surfaces ambiguous cases during design rather than in production. The table above is UNIQUE: R01 could have been written as a FIRST-policy override, but repeating “in collections = false” on the later rows makes every row readable on its own, with no hidden dependence on order.

DMN: the standard

DMN (Decision Model and Notation) is an OMG standard for modeling decisions. It has three parts worth knowing:

  • Decision requirements diagrams (DRDs) show how a decision breaks down: which sub-decisions feed it, what input data each needs, and which knowledge sources (policies, regulations) govern them.
  • Decision tables with the hit policies above.
  • FEEL (Friendly Enough Expression Language) for conditions and calculations, designed to be readable by analysts: annual value > 2000, date(application date) - date(birth date) >= duration("P18Y").
flowchart BT
    CD["Customer data"] --> EL["Offer eligibility\n(decision table)"]
    PH["Policy: discount cap\n(knowledge source)"] -.-> EL
    CD --> SC["Uplift scores\n(model)"]
    EL --> OS["Offer selection\n(optimization)"]
    SC --> OS
    BU["Budget"] --> OS

    style CD fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style BU fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style PH fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style EL fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style SC fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style OS fill:#f0fdf9,stroke:#0D9488,color:#0F172A

DMN engines (Camunda, Drools, and several open-source and commercial alternatives) execute these models directly, so the diagram analysts review is the logic that runs. Even if you never adopt an engine, drawing the DRD is a useful exercise: it shows exactly where models, rules and optimization meet.

Rules as data, in code

You don’t need an engine to get most of the benefit. Store tables as data, evaluate them with a small interpreter, and return reason codes:

from dataclasses import dataclass
from typing import Callable

@dataclass(frozen=True)
class Rule:
    id: str
    when: Callable[[dict], bool]
    allowed: frozenset[str]
    reason: str

ALL = frozenset({"none", "discount_10pct_3mo", "discount_25pct_3mo",
                 "free_upgrade_1mo", "account_manager_call"})
NO_CALL = ALL - {"account_manager_call"}

ELIGIBILITY_V7 = [  # hit policy: UNIQUE (checked in tests)
    Rule("R01", lambda c: c["in_collections"], frozenset({"none"}), "collections hold"),
    Rule("R02", lambda c: not c["in_collections"] and c["discounts_12m"] >= 1,
         frozenset({"none", "free_upgrade_1mo"}), "discount cap reached"),
    Rule("R03", lambda c: not c["in_collections"] and c["discounts_12m"] == 0
         and c["plan"] == "annual" and c["annual_value"] > 2000, ALL, "full eligibility"),
    Rule("R04", lambda c: not c["in_collections"] and c["discounts_12m"] == 0
         and not (c["plan"] == "annual" and c["annual_value"] > 2000), NO_CALL, "no call tier"),
]

def eligible_actions(customer: dict, table=ELIGIBILITY_V7):
    hits = [r for r in table if r.when(customer)]
    if len(hits) != 1:
        # UNIQUE violated or a gap: fail closed, never guess.
        raise ValueError(f"{len(hits)} rules matched {customer.get('id')}")
    return hits[0].allowed, hits[0].id, hits[0].reason

Note the fail-closed behaviour. A gap (no rule matches) or an overlap (several match) is a bug, and the safe response for an eligibility table is to refuse and alert, not to fall through to a default that might allow something it shouldn’t.

Testing rules

Rules are code and need tests, but the tests look different:

  • Table-driven cases written with the business owner: one test per row, plus boundary cases (annual value exactly 2000, exactly 1 prior discount).
  • Completeness and overlap checks: generate inputs across the domain of each variable (including boundaries and missing values) and assert exactly one rule matches. For small tables you can enumerate; for larger ones, property-based testing (Hypothesis) finds gaps quickly.
  • Regression on real traffic: before releasing a new table version, run it against last week’s decisions and diff the outputs. Every changed decision should be explainable by the intended change.

Governing rules

Rules change for business reasons, often urgently (“legal says stop offering X in region Y today”). Treat them like configuration with strong controls:

  • Versioned and immutable. Every decision logs the table version that produced it. Never edit a version in place.
  • Owned. Each table has a business owner who approves changes and an engineering owner who deploys them.
  • Effective dates. Rules often apply from a date. Store valid_from and valid_to instead of deploying at midnight.
  • Reason codes everywhere. Every rule outcome has a stable code. They feed explanations, adverse action notices (Lesson 22) and analysis of which rules cost the most (add them as constraints in Lesson 7’s model and read the shadow price).
  • Periodic pruning. Each quarter, list rules that never fired, rules that always fire together, and rules whose reason nobody remembers. Rule sets only grow unless someone actively shrinks them.

Rules are deterministic and auditable. The next layer is neither: language models. The next lesson covers where they fit inside a decision system and how to keep them from undermining everything above.

Knowledge Check

4 questions to test your understanding

1 A decision table has the hit policy UNIQUE. Two rows match the same input. What should happen?

2 Why should rules return reason codes along with the decision?

3 A rule set has grown to 2,400 rules that approximate risk using thresholds on dozens of variables. What is the likely problem?

4 What does a DMN decision requirements diagram (DRD) show?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan