Skip to content

Coupon and Discount Engine: LLD Design#

Problem statement (interviewer prompt)

Design a discount engine for an e-commerce checkout. Support percentage, flat, BuyXGetY, and free-shipping coupons. Enforce per-user and per-day usage limits, eligibility rules (min cart value, specific category, first-time buyer), stacking with conflict resolution, and an audit trail. Optimise for adding new rule types without touching the engine. Target rule evaluation under 10 ms per cart and a deterministic result for the same input.

A coupon discount engine LLD blends two patterns: Strategy (each coupon type is a swappable rule) and Chain of Responsibility (rules evaluate in priority order, short-circuiting on exclusive offers). The same engine handles flash-sale codes, loyalty perks, and referral incentives without code changes.

Requirements#

Functional

  • Rule types: PERCENTAGE_OFF, FLAT_OFF, BUY_X_GET_Y, FREE_SHIPPING, plus future types.
  • Eligibility predicates: min cart value, category whitelist, first-time buyer, channel (app, web, kiosk), coupon expiry.
  • Stacking: each rule tagged STACKABLE or EXCLUSIVE. At most one EXCLUSIVE fires.
  • Limits: per-user-per-day, global cap, per-coupon-code lifetime cap.
  • Cap and floor: total discount cannot exceed a configured ceiling; final price must respect a per-SKU floor.
  • Audit: every applied rule logged with inputs, outputs, and a deterministic hash.

Non-functional

  • Evaluate a cart of 50 items in under 10 ms (P99).
  • Deterministic: same CartContext plus same active rule set always returns the same DiscountResult.
  • Thread-safe usage tracking across concurrent checkouts.
  • New rule types added by writing one class, no engine changes.

Class design#

classDiagram
  class CartContext {
    +userId : String
    +items : List~LineItem~
    +subtotal : Money
    +channel : Channel
    +isFirstTimeBuyer : boolean
    +appliedCoupons : List~String~
  }
  class Rule {
    <<interface>>
    +priority() int
    +mode() Mode
    +isApplicable(CartContext) boolean
    +apply(CartContext) DiscountResult
  }
  class PercentageOffRule
  class FlatOffRule
  class BuyXGetYRule
  class ShippingDiscountRule
  class EligibilityCheck {
    +matches(CartContext, CouponCode) boolean
  }
  class RuleEngine {
    -rules : List~Rule~
    -tracker : UsageTracker
    -audit : AuditLog
    +evaluate(CartContext) DiscountResult
  }
  class CouponCode {
    +code : String
    +ruleId : String
    +expiresAt : Instant
    +maxUses : int
    +perUserPerDay : int
  }
  class UsageTracker {
    +tryReserve(code, userId, day) boolean
    +release(code, userId, day)
  }
  class DiscountResult {
    +breakdown : List~AppliedRule~
    +totalDiscount : Money
    +finalTotal : Money
  }
  class AuditLog {
    +record(CartContext, DiscountResult)
  }
  Rule <|.. PercentageOffRule
  Rule <|.. FlatOffRule
  Rule <|.. BuyXGetYRule
  Rule <|.. ShippingDiscountRule
  RuleEngine --> Rule
  RuleEngine --> UsageTracker
  RuleEngine --> AuditLog
  Rule --> EligibilityCheck
  Rule --> CouponCode

Rule is the strategy. RuleEngine owns the chain plus the side effects (usage, audit). CartContext is an immutable snapshot so concurrent evaluations never see partial state.

Rule evaluation flow#

sequenceDiagram
  participant Checkout
  participant Engine as RuleEngine
  participant Ctx as CartContext
  participant Rule
  participant Elig as EligibilityCheck
  participant Tracker as UsageTracker
  participant Audit as AuditLog

  Checkout->>Engine: evaluate(ctx)
  Engine->>Ctx: snapshot
  loop each Rule in priority order
    Engine->>Rule: isApplicable(ctx)
    Rule->>Elig: matches(ctx, coupon)
    Elig-->>Rule: ok
    Rule-->>Engine: yes
    Engine->>Tracker: tryReserve(code,user,day)
    Tracker-->>Engine: reserved
    Engine->>Rule: apply(ctx)
    Rule-->>Engine: AppliedRule
    alt EXCLUSIVE
      Engine-->>Engine: break loop
    end
  end
  Engine->>Audit: record(ctx, result)
  Engine-->>Checkout: DiscountResult

The engine reads from CartContext only. It writes to UsageTracker and AuditLog. Rules are pure functions of the context plus the coupon record.

Coupon stacking decision#

flowchart TB
  start([Cart: 1200 INR, coupon NEW20 plus WELCOME50]) --> sort[Sort rules by priority]
  sort --> r1{NEW20 applicable?}
  r1 -- yes --> apply1[Apply 20% off = 240 INR]
  apply1 --> stk1{STACKABLE?}
  stk1 -- yes --> r2{WELCOME50 applicable?}
  r2 -- yes --> apply2[Apply flat 50 INR off]
  apply2 --> cap{Total under cap of 300 INR?}
  cap -- yes --> done([Final discount 290 INR, total 910 INR])
  cap -- no --> trim[Trim to cap]
  trim --> done
  r1 -- no --> r2
  stk1 -- no, EXCLUSIVE --> doneEx([Stop, only NEW20 wins])

Priority orders the chain. The STACKABLE flag controls whether the loop continues. A final cap enforces the platform rule that no cart can ever be more than 25% off.

Code (Java)#

public interface Rule {
    int priority();
    Mode mode();           // STACKABLE or EXCLUSIVE
    boolean isApplicable(CartContext ctx);
    DiscountResult.AppliedRule apply(CartContext ctx);
}

public final class CartContext {
    public final String userId;
    public final List<LineItem> items;
    public final Money subtotal;
    public final Channel channel;
    public final boolean firstTimeBuyer;
    public final Set<String> couponCodes;
    public final Instant evaluatedAt;
    // builder + defensive copies, no setters
}

public final class PercentageOffRule implements Rule {
    private final CouponCode coupon;
    private final int percent;
    private final Money cap;

    public boolean isApplicable(CartContext ctx) {
        return ctx.couponCodes.contains(coupon.code)
            && coupon.notExpired(ctx.evaluatedAt)
            && ctx.subtotal.compareTo(coupon.minCart) >= 0
            && coupon.eligibility.matches(ctx);
    }

    public AppliedRule apply(CartContext ctx) {
        Money raw = ctx.subtotal.multiply(percent).divide(100);
        Money discount = raw.min(cap);
        return new AppliedRule(coupon.code, discount, this.mode());
    }

    public int priority() { return coupon.priority; }
    public Mode mode() { return coupon.mode; }
}

public final class RuleEngine {
    private final List<Rule> rules;          // sorted by priority desc
    private final UsageTracker tracker;
    private final AuditLog audit;
    private final Money maxTotalDiscount;

    public DiscountResult evaluate(CartContext ctx) {
        List<AppliedRule> applied = new ArrayList<>();
        Money runningDiscount = Money.ZERO;

        for (Rule r : rules) {
            if (!r.isApplicable(ctx)) continue;

            CouponCode cc = r.coupon();
            LocalDate day = LocalDate.ofInstant(ctx.evaluatedAt, ZoneOffset.UTC);
            if (!tracker.tryReserve(cc.code, ctx.userId, day)) continue;

            AppliedRule ar = r.apply(ctx);
            Money next = runningDiscount.plus(ar.amount);
            if (next.compareTo(maxTotalDiscount) > 0) {
                ar = ar.cappedTo(maxTotalDiscount.minus(runningDiscount));
                next = maxTotalDiscount;
            }
            applied.add(ar);
            runningDiscount = next;

            if (r.mode() == Mode.EXCLUSIVE) break;
            if (runningDiscount.equals(maxTotalDiscount)) break;
        }

        Money finalTotal = ctx.subtotal.minus(runningDiscount);
        DiscountResult result = new DiscountResult(applied, runningDiscount, finalTotal);
        audit.record(ctx, result);
        return result;
    }
}

public final class UsageTracker {
    private final RedisTemplate redis;

    public boolean tryReserve(String code, String userId, LocalDate day) {
        String key = "coupon:" + code + ":" + userId + ":" + day;
        Long count = redis.opsForValue().increment(key);
        if (count == 1L) redis.expire(key, Duration.ofDays(2));
        long limit = lookupLimit(code);
        if (count > limit) {
            redis.opsForValue().decrement(key);   // rollback
            return false;
        }
        return true;
    }
}

The rule loop is short, the per-rule logic is isolated. New rule types plug in by implementing Rule.

Concurrency#

Two checkouts redeeming the last slot of FLASH100 at the same instant: both call tryReserve. Redis INCR is atomic, so one returns 2 (over limit) and rolls back, the other returns 1 and proceeds. The DB equivalent uses an INSERT ... ON CONFLICT DO NOTHING against a uniqueness key of (code, user, day).

Cart mutation during evaluation is prevented by snapshotting: the engine receives an immutable CartContext copied at the start of checkout. Any cart edits create a new context and re-evaluate.

Retries must be idempotent. Each evaluation carries a requestId. The audit log keys by requestId, and the UsageTracker increments only once per requestId (Lua script or DB unique constraint).

Edge cases#

  • Coupon expires mid-checkout: rule re-checks expiresAt at apply time; if expired, the engine returns the un-discounted total and the UI shows a clear retry.
  • Partial refunds: discounts unwind in reverse priority. The platform-funded portion is refunded first; supplier-funded discounts roll back via a settlement reconciliation.
  • Price-floor rules: a per-SKU minSellingPrice clamps the discount. If a rule would push the line item below floor, the engine trims the rule output to the floor.
  • Tax recalculation: in jurisdictions where tax is computed on the discounted price, the engine emits the discount and the tax service re-runs after.
  • Currency conversion: Money carries currency; rules reject contexts whose currency does not match the coupon currency. Multi-currency carts split into per-currency evaluations.

Extensions#

  • A/B testing: a RuleSetProvider returns a different ordered list per user-bucket; the engine itself stays unchanged.
  • Personalised offers: an ML service injects synthetic PersonalisedRule instances at the top of the chain with a per-user discount amount.
  • Scheduled coupons: a TimeWindow field gates visibility; rules outside their window return isApplicable=false.
  • Supplier-funded vs platform-funded: each AppliedRule carries a fundingSource enum so settlement can attribute the discount cost correctly.
  • Referral codes: a ReferralRule checks a referral graph and credits both the referrer and the referee with separate AppliedRule entries.

Quick reference#

Decision Choice
Pattern Strategy plus Chain of Responsibility
Context Immutable CartContext snapshot
Conflict resolution Priority order, EXCLUSIVE mode short-circuits
Cap Single max-total-discount, plus per-SKU floor
Usage tracking Atomic Redis INCR with TTL, or DB unique constraint
Determinism Pure rules, evaluatedAt timestamp in context
Extensibility New Rule class, registered via DI or config
Audit AuditLog.record after each evaluation, keyed by requestId

Refs#

  • Shopify Engineering: discount allocation engine and shopping cart pricing.
  • Razorpay Engineering: coupon and offer service design.
  • Stripe: coupon plus promotion-code data model for billing.

FAQ#

How do you design a coupon / discount engine in an LLD interview?#

Model the cart as an immutable CartContext, define a Rule interface with isApplicable plus apply methods, implement each coupon type as a concrete Rule, and let a RuleEngine evaluate the chain in priority order with a max-discount cap.

What design pattern fits a discount engine?#

Strategy plus Chain of Responsibility: each Rule (PercentageOff, FlatOff, BuyXGetY, ShippingDiscount) is a strategy; the RuleEngine chains them, each rule reads CartContext and either applies or passes.

How do you handle coupon stacking and conflicts?#

Tag each rule as STACKABLE or EXCLUSIVE, sort by priority, and short-circuit on the first EXCLUSIVE that fires. Apply a max-total-discount cap and a never-below-cost floor to bound the combined effect.

How do you prevent coupon abuse (per-user / per-day limits)?#

A UsageTracker stores counts keyed by (couponCode, userId, day) and uses atomic compare-and-increment (DB UNIQUE constraint or Redis INCR with TTL) so concurrent checkouts cannot exceed the limit.

How does the engine validate eligibility?#

Each rule runs an EligibilityCheck against CartContext: min cart value, allowed categories, first-time buyer flag, channel (app vs web), and expiry window. A failed check returns NotApplicable and the chain moves on.

How is the engine kept extensible for new rule types?#

All rules implement the same interface and are discovered via a registry (Spring beans, ServiceLoader, or config). Adding a new coupon type means writing one class; the engine never changes.