Resolving VIP Tier Discount Stacking with a Mutex Group
A pattern that guarantees exactly one discount applies when tier discounts and seasonal campaigns collide.
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 blameand 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 mutex group in Single mode (mutexMode: EXCLUSIVE), then let rule priority decide the winner.
In LexQ terms, this scenario maps to three concepts.
- Fact: the order and the customer behind it, as the storefront reports them at checkout.
loyaltyTier,purchaseSubtotalUsd,activeCampaign. - Rule: one rule per discount. Each has a condition and a
MUTATE_FACTaction that subtracts a percentage frompurchaseSubtotalUsd. - 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 — Single mode, so only the winning rule's action runs. mutexStrategy carries a single value, HIGHEST_PRIORITY, so there is no strategy to select: of the discounts an order qualifies for, the one with the smallest priority number is the one it gets.
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": "activeCampaign",
"operator": "EQUALS",
"value": "SUMMER_SALE",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"operand": 15,
"method": "PERCENTAGE",
"targetVar": "purchaseSubtotalUsd",
"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": "loyaltyTier",
"operator": "EQUALS",
"value": "PLATINUM",
"valueType": "STRING"
}
]
},
"actions": [
{
"type": "MUTATE_FACT",
"parameters": {
"operand": 12,
"method": "PERCENTAGE",
"targetVar": "purchaseSubtotalUsd",
"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 Single mode mutexLimit is always 1 (a single winner) and may be omitted.
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. The discipline production code gets — a review against real outcomes — has to carry over. That mechanism is Impact Simulation: a candidate version runs against historical order data before it goes live, and testing a rule change before deploy works through that comparison as a pattern of its own.
The setup uses two versions. The baseline is the version discounts still stack in: whatever is in front of live orders today, or, before anything has shipped, the pre-mutex draft, called v1 below. The candidate is the version with the best-discount mutex group applied, v2. 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": "purchaseSubtotalUsd__delta",
"aggregationType": "SUM"
}
}
}'
Shipping the candidate hinges on two checks: one per order, one across the whole campaign window. First, no order may show a discount amount (the absolute value of purchaseSubtotalUsd__delta) larger than the single largest discount applicable to it. If any does, stacking survived. Second, the realized discount rate across the window must land within the tolerance the team set against its campaign budget assumption (for example, within ±2% of the planned campaign rate). That tolerance is not the panel's Change figure: Change reports how far the aggregate discount moved from the baseline to the candidate, which is large by design when stacking is what you are removing. The simulation replays stored orders and writes nothing back, so no cart total and no campaign spend moves.
The run pictured took the upload path rather than live history: 500 representative orders, of which 324 match a discount rule and so carry a purchaseSubtotalUsd__delta for the sum to reach. That 324 is the panel's Measured count; the remaining 176 orders take no discount at all.
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": {
"inputFacts": { ... },
"mutatedFacts": {
"purchaseSubtotalUsd": 510
},
"generatedVariables": {
"purchaseSubtotalUsd__delta": -90
},
"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
}
]
}
}
mutatedFacts holds the final subtotal. generatedVariables carries purchaseSubtotalUsd__delta (the signed discount amount -90, 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, a purchaseSubtotalUsd__delta of -151.2, landing at 448.8, a 25.2% discount. This stacking is exactly what the candidate removes.
Edge cases
This pattern resolves the common stacking. A few adjacent cases call for a deliberate decision.
- Changing the winner. Within a version,
priorityis 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. The engine has no strategy for this, by design: order is set by the author, never inferred from values. If the tiers are ranked by outcome, encode that ranking in
priority— a 20% tier sits above a 15% tier. Where "largest" genuinely depends on runtime input, split the branch into separate rules with explicit conditions. Adjudicating credit applications carries the same mechanism into a decision with several possible outcomes. - 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 leave that loyalty-credit rule out of the
best-discountgroup. 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 the group to Top N mode (
mutexMode:MAX_N) and specify the count withmutexLimit. It is the same rule-level mechanism, and a rule pushed past the limit is recorded asBLOCKED/MUTEX_LIMIT_REACHED. When the competition spans policy groups rather than rules inside one version, that is an Execution Group: policy groups sharing the sameactivationGroupcompete under a sharedactivationMode,activationStrategy, andexecutionLimit, and a rule in a losing group is recorded asGROUP_PRIORITY_LOST/GROUP_LIMIT_REACHED. That is outside this pattern's scope.
Production rollout
A validated candidate goes to production with Deploy. The rule set is sealed under a snapshot hash at deploy time, so the version that prices live orders can always be checked against the one that was reviewed, and the record names who published which pricing version and when. Rather than switching every order over at once, start an A/B test that routes a slice of live orders to the candidate and the rest to the baseline, then widen the slice in steps — 5% → 25% → 50%. Hold at each step long enough to read the decision traces coming back, and widen again only while the discount mix stays where the simulation put it. Deploying the candidate hands it the orders that remain.
Roll back immediately on any of the following in live order traffic:
- An order discounted past its largest single applicable discount. The actual discount (
purchaseSubtotalUsd__delta) comes out larger than the biggest single discount that order qualifies for — stacking the simulation missed has leaked through. - A campaign rule firing at a rate far from the simulation's prediction. The facts your application sends don't match what the rules expect.
A rollback puts the previous pricing version back in front of order traffic. That reversal writes its own deployment record, so it stays auditable too. Once a version serves all traffic, 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. If checkout slows down after the deploy, the question is which discount rule consumed the time. Isolating a slow rule on a hot path measures that rule by rule.
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