What Is a Loyalty and Rewards System?
A loyalty program tracks user activity and awards points redeemable for discounts or products. Amazon Prime, Starbucks Rewards, and airline miles programs are examples. Core challenges: points accrual without race conditions, expiry, transaction consistency, and scaling to tens of millions of members.
System Requirements
Functional
- Award points for purchases, referrals, reviews, login streaks
- Redeem points against purchases
- Points expiry after 12 months
- Tier system: Bronze/Silver/Gold based on spend
- Promotional multipliers: 2x/3x points during campaigns
Non-Functional
- Exact consistency — no lost or duplicated points (billing depends on it)
- Idempotency — retries must not double-award
- 100M members, 10K transactions/second peak
Core Data Model
members: id, user_id, tier, total_earned, total_redeemed
point_transactions: id, member_id, type(earn/redeem/expire), amount,
reference_id, reference_type, created_at, expires_at
point_balances: member_id, available_points, tier_points_ytd
Points Accrual — Idempotency
Risk: loyalty service crashes after processing but before committing Kafka offset — event redelivered, double points. Solution: unique constraint on (reference_id, reference_type):
INSERT INTO point_transactions
(member_id, type, amount, reference_id, reference_type)
VALUES (?, 'earn', ?, 'order_123', 'purchase')
ON CONFLICT (reference_id, reference_type) DO NOTHING;
Even if processed twice, only one row is inserted. Safe to replay Kafka events.
Balance — Ledger + Cached Sum
Update cached balance atomically with each transaction:
BEGIN TRANSACTION;
INSERT INTO point_transactions (...) VALUES (...);
UPDATE point_balances
SET available_points = available_points + ?
WHERE member_id = ?;
COMMIT;
Redemption — Preventing Overdraft
User tries to redeem 500 points but only has 300. Pessimistic locking:
BEGIN TRANSACTION;
SELECT available_points FROM point_balances
WHERE member_id = ? FOR UPDATE; -- row lock
IF available_points < 500: ROLLBACK;
INSERT INTO point_transactions (type='redeem', amount=-500, ...);
UPDATE point_balances SET available_points = available_points - 500;
COMMIT;
FOR UPDATE prevents two concurrent redemptions from both succeeding with insufficient balance. Low contention (one user’s balance) justifies pessimistic over optimistic locking here.
Points Expiry
- Store expires_at on each earn transaction
- Nightly batch job: compute unredeemed amount of expiring transactions, insert expiry transaction
- FIFO redemption: consume oldest non-expired points first
- Notify members 30/7/1 days before expiry
Tier System and Promotions
Tiers computed by nightly job on rolling-12-month spend. Store tier_points_ytd in point_balances. Promotions: at accrual time, check active promotions and apply highest multiplier. Store multiplier in point_transactions for auditing.
Caching
- Member balance/tier: Redis 30-second TTL, invalidated on each transaction
- Active promotions: Redis 5-minute TTL
- Transaction history: paginated, 60-second TTL
Interview Tips
- Idempotency via unique constraint on reference_id is the core reliability pattern.
- Ledger + cached balance = double-entry accounting — shows financial systems knowledge.
- FOR UPDATE for redemption: low contention but high cost of overdraft justifies pessimistic locking.
- FIFO expiry and tier computation are product details that differentiate senior candidates.