Engineering Atlas › Low-Level Design

Code a discount and pricing rules engine

17 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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 ..|> Rule

Key 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

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

In the wild

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

  1. Customers see and dispute totals, so the total must be reproducible from recorded inputs, which forces a pure function and an injected clock.
  2. Promotions are created by marketing weekly while checkout code ships monthly, so rules sit behind one interface and priority and policy are data.
  3. Order changes the answer, so order must be explicit, total (ties broken) and displayed in the explanation.
  4. Rules come from many authors, so the engine, not each rule, enforces the invariants (clamp at zero, exact sums).
  5. Money cannot be fractional or approximate, so integers in minor units and one rounding rule in one place.

Extensibility

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →