Design a Rewards/Loyalty Points System
Problem Design a rewards/loyalty points system: users earn points on orders, view their balance, redeem points for discounts, and promotional multipliers apply — without ever double-counting or losing points.
Functional requirements
- Award points on order completion based on a configurable ruleset (order value, tier, category).
- Apply promotional multipliers (e.g. 2x points on weekends).
- Show a user their current balance and a transaction history.
- Redeem points for a discount at checkout, validating sufficient balance.
- Handle order cancellation/refund after points were earned or redeemed.
- Support point expiry.
Non-functional requirements
- ~50M users, ~2M orders/day → ~25 earn events/sec average, ~300/sec at dinner peak.
- ~200k redemptions/day, concentrated at checkout: ~50/sec peak, but each is on the critical purchase path — p99 < 200 ms.
- Balance read is on the app home screen: ~10k QPS.
- Ledger is append-only and retained 7 years: ~5B rows → time-partitioned.
- Correctness is the top-priority NFR: zero double-credits, zero lost points, full auditability. Points are a financial liability on the balance sheet.
Key components
- PointsLedger: append-only event log (earn / redeem / expire / adjust), never a mutable balance column. Balance is derived, and a cached balance projection serves the 10k QPS read path.
- RewardsService: consumes order-completed events from a queue, evaluates the rules, and writes an earn entry keyed by order_id.
- Rules engine / config service: multiplier and eligibility rules looked up at earn time, versioned so a historical entry can be explained.
- RedemptionService: validates balance and writes a redeem entry transactionally with the discount application.
- Balance projection: maintained by a trigger or a stream consumer over the ledger, with periodic reconciliation against a full ledger sum.
- Expiry job: scheduled sweep writing expiry entries for aged lots.
Deep dives / trade-offs
- Append-only ledger vs mutable balance: a balance column invites lost updates under concurrent earn/redeem (classic read-modify-write race) and leaves no audit trail when a user disputes their balance. The ledger makes balance derivable and auditable at any point in time; the cost is that reads need a projection and a reconciliation job to catch projection drift. This mirrors how real double-entry financial systems work.
- Idempotency: order-completed events will be redelivered. A unique constraint on (order_id, event_type) in the ledger is what actually prevents double-crediting — not application-level checks, which race.
- Compensation over mutation: a cancelled order after points were credited must write a compensating negative entry, never delete or edit the original. Walk through the nasty case: user earns 500 points, spends them, then refunds the order — the balance goes negative. Do you allow negative balances, claw back, or eat the loss? There's no purely technical answer; state the policy.
- Redemption concurrency: two devices redeeming simultaneously must not both succeed on the same balance. Conditional write / optimistic concurrency on a balance version, or a serialized per-user transaction — discuss why a plain SELECT-then-INSERT is broken.
- Expiry semantics: FIFO lot-based expiry (oldest points first) requires tracking lots, not just a scalar balance — this changes the ledger model, so decide early.
- Redemption spans the points service and the order/payment service — a saga with compensation, since a failed payment after a successful redeem must return the points.
asked …