Low-Level Design: Coupon and Discount System — Validation, Stacking Rules, Usage Limits, and Analytics

Core Entities

Coupon: (coupon_id, code, discount_type=PERCENTAGE|FIXED_AMOUNT|FREE_SHIPPING|BUY_X_GET_Y, discount_value, min_order_amount_cents, max_discount_cents, start_date, end_date, is_active, usage_limit_global, usage_limit_per_user, applicable_to=ALL|CATEGORY|PRODUCT, applicable_ids[]). CouponUsage: (usage_id, coupon_id, user_id, order_id, discount_applied_cents, used_at). DiscountRule: for automatic discounts (no code needed, e.g., “10% off all orders above $50 in July”). CouponStack: which coupons can be combined (e.g., a welcome coupon can stack with a category coupon but not with another percentage coupon).

Coupon Validation

On coupon code submission: (1) Code exists and is active: SELECT coupon WHERE code=:code AND is_active=TRUE. (2) Date validity: NOW() BETWEEN start_date AND end_date. (3) Minimum order amount: cart_total >= coupon.min_order_amount_cents. (4) Applicable to cart items: if coupon.applicable_to=CATEGORY, at least one cart item must be in applicable_ids. (5) Global usage limit: SELECT COUNT(*) FROM coupon_usage WHERE coupon_id=:id. If count >= coupon.usage_limit_global: return “Coupon has reached its maximum uses”. (6) Per-user limit: SELECT COUNT(*) FROM coupon_usage WHERE coupon_id=:id AND user_id=:user. If count >= coupon.usage_limit_per_user: return “You have already used this coupon”. Run all checks before applying. Return the first failure with a clear error message.

Concurrency: Preventing Over-Use

Race condition: two users simultaneously apply the last use of a coupon. Both pass the usage count check (count=99, limit=100) and both apply it. Result: 101 uses of a 100-use coupon. Solutions: (1) Database UPDATE with check: UPDATE coupons SET current_uses=current_uses+1 WHERE coupon_id=:id AND current_uses usage_limit_global: DECR to undo and reject. Redis is faster but requires Redis + DB consistency. (3) Pessimistic lock: SELECT FOR UPDATE on the coupon row, check and increment in the same transaction. Safest but slowest. The Redis approach is best for high-traffic promotions (e.g., flash sale coupons).

Discount Calculation

Apply discounts after validation. Discount types: PERCENTAGE: discount_cents = round(cart_total * discount_value / 100). Cap at max_discount_cents if set. FIXED_AMOUNT: discount_cents = min(discount_value, cart_total) (cannot discount more than the order). FREE_SHIPPING: set shipping_cents = 0. BUY_X_GET_Y: find qualifying items, apply discount to the Y cheapest items. Stacking rules: check CouponStack table to determine if multiple coupons are combinable. Apply in order: free shipping first (no interaction), then fixed amounts, then percentages. Always apply to the post-previous-discount price to prevent over-discounting. Store discount_applied_cents on the CouponUsage record for accounting and analytics.

Automatic Discounts

Automatic discounts apply without a code (e.g., “Summer Sale: 20% off Electronics in July”). Store as DiscountRule with the same attributes as coupons but with auto_apply=TRUE. On cart load: evaluate all active DiscountRules against the cart. Apply all matching rules. Display applied discounts to the user in the cart summary. Priority: if two rules conflict, apply the one with higher priority (priority field on DiscountRule). Stacking: automatic discounts may stack with each other or with coupons — configurable. The cart total after all automatic discounts is the base for coupon validation.

Analytics

Track coupon performance: redemption rate (used/distributed), average discount per redemption, revenue attributed to coupons (orders that used a coupon), orders per coupon, and incremental revenue (did the coupon cause a purchase or was the user going to buy anyway?). The incremental revenue question is answered with A/B tests (show coupon to 50%, not to 50%; compare conversion and revenue). Store a coupon_distributed table (coupon_id, user_id, channel=EMAIL|PUSH|REFERRAL, distributed_at) for tracking the full funnel. Dashboards: aggregate by coupon, by campaign, by time period.

Interview Tips

  • The hardest part is concurrency on usage limits during flash sales. Redis INCR + DECR on limit violation is the standard high-performance solution.
  • Store the discount_applied_cents at order time — do not recalculate it later. Coupon values or rules may change; the historical discount amount must be preserved for accounting.
  • Stacking rules are a design complexity that separates a basic coupon system from a production one. Always mention them in interviews as a design consideration.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent coupon over-use under high concurrency?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When many users simultaneously apply the last available use of a coupon, naive implementations allow more uses than the limit. Prevention options: (1) Atomic database update: UPDATE coupons SET current_uses = current_uses + 1 WHERE coupon_id = :id AND current_uses usage_limit: DECR to undo and reject. Redis handles thousands of INCR/second safely. Use Redis as the gate; record actual usage in the database asynchronously (on order completion). (3) Semaphore with Redis: SET NX coupon:{id}:lock with a TTL. Only one request processes at a time. Lowest throughput but simplest correctness. For flash sale coupons with thousands of redemptions per second: Redis INCR is the right choice.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement percentage vs flat discount calculation correctly?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Percentage discount: discount_cents = round(cart_subtotal_cents * discount_percent / 100). Apply max_discount_cents cap: actual_discount = min(discount_cents, coupon.max_discount_cents). This prevents ‘90% off’ coupons from giving excessive discounts on large orders. Fixed amount discount: actual_discount = min(coupon.discount_amount_cents, cart_subtotal_cents). Cannot discount more than the order total — avoids negative order totals. Free shipping: set shipping_cents = 0 regardless of cart total. Apply shipping discount before percentage discounts (percentage applies to subtotal, not shipping). Order of operations matters: apply shipping discounts first, then fixed amounts, then percentages. Always compute on integer cents to avoid floating-point rounding errors.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle coupon stacking rules?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Stacking defines which coupons can be combined. Implementation: CouponStackGroup table: each coupon belongs to a group (e.g., WELCOME, SEASONAL, LOYALTY, REFERRAL). StackRule table: (group_a, group_b, is_combinable). When multiple coupons are applied: for each pair, check if their groups are combinable. If any pair is not combinable: reject one (typically the lower-value one, or show an error). Simple approach: is_stackable boolean on the coupon. Only one non-stackable coupon can be applied per order; stackable coupons can be combined freely. Most e-commerce platforms use this simple approach. Complex stacking (percentage + fixed OK, two percentages NOT OK) requires the group-based approach. Always apply the most favorable combination and communicate clearly to the user which coupons were applied.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement per-user coupon usage limits without database bottlenecks?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Per-user limit (e.g., each user can use a coupon at most once): SELECT COUNT(*) FROM coupon_usage WHERE coupon_id=:cid AND user_id=:uid. This query must be fast — index on (coupon_id, user_id). For high-traffic coupons: Redis SETNX coupon:{id}:user:{uid} with TTL. If the key already exists: user has used it. This is O(1) and handles thousands of requests per second. On order completion: persist the usage to the database (coupon_usage INSERT). On order cancellation: delete the Redis key and the database record to allow reuse. Edge case: if the order fails after the Redis key is set but before DB INSERT: the key expires (TTL cleanup), allowing a retry. Set TTL long enough for order processing but short enough to not block legitimate retries (e.g., 30 minutes).”
}
},
{
“@type”: “Question”,
“name”: “How do you build coupon analytics to measure campaign effectiveness?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Track the full coupon funnel: distributed (how many users received the coupon), viewed (user saw the coupon in UI), applied (coupon entered at checkout), completed (order with coupon was paid). Funnel: distributed -> viewed -> applied -> completed. Conversion rate at each step identifies where users drop off. Metrics per coupon: total redemptions, average order value with vs without coupon, total discount given, revenue generated (orders attributed to coupon), and incremental revenue (revenue that would not have happened without the coupon). Incremental revenue requires an A/B test: show coupon to 50% of eligible users, compare conversion rates. Without an A/B test, you cannot distinguish ‘coupon caused purchase’ from ‘user was going to buy anyway.’ Store coupon_distributed table (coupon_id, user_id, channel, distributed_at) to enable funnel analysis.”
}
}
]
}

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Asked at: DoorDash Interview Guide

Asked at: Snap Interview Guide

Scroll to Top