Design a coupon and offers engine
The interviewer asks
“Design the coupons and offers engine for an e-commerce or food delivery app. Marketing creates offers like "FLAT100 on orders above Rs 499 for new users" or "10% off with HDFC cards, max Rs 150"; at checkout the system must apply the best valid offers correctly and enforce usage limits and budgets.”
What a strong answer looks like
A strong answer separates three concerns. First, offer definition: a rules model (conditions on user, cart, items, payment method, time, city) plus actions (flat, percent with cap, free delivery, buy-X-get-Y), stored in a database and compiled into an in-memory rule index on every evaluation node. Second, evaluation: a stateless, fast service that takes a cart context and returns applicable offers and the best combination under stacking rules, computing the discount with integer paise and per-line apportioning. Third, redemption accounting: per-user and global usage limits and campaign budgets, which need atomic counters with reservation at order creation and confirm/release on payment, idempotent on order_id. Strong candidates handle the race on 'first 10,000 users', budget burn during sales, fraud (multi-accounting, coupon leaks), and keep the price shown on the cart page consistent with what checkout charges.
Concepts to mention
1 Requirements
Functional
- Marketing creates offers with eligibility rules (new user, city, min cart value, categories, payment method, time window) and benefits (flat, percent with cap, free delivery, BOGO, cashback)
- Cart page shows applicable offers and auto-applies the best one; users can also type a code
- Enforce limits: per-user uses, total redemptions, campaign budget in rupees
- Stacking rules: e.g. one coupon + one bank offer, never two coupons
- Unique single-use codes (e.g. 1M codes for a partner campaign) and public codes
- Reverse redemptions on cancellation or refund
Non-functional
- Evaluation p99 under 50 ms (it sits in cart and checkout paths)
- ~20k evaluations/s at peak (every cart view and change), ~2k redemptions/s
- Usage limits and budgets never exceeded by more than a tiny tolerance
- Discount shown in cart equals discount charged at checkout, or the user is told why
- Offer changes go live within ~1 minute; full audit of who changed what
Out of scope (say it out loud)
- Dynamic pricing of products
- Loyalty points programme
- Settlement of bank-funded offers with the bank (only record funding source)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/offers/evaluate | Input: user, cart lines, address, payment method (optional), entered code. Output: applicable offers, best combination, per-line discounts, reasons for ineligible ones |
POST/v1/redemptions | At order creation: reserve the chosen offers for order_id (idempotent); checks limits and budgets atomically |
POST/v1/redemptions/{order_id}/confirm | Payment success: make redemption final |
POST/v1/redemptions/{order_id}/release | Payment failed, order cancelled or fully refunded: return the use and budget |
POST/admin/v1/offers | Create or edit an offer (draft -> approved -> live), with validation and audit |
POST/admin/v1/offers/{id}/codes | Generate a batch of unique single-use codes |
4 Data model
offersPostgresoffer_idcode (nullable for auto-apply)typeconditions JSON (rule tree)benefit JSONprioritystack_groupper_user_limittotal_limitbudget_paisefunded_by (platform/brand/bank)start_atend_atstatusversionSource of truth, low write rate. Changes publish an event so evaluators reload. Versioned; redemptions record the offer version used.
offer_index (compiled)In-memory on each evaluator, built from offersmaps: city -> offerscategory -> offerspayment_method -> offerscode -> offerPre-filtering by indexed dimensions avoids evaluating all 20k rules per request.
usage_countersRedis (cluster) backed by the redemptions tableoffer:{id}:usedoffer:{id}:budget_spent_paiseuser:{uid}:offer:{id}:usedAtomic Lua check-and-increment for limits; rebuilt from the redemptions table if lost.
redemptionsPostgres (sharded by user_id at scale)redemption_idorder_id + offer_id (unique)user_iddiscount_paisestatus (RESERVED, CONFIRMED, RELEASED)offer_versioncreated_atDurable ledger of every use; source for reconciliation, finance reports and brand/bank billing.
unique_codesPostgres or DynamoDBcode (PK, hashed)offer_idstatus (UNUSED, RESERVED, USED)user_idorder_idSingle-use claim via conditional update WHERE status = 'UNUSED'.
user_segmentsFeature store / Redisuser_id -> {is_new_userorder_countsegments[]fraud_risk}Precomputed segments for targeting; evaluator reads one key per request.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Evaluate offers on the cart page
- 1Cart service calls POST /v1/offers/evaluate with user_id, lines (sku, qty, price in paise, category, brand), city, delivery fee and any typed code.
- 2Evaluator loads the user's segments (new user, order count, risk score) from one key-value read.
- 3It narrows candidates using its in-memory index (offers live now, for this city, matching categories or payment methods): ~20-50 of 20k.
- 4Each candidate's rule tree is evaluated against the cart context; ineligible offers keep a reason ('Add Rs 120 more to unlock').
- 5It reads remaining global and per-user usage from Redis to hide exhausted offers (advisory only, not a reservation).
- 6It computes discounts in integer paise, applies caps, apportions the discount to lines (needed for GST, partial refunds and brand funding), and picks the best valid combination under stacking rules.
- 7Response returns the auto-applied best combination plus the list of other offers; total time ~10-20 ms.
Redeem at order creation
- 1Checkout re-evaluates the cart with the chosen offers (prices and rules may have changed) and then calls POST /v1/redemptions with order_id.
- 2Redemption service runs a Redis Lua script per offer: check per-user used < limit, global used < total_limit, budget_spent + discount <= budget; if all pass, increment all three atomically.
- 3It inserts redemption rows (RESERVED) with unique (order_id, offer_id); a retry with the same order_id returns the existing result without touching counters again.
- 4If any offer fails its check, it rolls back the counters it already incremented for this order and returns which offer is no longer valid; checkout shows the new price before charging.
- 5Payment success event -> CONFIRMED. Payment failure, timeout (sweeper after 30 min) or cancellation -> RELEASED and counters decremented.
- 6Reconciler compares Redis counters with the redemptions table every few minutes and fixes drift; budget alerts notify marketing at 80% and auto-pause at 100%.
7 Deep dives
Rules model and fast evaluation
Avoid hard-coding offers in if-else code; marketing creates new ones daily. Model an offer as conditions plus benefit. Conditions are a small tree of typed predicates: user (is_new, segment in [...], order_count < 3), cart (subtotal >= 49900 paise, contains category 'pizza', brand = X), context (city in [...], time between 22:00-02:00, platform = android), payment (method = card, bin range in HDFC list). Benefits: flat, percent with max cap, free delivery, BOGO on the cheapest item, cashback credited later. Keep the predicate language limited and typed rather than letting marketing write arbitrary scripts; it is safer, can be validated at creation and can be compiled. Evaluation speed comes from indexing: build inverted maps from dimensions (city, category, payment method, code) to offers so each request evaluates only tens of rules. Evaluators are stateless and hold the compiled index in memory; on an OfferChanged event they rebuild and swap atomically. Store the offer version in each redemption so later disputes see the exact rule that was applied.
Usage limits and budgets under concurrency
'First 10,000 orders' or 'Rs 50 lakh budget' are shared counters hit by many checkouts at once. Read-check-write in the application races and overshoots. Use an atomic check-and-increment: a Redis Lua script that checks per-user count, global count and budget, then increments all, or nothing. Redis gives ~100k ops/s per shard; put all keys for one offer in one hash slot using a hash tag ({offer:123}) so the script can touch them, and keep per-user keys separate or in the same slot with the offer tag. Reserve at order creation, confirm on payment, release on failure, like inventory. Because Redis can lose recent writes on failover, the redemptions table is the durable truth: on recovery, rebuild counters as the sum of RESERVED + CONFIRMED rows. For very hot offers, you can pre-allocate budget chunks to evaluator pods (e.g. 1,000 uses at a time) to reduce contention, accepting a small overshoot or undershoot at the end. Tell the interviewer what tolerance the business accepts: overspending 0.1% of a budget is usually fine, double-using a single-use code is not.
Choosing the best combination and splitting the discount
Users expect the app to pick the best deal. With stacking groups (one coupon, one bank offer, one free-delivery offer), the search space is small: for each group pick at most one offer, so brute force over a few candidates per group is fine. Order matters: a percent offer applied after a flat one gives a different total, so define a fixed order (item-level offers, then cart-level coupons, then payment offers on the remaining amount) and stick to it. Always use integer paise and round in one direction at a defined step to avoid one-paisa mismatches between cart, invoice and refund. Apportion each discount across eligible lines in proportion to their value, with the remainder on the largest line; this matters for GST invoices (tax is on the discounted value), partial cancellations (refund the line's net price) and billing brands for brand-funded offers. Cashback offers are not a price reduction; they create a wallet credit after delivery, so they belong in a separate post-order flow.
Abuse and leaks
Coupons are money, so they attract fraud. Common patterns: one person creating many accounts for 'new user' offers (detect with device ID, phone, payment instrument and address fingerprints; tie 'new user' to device and instrument, not just account); public leaks of codes meant for a partner (use unique single-use codes or bind codes to user IDs); bots brute-forcing short codes (rate limit code attempts per user and IP, use long random codes); order-and-cancel loops to farm cashback (release only after delivery, cap per device); and internal mistakes such as a missing max cap on a 50% offer during a sale. Guardrails: mandatory max cap and budget for percent offers, a second approver for offers above a value threshold, budget burn-rate alerts (spending 30% of a day's budget in 10 minutes means something is wrong), and a kill switch that pauses an offer within seconds through the event pipeline.
8 Trade-offs
Typed rule tree vs general rules engine (Drools) vs scripts
A typed predicate tree is fast, safe and easy to validate and explain. A general rules engine is flexible but heavier and harder to reason about. Arbitrary scripts are dangerous. Start with a typed tree and add predicate types as needed.
Reserve at order creation vs at payment
Reserving at order creation guarantees the user gets the discount they saw when paying, at the cost of holding uses for unpaid orders (released after a timeout). Reserving after payment risks charging the full price or honouring an exhausted offer.
Redis counters vs database counters
DB counters with conditional updates are durable but hot rows limit throughput. Redis is fast but needs rebuild-from-ledger on failover. Use Redis for hot campaigns, with the redemptions table as truth.
Auto-apply best offer vs user choice
Auto-applying improves conversion and fairness; letting users choose matters when offers differ in type (cashback vs instant). Auto-apply the best instant discount and show others.
9 Bottlenecks
Sale-start burst on one offer
Thousands of redemptions per second on one offer's counters. Hash-tag keys to one slot and keep the Lua script tiny; if needed, pre-allocate chunks to pods; move evaluation-time limit checks to cached values to avoid read load on the same keys.
Evaluation on browse surfaces
Showing offer badges on every listing card multiplies calls. Precompute non-personal badges ('20% off') into the catalogue/search index and only evaluate personal offers in the cart.
10 Failure handling
Redis down
Evaluation still works with rules only (limits treated as available). Redemption checks fall back to conditional updates on the database for limited offers, at lower throughput, or pause limited offers while unlimited ones keep working.
Payment event lost
Reserved redemptions without confirm or release are swept after 30 minutes by checking the order state with the order service, so uses are not leaked forever.
Misconfigured offer goes live
Burn-rate alerts fire within minutes; the kill switch pauses the offer through an event to all evaluators. Orders already placed are honoured or handled per policy; the audit log shows who approved it.
11 What impresses
- Separating evaluation (fast, stateless) from redemption (stateful, atomic)
- Explaining why the cart and checkout totals can differ and how the user is told
- Apportioning discounts per line for GST and partial refunds
- Rebuilding Redis counters from the ledger after failover
- Budget burn-rate alerts and an instant kill switch
- Defining 'new user' by device and payment instrument, not account
12 Mistakes
- Hard-coding offers in application code
- Checking limits with read-then-write and overshooting the budget
- Using floating point for money
- Incrementing usage on cart view or never releasing it on failure
- Evaluating all offers for every request with no index
- Ignoring fraud and multi-accounting
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.