LexQLexQ
Back to patterns
E-commercePricingOperationsTrustBeginner

Resolving VIP Tier Discount Stacking with a Mutex Group

A pattern that guarantees exactly one discount applies when tier discounts and seasonal campaigns collide.

Sanghyun Park·June 1, 202611 min read15 min

The problem

Most retail teams run two kinds of discounts at once. Tier discounts are percentage discounts granted continuously to repeat customers — PLATINUM, GOLD, SILVER. Seasonal campaigns are discounts applied temporarily to every customer, like a summer sale or a launch-week promotion. Each is reasonable on its own. They collide where they overlap.

While a campaign runs, a PLATINUM customer qualifies for both the tier discount and the campaign discount. In most discount code the two are line items added independently to a running total, so the customer gets both. A 12% tier discount and a 15% campaign discount become 27%. Finance budgeted assuming only the campaign rate; the extra 12% surfaces weeks later as missing margin, and the cause is traced long after.

The constraint that should have held was written down nowhere. The rule "at most one discount applies" spans two discount blocks, and a running-total approach has no place to put a constraint that spans them.

The question this pattern answers: how does a team guarantee that a single order receives exactly one discount, and how does it answer, months later, which discount applied and why.

The naive approach

The first version computes the tier discount, computes the campaign discount, then subtracts the total. It holds as long as the two never overlap.

public BigDecimal applyDiscount(Order order, Customer customer) {
    BigDecimal subtotal = order.getSubtotal();
    BigDecimal discount = BigDecimal.ZERO;

    // Loyalty tier — owned by accounting
    if (customer.getTier() == Tier.PLATINUM) {
        discount = subtotal.multiply(new BigDecimal("0.12"));
    } else if (customer.getTier() == Tier.GOLD) {
        discount = subtotal.multiply(new BigDecimal("0.08"));
    } else if (customer.getTier() == Tier.SILVER) {
        discount = subtotal.multiply(new BigDecimal("0.05"));
    }

    // Seasonal campaign — added later by the growth team
    if (campaignService.isActive("SUMMER_SALE")) {
        discount = discount.add(subtotal.multiply(new BigDecimal("0.15")));
    }
    if (campaignService.isActive("NEW_USER_WEEK")) {
        discount = discount.add(subtotal.multiply(new BigDecimal("0.10")));
    }

    // During the summer sale a PLATINUM customer now gets 27%.
    // No one decided that. It is just the sum of two independent blocks.
    return subtotal.subtract(discount);
}

It is not careless code. Each block was written against a real decision and was correct the day it shipped. The tier block and the campaign block were written by different people, months apart. Neither block knows the other exists. That is the flaw, and it shows up in three places.

  • The "no stacking" rule has nowhere to live. It is not a line of code you can review — it is the absence of one. A refactor that touches either block can produce 27% without turning a single test red.
  • Priority is implicit. If the business decides the campaign rate should replace the tier rate instead of adding to it, that change means re-reading the whole method and re-sequencing it. The decision lives in control flow, not in data you can review.
  • There is no record. Six months later, when finance asks why an order got 27%, the answer is git blame and a guess about which campaign was on that day.

Defining the pattern

The fix is structural. Model each discount as its own rule, put every competing discount into one EXCLUSIVE mutex group, then let rule priority decide the winner.

In LexQ terms, this scenario maps to three concepts.

  • Fact: the input the engine reads. loyalty_tier, purchase_subtotal_usd, active_campaign.
  • Rule: one rule per discount. Each has a condition and a MUTATE_FACT action that subtracts a percentage from purchase_subtotal_usd.
  • Mutex Group: the field that turns a list of discounts into a competition with exactly one winner.

Every discount rule carries the same mutexGroup key, best-discount, with mutexMode set to EXCLUSIVE. EXCLUSIVE mode means only the winning rule's action runs. mutexStrategy is HIGHEST_PRIORITY, so among the matching members the rule with the smallest priority number wins.

priority here is not a value you set when you create a rule. It is an order assigned automatically as 1..N within a version, and the only way to change it is reorder (drag in the console). And priority is independent of mutexGroup — it is a single sequence across the entire version, not a rank within the group. Put the campaign rule at the top of the list (create it first, or drag it up) and it gets the smallest priority. During a campaign both the campaign rule and the tier rules match, but the campaign rule at the top wins.

{
  "name": "Campaign: Summer Sale 15%",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "active_campaign",
        "operator": "EQUALS",
        "value": "SUMMER_SALE",
        "valueType": "STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "MUTATE_FACT",
      "parameters": {
        "rate": 15,
        "method": "PERCENTAGE",
        "refVar": "purchase_subtotal_usd",
        "operator": "SUB",
        "rounding": { "mode": "HALF_UP", "scale": 2 }
      }
    }
  ],
  "mutexGroup": "best-discount",
  "mutexMode": "EXCLUSIVE",
  "mutexStrategy": "HIGHEST_PRIORITY",
  "mutexLimit": 1,
  "isEnabled": true
}
{
  "name": "Tier: PLATINUM 12%",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "loyalty_tier",
        "operator": "EQUALS",
        "value": "PLATINUM",
        "valueType": "STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "MUTATE_FACT",
      "parameters": {
        "rate": 12,
        "method": "PERCENTAGE",
        "refVar": "purchase_subtotal_usd",
        "operator": "SUB",
        "rounding": { "mode": "HALF_UP", "scale": 2 }
      }
    }
  ],
  "mutexGroup": "best-discount",
  "mutexMode": "EXCLUSIVE",
  "mutexStrategy": "HIGHEST_PRIORITY",
  "mutexLimit": 1,
  "isEnabled": true
}

Note that neither rule sends a priority when created. The engine assigns a version-wide order in creation sequence, and that order can be changed at any time with reorder. In EXCLUSIVE mode mutexLimit is always 1 (a single winner) and may be omitted.

best-discount rule group with three rules

The constraint "at most one applies" is now stated in a field, mutexMode. It is no longer the gap between two if-blocks. The GOLD rule already appears in the list above, and any further tiers such as SILVER follow the same shape below it.

Impact Simulation strategy

Moving discounts into rules introduces a new risk. A rule edited in the console reaches live orders within seconds, with no PR in between. You have to port the discipline production code gets — a review against real outcomes. That mechanism is Impact Simulation. You run a candidate version against historical order data before it goes live.

The setup uses two versions. The baseline is the current production version where discounts still stack. The candidate is the version with the best-discount mutex group applied. The dataset is historical execution data covering at least one past campaign window, so the stacking cases are guaranteed to be in the run. If you have no production traffic yet, you can upload a representative dataset — orders spanning the tier and campaign-on/off combinations, stacking cases included — and run the same comparison against it.

lexq analytics simulation start --json '{
  "policyVersionId": "<candidate-version-id>",
  "dataset": {
    "type": "HISTORICAL",
    "source": "EXECUTION_LOGS",
    "from": "2026-04-01",
    "to": "2026-04-30"
  },
  "options": {
    "baselinePolicyVersionId": "<baseline-version-id>",
    "includeRuleStats": true,
    "maxRecords": 10000,
    "metricConfig": {
      "targetVariable": "purchase_subtotal_usd__delta",
      "aggregationType": "SUM"
    }
  }
}'

Two conditions decide whether to ship the candidate. First, no order may show a discount amount (the absolute value of purchase_subtotal_usd__delta) larger than the single largest discount applicable to it. If any does, stacking survived. Second, the aggregate discount must land within the tolerance the team set against its campaign budget assumption (for example, within ±2% of the planned campaign rate). During a simulation, integration calls are mocked, so the run has no side effects.

Impact Simulation comparing v2 against v1

Decision Trace output

Every execution returns a trace. For a PLATINUM customer's $600 cart during the summer sale, the decision trace records which rule won and which was blocked.

{
  "result": "SUCCESS",
  "data": {
    "traceId": "31bd3596-...",
    "inputFacts": { ... },
    "mutatedFacts": {
      "purchase_subtotal_usd": 510.00
    },
    "generatedVariables": {
      "purchase_subtotal_usd__delta": -90.00
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Campaign: Summer Sale 15%",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Tier: PLATINUM 12%",
        "status": "BLOCKED",
        "reasonCode": "MUTEX_PRIORITY_LOST",
        "reasonDetail": "Winner=[Campaign: Summer Sale 15%], Strategy=HIGHEST_PRIORITY"
      },
      {
        "ruleName": "Tier: GOLD 8%",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      }
    ]
  }
}
Dry Run on the candidate version

mutatedFacts holds the final subtotal. generatedVariables carries purchase_subtotal_usd__delta (the signed discount amount -90.00, exactly 15% of 600). In decisionTraces, status is the category of the outcome and reasonCode is the specific reason within that category. The PLATINUM rule matched but lost the mutex competition, recorded as BLOCKED / MUTEX_PRIORITY_LOST. Which match expression each rule was evaluated against, and whether it matched, appears in the Execution Trace table in the Dry Run view above. This is the answer for audit. Six months later, the trace explains the 15% without a debugger.

Run the same order through the baseline (v1) and the result diverges. With no mutex group, Campaign and PLATINUM both fire — Campaign takes 15%, then PLATINUM takes another 12% of the remainder, landing at 448.80, roughly a 25.2% discount. This stacking is exactly what the candidate removes.

Dry Run on the baseline version

Edge cases

This pattern resolves the common stacking. A few adjacent cases call for a deliberate decision.

  • Changing the winner. Within a version, priority is enforced unique, so a tie cannot occur. Among the matching members the winner always resolves to exactly one. To make a different rule win, raise its position with reorder.
  • No rule matches inside the group. A customer with no tier and no active campaign matches no discount rule. The mutex group is inactive, because it only arbitrates among rules that already matched. The result is no discount, and no error.
  • Selecting the rule with the largest output. A fixed priority is not always the goal. If the rule is "select the rule with the largest output value," the strategy is MAX_BENEFIT, not HIGHEST_PRIORITY. The group structure is identical; only mutexStrategy changes. For value-reducing actions like discounts, check carefully which output counts as "largest".
  • Discounts you want to stack on purpose. Not every discount belongs in one group. Suppose the rule is "apply the best discount, then always take an extra $5 loyalty credit on top" — you simply leave that loyalty-credit rule out of the best-discount group. Mutex only makes rules within the same group compete, so a rule outside the group fires on its own, unaffected. A single version can therefore hold both "apply only one" discounts (inside the group) and "always apply" discounts (outside it).
  • A different scope of competition. If you need to allow up to N within a group rather than exactly one (the top two discounts, say), set mutexMode to MAX_N and specify the count with mutexLimit. It is the same rule-level mechanism, and a rule pushed past the limit is recorded as BLOCKED / MUTEX_LIMIT_REACHED. When the constraint spans rules across the whole version rather than within one group, that is an activation group, recorded as GROUP_PRIORITY_LOST / GROUP_LIMIT_REACHED. That is outside this pattern's scope.

Production rollout

A validated candidate goes to production with Deploy. The rule snapshot at deploy time is sealed with a hash and integrity-verified, and the record captures who deployed which version and when. To expose it gradually instead of cutting all traffic over at once, run an A/B test between the candidate and the baseline and raise the traffic share in steps — 5% → 25% → 50% → 100%. At each step you watch the live decision traces and only advance to the next share if nothing looks wrong.

Either of two signals means roll back immediately:

  • An order whose actual discount (purchase_subtotal_usd__delta) comes out larger than the biggest single discount that order qualifies for — stacking the simulation missed has leaked through.
  • A campaign rule that fires at a rate far from the simulation's prediction — the facts your application sends don't match what the rules expect.
Deployment detail for v2

A rollback returns the policy group to the previous version and leaves a deployment record, so the rollback itself stays in the audit trail. Once a version stabilizes at 100%, the per-rule statistics show how often each discount rule wins — the input you need to adjust priority or retire a rule that never fires.


See how LexQ works for yourself in the playground.

Ready to move decisions out of your deploy pipeline?

Try LexQ free — no credit card required.

Start Free