Code a discount and pricing rules engine
Open in the interactive app: practice, quiz, mock interview →In one minute
- A pricing engine is a pure function: cart, active rules, codes and a clock go in; a total and an itemised explanation come out. Everything else (admin screens, campaigns, analytics) hangs off that function.
- Rules are the part that changes weekly, so each one is a Strategy behind one small interface. The engine owns the ordering, the stacking policy and the clamping, so a new rule type never touches existing ones.
- Order is a business decision hidden in code. "20% off then 5 off" and "5 off then 20% off" give different totals (Stripe docs), so priority and stacking policy must be explicit data, not the accident of a list.
- Money is integer minor units with one stated rounding rule, and every discount is allocated back to lines without losing a unit. A total that cannot be explained line by line will be disputed and refunded wrongly.
- The shape is multi-step consistency under policy: many independent rules must compose into one number that stays correct when rules are added, expire or conflict. The same design appears in tax engines, shipping-rate calculators and cloud billing credits.
Requirements
Functional:
- A cart has lines of
(sku, unit price, quantity). The engine returns a subtotal, a total, per-line totals and the list of rules that fired, each with its amount. - Rule types: percentage off (optionally for some SKUs), buy X get Y free, quantity tiers, and coupon codes that take a fixed amount off the order.
- Rules have a priority, a validity window and an optional code the customer must present.
- A stacking policy decides whether all eligible rules apply in order, or only the single best one.
Non-functional:
- Deterministic: the same cart, rules, codes and clock reading always give the same quote, so a quote can be replayed in a dispute.
- Never negative, never lose or invent a minor unit: the sum of the applied discounts equals subtotal minus total, and the line totals sum to the total.
- A quote for a 50-line cart with 100 active rules finishes in well under a millisecond in-process; the network call to the pricing service dominates.
Out of scope: tax, shipping rates, multi-currency conversion, per-customer redemption limits and promotion authoring UI. They are named in Extensibility.
Entities and relationships
- Line is an immutable value: SKU, unit price in minor units, quantity. Resist a
Productclass with agetDiscountedPricemethod; discounts are a property of the cart in context, not of the product. - Rule is the strategy interface: given the lines and the amount still payable per line, return one discount per line. Concrete rules are
PercentOff,BuyXGetY,TieredandAmountOffCoupon. Each carries shared metadata: id, priority, optional code, and a validity window. - PricingEngine owns the sorted rule list, the injected Clock and the stacking Policy. It decides eligibility, runs rules in order, clamps results and builds the quote.
- Quote is the output value. It contains Applications, one per rule that fired, each with the rule id, total amount and the per-line split. The explanation is not a log line bolted on later; it is part of the return type.
Relationships: the engine aggregates rules but rules do not know about each other or about the engine. A quote references rule ids, not rule objects, so it can be stored and shown after the rule is edited or deleted. In a real system you would also store the rule version beside the id; see Extensibility.
Eligibility has two layers, and keeping them apart matters. The engine owns the cross-cutting checks (window, code presented). The rule owns what only it understands (does this SKU match, has the tier been reached, is the minimum spend met). This split is the Specification idea from Evans and Fowler's paper (Specifications): a rule's conditions are small testable predicates that can be combined, instead of an if ladder inside the pricing loop.
Class diagram
classDiagram
class PricingEngine {
-rules: Rule[]
-clock: Clock
-policy: Policy
+quote(lines, codes) Quote
}
class Rule {
<<interface>>
+id: string
+priority: int
+code: string
+start: int
+end: int
+discounts(lines, remaining) int[]
}
class PercentOff {
-bps: int
-skus: string[]
}
class BuyXGetY {
-sku: string
-buy: int
-get: int
}
class Tiered {
-sku: string
-tiers: Tier[]
}
class AmountOffCoupon {
-amountOff: int
-minSubtotal: int
}
class Quote {
+subtotal: int
+discount: int
+total: int
+lineTotals: int[]
+applications: Application[]
}
class Application {
+ruleId: string
+amount: int
+lineAmounts: int[]
}
class Clock {
<<interface>>
+now() int
}
PricingEngine o-- Rule
PricingEngine --> Clock
PricingEngine ..> Quote
Quote "1" *-- "many" Application
PercentOff ..|> Rule
BuyXGetY ..|> Rule
Tiered ..|> Rule
AmountOffCoupon ..|> RuleKey flows
A quote is a single pass over the eligible rules, in priority order. Each rule sees what is still payable after earlier rules, which is what makes order matter.
sequenceDiagram
participant C as Checkout
participant E as PricingEngine
participant K as Clock
participant R1 as BuyXGetY
participant R2 as PercentOff
participant R3 as Coupon
C->>E: quote(lines, codes)
E->>K: now()
K-->>E: 1700000000
E->>E: sort by priority, drop expired or code-less rules
E->>R1: discounts(lines, remaining)
R1-->>E: [300, 0]
E->>E: clamp to remaining, record Application
E->>R2: discounts(lines, remaining)
R2-->>E: [120, 30]
E->>R3: discounts(lines, remaining)
R3-->>E: [50, 0]
E-->>C: Quote(total, lineTotals, applications)Under the best-only policy the loop changes in one place: instead of applying each result, the engine evaluates every eligible rule against the original amounts, keeps the one with the largest total discount (ties go to the earlier rule in priority order) and applies only that. Shopify describes the same fallback: when discounts cannot combine, "the best discount for the customer's cart is always applied" (Shopify Help Center).
Design patterns used and why
- Strategy for each rule type. The engine calls one method,
discounts, and knows nothing about buy-X-get-Y arithmetic. A new promotion type is a new class and a new test, with no edit to the loop. The rule is a pure function of lines and remaining amounts, which is also what makes it testable without a database. - Chain of Responsibility, in its list-plus-driver form. Each rule gets a turn and passes the reduced amounts to the next. The classic linked-handler form (each handler holds a reference to its successor) is a poor fit because priority is data that changes: a sorted list is reorderable, inspectable and loggable, and a linked chain is not. The difference from the textbook pattern is that every handler contributes instead of the first capable handler stopping the chain.
- Specification for eligibility. Window checks, "code presented", "SKU matches", "minimum spend reached" are small predicates. Compose them with
and/orwhen rules multiply; keep them as plain methods until then. - Value objects for
Line,QuoteandApplication. They are immutable, compared by value, and safe to cache or store. Fowler's Money pattern makes the same argument for amounts: wrap the unit and the rounding behind one type so nobody does raw arithmetic (Money, Fowler). - Dependency injection for the clock. A promotion that starts at 09:00 must be testable at 08:59:59 and 09:00:00 without sleeping.
Resist a Decorator stack of PriceWithTenPercentOff(PriceWithCoupon(Price)). It looks elegant for two rules, but you cannot sort it by priority at runtime, cannot show which layer produced which amount, and cannot switch to best-only without rebuilding the chain.
Concurrency and edge cases
- Quotes are read-only. The engine holds an immutable sorted rule list and does no writes, so concurrent quotes need no lock. Rule changes publish a new engine (copy on write) and swap one pointer. A quote already in flight finishes on the old rule set.
- Redemption is the stateful part. A coupon with a global cap of 500 uses is a counter, and "check the counter, then place the order" is a race. Quoting stays pure; redeeming happens at order placement with an atomic conditional increment, and a failed redemption sends the customer back to a fresh quote. Stripe counts both attaching a coupon and entering a promotion code toward the same
max_redemptions(Stripe docs). - Rounding. Percentages produce fractions of a minor unit. The practice code uses half-up on
amount * bpsin integers. Other rules exist (round half to even is common in accounting), and what matters is picking one and applying it at one place (Rounding, Wikipedia). Never use floats for money: 0.1 has no exact binary form (Goldberg). - Minor units are per currency. Yen has no minor unit, so 500 JPY is
500while 10 USD is1000(Stripe docs). Store the currency with every amount and take the exponent from a table, not from an assumption of two decimals. - Allocation. An order-level coupon of 1.00 on a 1.50 cart must land on the lines, or a partial return cannot compute what to refund. Split pro rata with floor division, then hand the leftover minor units out one by one. The sum is exact by construction.
- Floors and clamps. Clamp each rule's result to what is still payable per line, so no rule or combination can push a line below zero. The engine clamps rather than trusting rules, because rules are written by many people.
- Time. Windows are half-open: valid at
start, invalid atend. Evaluate the clock once per quote so a rule cannot be valid for line 1 and expired for line 2. - Minimum spend is checked against the amount still payable after earlier rules in this design. That is a policy choice; some stores check the original subtotal. Document which, and test the boundary.
In the wild
- Kogan, 2011: stackable vouchers sold hardware at a loss. On 10 August 2011 a bug in Kogan's coupon system let a 30 dollar PayPal voucher combine with a free-delivery deal. After a forum post alerted shoppers, an Android TV device (the Agora Android TV Portal) with a normal price of 159 dollars sold for 9, with more than 500 sold plus hundreds of other items before the fix landed at about 10:39am. The terms allowed Kogan to cancel the orders; it honoured them and its founder said the error was entirely its responsibility (ITWire). The lesson for the engine is that stacking needs an explicit policy and a floor on the total, not just a list of rules that each look reasonable alone.
- Shopify, current docs: combination classes and fixed order. Shopify applies product discounts first, then order discounts on the subtotal after them, then shipping. Two percentage order discounts are both computed from the original subtotal (10% and 20% off 100 is 70), but a percentage and a fixed amount apply in that sequence. Combination is opt-in per class, and limits are explicit: up to 25 automatic discounts, 5 product or order codes and one shipping code per order (Shopify Help Center). The design lesson is that a platform with many merchants cannot leave order implicit.
- Stripe, current docs: order of stacked discounts matters. Stripe allows up to 20 discounts on a subscription, item or invoice, states that "20% off then 5 USD off" differs from the reverse when both kinds are present, and rejects a coupon plus a promotion code created from the same coupon (Stripe docs). It also separates the coupon (the discount logic) from the promotion code (a customer-facing code with its own restrictions), the same split this design makes between rule and code eligibility.
Down the stack
Layer 1The contract
quote(lines, codes) returns subtotal = sum(unit x qty), total >= 0 and discount = subtotal - total. Every application has 0 < amount, its line amounts sum to the amount, and the applications sum to the discount. No line total is negative. With the same lines, codes, rule set and clock reading, the result is byte-identical. Under stack, rules apply in (priority, id) order, each against the amounts left by its predecessors. Under best-only, exactly one application or none appears. A rule that is expired, not yet started, or demands an absent code contributes nothing and does not appear in the quote.
Layer 2The mechanism
The engine sorts rules once at construction by (priority, id); the id tie-break makes ordering total, so two rules with equal priority cannot swap places between runs. Per quote it computes a remaining array of line amounts, filters rules with the window and code predicates, then loops. For each rule it calls discounts(lines, remaining), clamps each entry to [0, remaining[i]], and if the sum is positive subtracts it and appends an Application. Percentages use (amount * bps + 5000) / 10000 with integer division, where bps is basis points (1000 = 10%). Buy-X-get-Y gives qty / (buy + get) * get free units, using floor division so a partial group earns nothing. Tiered rules scan sorted (min_qty, bps) pairs and keep the last tier reached. The fixed coupon caps at the payable total and uses largest-remainder style allocation: floor(cap * r_i / total) per line, then one extra unit per line, in order, until the leftover is spent. The leftover is smaller than the number of lines that still have room, so one pass always suffices.
Layer 3The machine
Everything is int64 arithmetic on a handful of values per line. A multiply or divide costs a few CPU cycles (integer division is the slow one at tens of cycles on many x86 cores), so 100 rules over 50 lines is on the order of 5,000 operations, a few microseconds in Go or Rust and tens to a few hundred microseconds in Python, which pays for a dynamic attribute lookup and a method call per rule per line. In Go, a call through the Rule interface loads the method from the interface table and jumps indirectly, around 2 ns, and the compiler cannot inline it; at this volume that is irrelevant. The slices (remaining, per-rule results) are small, short-lived allocations, so allocation, not arithmetic, dominates. Reuse a scratch buffer per request only if profiling says so. Overflow is the real hazard: amount * bps for a 10^12 minor-unit amount and 10^4 bps reaches 10^16, within int64's 9.2 x 10^18 but not within JavaScript's 2^53, about 9 x 10^15. TypeScript needs BigInt once totals can reach that range. Floats are worse: 0.1 + 0.2 is 0.30000000000000004 because a binary double cannot hold 0.1, and a 53-bit mantissa quietly rounds a different way per platform and per operation order.
Why it's built this way
- Customers see and dispute totals, so the total must be reproducible from recorded inputs, which forces a pure function and an injected clock.
- Promotions are created by marketing weekly while checkout code ships monthly, so rules sit behind one interface and priority and policy are data.
- Order changes the answer, so order must be explicit, total (ties broken) and displayed in the explanation.
- Rules come from many authors, so the engine, not each rule, enforces the invariants (clamp at zero, exact sums).
- Money cannot be fractional or approximate, so integers in minor units and one rounding rule in one place.
Extensibility
- Rule versions and audit. Store
(rule_id, version)on every application and persist the quote with the order. Editing a campaign then cannot rewrite history, and support can replay the quote exactly. - Per-customer limits ("one per customer"). This needs state, so add a
RedemptionStoreinterface consulted at order placement, not insidequote. Quoting stays pure; the store is the single place with a race. - Cross-class combination (Shopify-style). Give each rule a class (product, order, shipping) and a set of classes it may combine with. The engine checks that every pair of applied rules allows each other and falls back to best-only when they do not.
- Mutual exclusion groups. Add a
groupfield: within a group only the best rule wins, across groups all stack. This sits between the two policies and covers "only one seasonal sale at a time". - Tax and multi-currency. Tax is computed after discounts in most jurisdictions but not all, so make it a second stage after the discount stage rather than a rule. Currency conversion happens before pricing, with the exponent per currency carried on the amount type.
- Rules as data. Once campaigns come from an admin tool, serialise rules to JSON with a type tag and build them through a factory. Keep the classes; add a validator that rejects a rule that could produce a negative amount.
Leadership lens
- Build first: the engine loop, the clamping and the invariants test (applications sum to the discount, line totals sum to the total). Add rule types after that, since each is small once the contract is pinned.
- Ask in review: what is the stacking policy and who owns changing it; what happens when two rules share a priority; what is the rounding rule and where does it live; what is the floor on the total; how is a quote replayed after the rule changes.
- Pricing mistakes are expensive and public. Kogan could cancel orders under its terms and chose not to, which shows the real cost is trust and cash rather than engineering. Put a below-cost alert and a maximum-discount guard on the quote path, and add a dry-run mode that previews a new campaign against yesterday's carts before it goes live.
- Ownership: marketing owns what the rules say, engineering owns what the engine guarantees. A validated rule format and a preview tool let marketing move quickly without being able to break an invariant.
- Cost drivers are mostly human: support time on disputed totals and refunds computed from unexplained numbers. The explanation field pays for itself there.
Check yourself
Two rules, "20% off" and "5.00 off", are both eligible. Why is the order not a detail, and where should it be decided?
Show answer
On a 100.00 line, percentage first gives 75.00 and fixed first gives 76.00, so the business outcome differs. The order is a policy, held as explicit priority data with a tie-break on rule id, not left to insertion order.
Why does the engine clamp each rule's result instead of trusting the rule?
Show answer
Rules are written by many people and combine in ways their authors did not test. Clamping to the amount still payable guarantees no line goes negative, whatever a single rule returns.
A 1.00 coupon applies to lines of 1.00 and 0.50. What allocation avoids losing or inventing a cent?
Show answer
Take floor of the coupon times each line's share of the total, then give the leftover units out one by one to lines that still have room. The line discounts then sum exactly to the coupon.
Which part of coupon handling must not live inside the pure quote function, and why?
Show answer
Redemption counting (global caps, one per customer). It is shared mutable state with a check-then-act race, so it belongs at order placement with an atomic conditional update.
Sources and further study
Primary
- Specifications (Evans and Fowler): the original paper on composable business-rule predicates behind the eligibility design.
- What Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg): why binary floats cannot hold decimal money.
Systems docs
- Stripe: coupons and promotion codes: a production model of discount logic, customer-facing codes, durations, redemption limits and stacking restrictions.
- Shopify: discount combinations: discount classes, order of application and explicit combination limits.
- Stripe: currencies and minor units: zero-decimal and two-decimal currencies, and why amounts carry a currency code.
- Python decimal module: decimal arithmetic with selectable rounding modes, the alternative to hand-rolled integer rounding.
Books
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 5 (Behavioral), covering Strategy and Chain of Responsibility.
- Patterns of Enterprise Application Architecture, Martin Fowler: the Money pattern in the Base Patterns chapter, on allocation without losing units.
- Domain-Driven Design, Eric Evans: chapter 9 "Making Implicit Concepts Explicit", where specifications are introduced.
Other curricula
- Refactoring.Guru: Chain of Responsibility: presents the linked-handler form where one handler may stop the request, a useful contrast with this lesson's every-rule-contributes driver.
Watch
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): the pattern behind pluggable rules.
- Specification Pattern for Object Oriented Programming and Domain Driven Design (Coding Blocks): composing eligibility predicates.
Free resources
- Strategy pattern (Refactoring.Guru): the pattern each rule type uses.
- Value Object (Martin Fowler): why quotes, lines and amounts are immutable values.
- Rounding (Wikipedia): half-up, half-even and other tie-breaking rules.
- Specification pattern (Wikipedia): the combinator form of eligibility predicates.