Skip to main content
Back to AI Commerce Lab
Commerce·October 2026·9 min read

Promotions Engines Age Worse Than Any Other System

A promotions engine accumulates rules the way a warehouse accumulates pallets nobody signed for. Every campaign adds logic, no campaign removes it, and by year 3 the only accurate spec for how discounts combine is whatever the engine happens to do. Treat rules as production code, test the pairs, and put an end date on everything.

Somewhere in your admin there is a discount rule created for a campaign that ended 2 years ago. It is still active, because deactivating it was on nobody's closing checklist, and it will meet this year's Black Friday automatic discount for the first time inside a cart no one ever built in staging.

That meeting is scheduled for late November, at your highest traffic of the year, in front of your most price-sensitive customers. This post is about why promotions logic rots faster than anything else you run, and what a testing strategy that matches the problem looks like.

Why promotions age worse than every other system

Most commerce systems converge. An order state machine settles after the first year of edge cases, a catalog schema stabilizes, a payment integration changes when the provider changes. Their behavior changes when engineering changes it, through review and deploy.

Promotions run the opposite direction. The codebase may be stable, but the rule set grows weekly forever, and each new rule changes production behavior for every cart that matches its conditions.

Every promotion rule is a production behavior change shipped by someone outside engineering, with no code review, no tests, and no rollback plan. That is the aging mechanism in 1 sentence.

The platforms are honest about this shape. Adobe Commerce's cart price rules apply discounts to cart items based on a set of conditions, fire automatically or from a coupon code, and are meant to be used per season or promotion by changing status and date range. The docs assume a human comes back to flip the status, and entropy assumes nobody does.

How a rule set decays

Decay is not one failure. It is 5 or 6 small omissions that compound, and every mature rule set I have audited shows the same list.

  • Rules with no end date, active since the campaign that justified them ended.
  • Conditions referencing categories, customer groups, or SKUs that no longer exist, so the rule silently matches nothing, or worse, matches everything.
  • Coupon codes that escaped to deal aggregators and now arrive year-round, funding a discount nobody budgets for.
  • Priority values assigned by creation order rather than intent, so evaluation order is an accident of history.
  • Compensating rules: a rule added to cancel the side effect of another rule, which then outlives it.

None of these is visible in a dashboard, because the engine is doing exactly what it was configured to do. The configuration is the defect.

Stacking is combinatorial and your test plan is linear

The deeper problem is arithmetic, the same kind that decides integration topology. With 10 active rules there are 45 possible pairs and 120 possible triples, and every cart a customer builds is an interaction test executed in production. A QA plan that verifies each rule alone covers 10 cases out of a space of thousands.

The useful news comes from NIST's combinatorial testing research. A series of NIST studies from 1999 to 2004 found that most software failures are caused by 1 or 2 parameters interacting, with progressively fewer involving 3 or more, and that combinatorial test sets achieved fault detection equal to exhaustive testing with a 20x to 700x reduction in test set size.

Translated to promotions: you do not need every combination of active rules in a test cart. You need every pair, plus the handful of triples your merchandising calendar deliberately stacks, and that is a tractable suite instead of an impossible one.

Platforms constrain the space too, and the constraints are worth knowing precisely. Shopify's discount model assigns each discount one or more classes, product, order, or shipping, and the classes determine which part of the cart a discount can touch. The Discount Function API caps a store at 25 active discount functions and runs them concurrently, with combination semantics defined by the platform's checkout execution order.

Documented execution order is a gift. It means the stacking behavior is testable against a spec rather than folklore, which is exactly what your legacy engine never gave you.

evaluation runs in priority order CART PRODUCT RULES per line item ORDER RULES cart subtotal SHIPPING RULES shipping line PRICED CART to the cent discount > line price same line discounted twice threshold un-earned fixed point: re-pricing a priced cart changes nothing

The evaluation sequence, the 3 seams where stacking conflicts live, and the fixed-point check that catches them.

The failure modes worth a named test

Stacking failureHow it happensCheapest guard
Same line discounted twiceA product rule and an order rule both touch the line, and proration double-countsPair test for every product-class and order-class rule combination
Discount exceeds line pricePercentage plus fixed-amount stack on a cheap SKUInvariant test: line discount never exceeds line price
Threshold rule gamed by another ruleAn order discount drops the subtotal below the threshold that activated itFixed-point test: re-evaluating the priced cart changes nothing
Zombie campaign firesNo end date, condition still matches current catalogMandatory end date at rule creation
Coupon meets automatic discountNeither author knew the other existedReplay the golden-cart corpus whenever any rule changes
Rounding disagreement at refund timeOrder-level discount prorated across lines differently by engine and financeAssert line-level proration sums exactly to the order discount

Why promo logic resists replatforming

Ask why a 10-year-old commerce stack is still alive and the promotions engine is usually in the answer. Orders can be migrated, catalogs can be re-imported, but promotions resist because the old engine's implementation is the only spec that exists.

The behavior that matters was never in a requirements doc. It is the tie-break when 2 rules claim the same line, the choice to round at line level or order level, whether the discount applies before or after tax, and how an order-level discount prorates across lines when 1 line comes back as a return.

Customers noticed those behaviors and finance closed books on them. Reproducing them on a new platform means first discovering them, and the discovery method is the same corpus replay that should have existed all along: run historical carts through the old engine, record totals to the cent, and treat the output as the spec you were never given.

You are not migrating rules. You are migrating years of tie-breaks, rounding choices, and proration decisions that were written down nowhere except the old engine's source.

Then decide deliberately which of those behaviors to keep. Some are load-bearing, some are bugs with tenure, and the replatform is the only moment you get to tell them apart cheaply.

Testing strategies that match the problem

Three techniques, layered, cover most of the risk. Each maps to a property of the problem rather than to a feature list.

First, golden carts. Keep a corpus of real historical carts with known post-discount totals, replay them through the engine on every rule change and every deploy, and treat any diff as a review item. This is regression armor for the behaviors you know about.

Second, property-based testing for the behaviors you have not imagined yet. Hypothesis, the property-based testing library for Python, generates inputs from strategies you define, and when a case fails it shrinks toward a minimal failing example, which for promotions means the smallest cart that breaks an invariant.

# invariants, not examples, do the work
from hypothesis import given

@given(cart=carts(), rules=active_rule_sets())
def test_pricing_invariants(cart, rules):
    priced = engine.price(cart, rules)
    assert priced.payable_total >= 0
    assert priced.discount_total <= priced.item_subtotal
    # fixed point: pricing an already-priced cart changes nothing
    assert engine.price_result(priced, rules) == priced

The invariants do the work, not the examples. Payable total never negative, discount never exceeding the subtotal, line proration summing exactly to the order discount, and item order in the cart never changing the total: each is 1 assertion that holds across every generated combination of rules.

Third, pairwise coverage of the active rule set, per the NIST result. Generate carts that trigger each pair of live rules at least once, and regenerate the suite when the calendar changes, because the test suite has to age at the same speed as the rule set.

Hygiene that stops the aging

Testing catches decay. Process prevents it, and the process is small enough to fit on 1 checklist.

  1. Make the end date mandatory at rule creation, enforced in the admin or the rule template, never left to memory.
  2. Keep a promotions calendar as a reviewed artifact, and expire any active rule the calendar does not reference in a quarterly audit.
  3. Log the rule identifiers applied to every order line, because refund proration and finance attribution both need to know which rules touched the money.
  4. Build a kill switch that removes a rule from evaluation in seconds, separate from the campaign tooling that created it.
  5. Replay the golden-cart corpus as a release gate before every major rule launch, BFCM above all.

The attribution item earns its place the first time a customer returns 1 line from a 3-line order that carried a stacked discount. Without per-line rule attribution, the refund amount is an argument. With it, the refund is a calculation.

The rule set is a deployable artifact

Underneath all of this sits 1 reframing: the rule set, not the engine, is the thing you ship every week, so give it the lifecycle you give code. Export the active rules to versioned files on every change, and the export becomes 3 things at once.

It is a diff, so the question "what changed before totals went strange on Tuesday" has an answer. It is an environment, so staging can run the exact production rule set against the golden carts instead of an approximation someone recreated by hand. And it is a rollback, because restoring last week's rule set is a deploy rather than an archaeology project.

This matters most in the weeks before Black Friday, when rule churn peaks exactly as tolerance for pricing surprises hits zero. A rule-set freeze in mid-November is cheap to hold once the calendar, the export, and the replay gate exist, and close to impossible to improvise without them.

FAQ

Should promotions be a separate service?

Once rules must behave identically across web, app, store, and marketplace channels, yes: 1 evaluation service with 1 decision log beats 4 engines drifting apart. Below that, platform-native rules with the hygiene list above are the simpler correct answer.

How do we test a percentage code stacked on a threshold rule with free shipping?

That triple is exactly what pairwise plus deliberate-stack testing exists for. Cover the 3 pairs automatically, add the intended triple as a named golden cart, and assert the fixed-point invariant so the threshold cannot be un-earned by the discount it granted.

Marketing needs to ship promos in hours, not sprints. Does review kill that?

Review the calendar, not each rule. Templates with mandatory end dates and pre-tested combination classes make the safe path the fast path, and the golden-cart replay runs in minutes, not meetings.

What do we do with the legacy engine's undocumented behavior during a replatform?

Capture it before you touch anything: replay a broad historical cart corpus through the old engine and record the totals as the de facto spec. Then classify each surprising behavior as keep or fix, on purpose, with finance in the room.

Can an LLM manage promotion rules?

Drafting rule configurations from a campaign brief, fine, with the same review as a human author. Deciding discounts at cart time, no: pricing needs deterministic, replayable evaluation, and a model in that path makes every total an anecdote.

References

From the Destm engineering archive. For current work on this topic, start at Solutions or the blog.