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
STACKABLEorEXCLUSIVE. At most oneEXCLUSIVEfires. - 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
expiresAtat 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
minSellingPriceclamps 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:
Moneycarries 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
RuleSetProviderreturns a different ordered list per user-bucket; the engine itself stays unchanged. - Personalised offers: an ML service injects synthetic
PersonalisedRuleinstances at the top of the chain with a per-user discount amount. - Scheduled coupons: a
TimeWindowfield gates visibility; rules outside their window returnisApplicable=false. - Supplier-funded vs platform-funded: each
AppliedRulecarries afundingSourceenum so settlement can attribute the discount cost correctly. - Referral codes: a
ReferralRulechecks a referral graph and credits both the referrer and the referee with separateAppliedRuleentries.
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.
Related Topics#
- Rate Limiter (LLD): same atomic-counter pattern powers usage limits.
- Movie Booking (LLD): another checkout flow with concurrency on a scarce resource.
- Behavioral Patterns: Chain of Responsibility plus Strategy reference.