Coupon System LLD: Rules Marketing Can Edit, Money That Can't Lie
A low-level design walkthrough of a coupon and promo system: eligibility rules as composable data, discounts with caps, the preview-redeem split, and usage limits that survive a rush.
"Design a coupon system — like Zepto's." It sounds like an if-statement with a marketing budget, and that's the trap: every promo system that starts as if (code.equals("WELCOME100")) ends, eighteen months later, as a two-thousand-line method nobody dares touch during a sale. The question is really three questions this queue has trained for: rules that change weekly without deploys (data, not code), a checkout that must tell users why a code failed (a reason, not false), and a usage limit that must hold while ten thousand people hammer FLASH50 at noon (the reservation handshake, wearing a discount).
Phase 3 closes here — with the design that puts more of this queue's tools in one box than any other.
Let's start nowhere near a computer
A cinema's student Tuesday: tickets half-price if you show a student ID, and it's a Tuesday, and it's before 6 p.m. — capped at ₹150 off, one per student, first 200 students only. The cashier doesn't memorize this; she reads it off a laminated card by the till, checks each condition aloud — and when you fail one, she tells you which ("it's 6:15, beta"). And the counter on her clicker — 184, 185, 186 — clicks only when a ticket is actually sold, never when someone merely asks.
Everything is on the card: the conditions, the discount with its cap, the limits. The cashier is an interpreter. Head office reprints cards weekly; the cashier never retrains. And the clicker-at-sale rule is the whole concurrency story, foreshadowed.
Where the laminated card runs
- Every checkout you've used — promo engines at Zepto, Swiggy, Amazon are card-stacks plus an interpreter.
- Feature flags and pricing tiers — eligibility predicates over a context object; the same engine sells groceries and gates beta features.
- The jackpot paytable and alarm rules — third and fourth sightings of rules-as-rows; this one adds composition.
Step 1 — Functional requirements (sentences first)
What the engine must do, as plain sentences — the functional requirements.
- A coupon has eligibility rules — all must pass: minimum cart, first order, expiry…
- A passing coupon computes a discount: flat, or percent with a cap.
- A failing coupon explains itself: the first failing rule's reason reaches the user.
- Applying at cart time is a preview; only payment redeems — and redemption enforces per-user and global limits.
- Marketing creates and retires coupons without engineering.
That fourth sentence is a deliberate stance, not a convenience. Typing a code into the box is a question — "what would this save?" — and asking must be free, repeatable, and side-effect-less; only a paid order may move a counter. Fold preview into redeem and you either burn a use every time someone experiments, or you check the limit somewhere that the actual sale never honors. The preview/redeem split is a feature, the same way backpressure is: it draws the line between asking and spending before a single counter exists.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a promo engine they are the design:
- Correctness. A limited coupon must never oversell its budget, and an invalid one must never apply — the discount the engine grants is money the business actually owes.
- Thread-safety. Concurrent redemptions must respect the limit; the check-then-act between "is there budget left?" and "take a unit" is the race the whole design exists to close.
- Precision. Money is integer paise and percentages are integer basis points, every discount capped — never a float, because a floating ₹0.01 leak is a correctness bug wearing a rounding costume.
- Editability / extensibility. Rules are data, changeable without a deploy; a new rule type plugs into the same list without the engine changing a line.
- Clarity. A rejection returns a reason, not a bare
false— the user at the payment screen learns why, and the API's contract carries it.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | redeem re-validates every rule at the moment of truth, then checks both limits before granting — Step 4 |
| Thread-safety | the limit check and the counter increment live inside one synchronized — check-and-act is welded — Step 4 |
| Precision | integer paise + basis points, every discount Math.min-capped — Step 2's Discount |
| Editability / extensibility | rules are Rule objects carried as data on the coupon; new types implement the interface — Steps 2, 3 |
| Clarity | Optional<String> reject(...) returns the reason; the first failing rule speaks — Steps 2, 3 |
Every trade-off below is chosen to keep one of these.
Step 3 — Rules: small predicates that explain themselves
The composable atom — note the return type, which is the design:
public interface Rule {
Optional<String> reject(Cart cart); // empty = pass; present = the REASON
}
public record MinCartRule(long minPaise) implements Rule {
@Override
public Optional<String> reject(Cart cart) {
return cart.totalPaise() >= minPaise
? Optional.empty()
: Optional.of("add ₹" + (minPaise - cart.totalPaise()) / 100 + " more to use this code");
}
}
public record FirstOrderRule() implements Rule {
@Override
public Optional<String> reject(Cart cart) {
return cart.firstOrder() ? Optional.empty() : Optional.of("only valid on your first order");
}
}A boolean isEligible() is the rookie signature — it can only say no. Optional<String> reject() says no, because — and the cashier's "it's 6:15, beta" becomes the API's contract. The expiry rule gets the injected clock, because a coupon you can't fast-forward past is a coupon you can't test.
Discounts are the other axis — one job, swappable math, the cheat sheet's Strategy with the cap as the detail everyone forgets:
public interface Discount {
long amountOff(long totalPaise);
}
public record FlatDiscount(long offPaise) implements Discount {
@Override
public long amountOff(long totalPaise) {
return Math.min(offPaise, totalPaise); // never discount below free
}
}
public record PercentDiscount(int basisPoints, long capPaise) implements Discount {
@Override
public long amountOff(long totalPaise) {
return Math.min(totalPaise * basisPoints / 10_000, capPaise); // "10% up to ₹100"
}
}Basis points and paise — the Splitwise commandment holds at the till: integer money, always.
Which structure — and why. Three choices carry this design, and each pays a non-functional requirement. Rules are composable objects, not code — every rule is a Rule returning Optional<String> reject(...), carried as a plain list on the coupon; that list is the editability and extensibility NFR, because marketing edits data and a new rule type implements one method instead of editing the engine. The reason-bearing return type is clarity — Optional<String> says "no, because," where a boolean could only say "no." And money is integer paise and basis points, capped — that is precision, the float kept out of the room entirely. The one piece of shared mutable state, the usage counters, is welded to its check by a single lock in the next two steps — that's correctness and thread-safety, and we keep the contended surface deliberately small (this is class-level design — counters in maps under one lock, no datastore, no shards).
Step 4 — Preview: free to ask, honest when refusing
/** What would this code save? Throws the first failing rule's reason. Consumes nothing. */
public synchronized long preview(String code, Cart cart) {
Coupon coupon = couponOrThrow(code);
for (Rule rule : coupon.rules()) {
Optional<String> reason = rule.reject(cart);
if (reason.isPresent()) {
throw new IllegalStateException(reason.get()); // the cashier, out loud
}
}
return coupon.discount().amountOff(cart.totalPaise());
}All rules must pass — first failure speaks. And preview touches no counters: the user typing codes into the box at cart time is asking, and asking is free. That separation is the next section's whole point.
Step 5 — Redeem: the clicker clicks exactly once
Noon. FLASH50, global limit 10,000. Forty thousand thumbs. If the limit check and the increment aren't one step, you'll honor twelve thousand coupons — the oversell bug with a hashtag. Redemption is check-then-act, welded:
/** Payment time: re-validate, enforce limits, consume — atomically. */
public synchronized long redeem(String code, Cart cart) {
long discount = preview(code, cart); // rules can change between cart and pay —
Coupon coupon = couponOrThrow(code); // re-check at the moment of truth
if (globalUses.getOrDefault(code, 0) >= coupon.globalLimit()) {
throw new IllegalStateException("this code's budget is exhausted");
}
String userKey = code + "|" + cart.userId();
if (userUses.getOrDefault(userKey, 0) >= coupon.perUserLimit()) {
throw new IllegalStateException("you've already used this code");
}
globalUses.merge(code, 1, Integer::sum); // the clicker — inside the lock
userUses.merge(userKey, 1, Integer::sum);
return discount;
}Three beats to narrate. Re-validation at redeem — the cart changed, the clock moved; the airline's confirm-re-checks-the-clock lesson. Both limits inside the same synchronized as their increments — the whole point. And the failure messages stay human, because the user at the payment screen is the least patient user you have.
The follow-up with teeth: payment fails after redeem — is the use burned?
Production answer: redemption joins the payment's transaction, or gets a
compensating unredeem on payment failure — the ATM's plan → debit →
commit ordering, with the coupon counter as one more pot
of truth. Name the saga; don't hand-wave it.
Step 6 — Best coupon: a fold over the cards
"Apply the best offer automatically" — sounds like a feature, is actually a one-liner once the engine exists:
public synchronized Optional<String> bestFor(Cart cart) {
return coupons.values().stream()
.filter(c -> c.rules().stream().allMatch(r -> r.reject(cart).isEmpty()))
.max(Comparator.comparingLong(c -> c.discount().amountOff(cart.totalPaise())))
.map(Coupon::code);
}Stacking (combining coupons) is the deliberate non-feature: it's a policy minefield (which order? caps against what base?), and the honest v1 says one coupon per order out loud — then offers stacking groups as the v2 sentence.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
rules as data (a Rule list) | rules as code (an if-chain) | marketing edits coupons without a deploy; new rule types plug in untouched | editability |
Optional<String> reject(...) | boolean isEligible() | a refusal carries its reason to the payment screen, not a silent false | clarity |
| preview consumes no counters | one path that checks-and-consumes | asking is free and side-effect-less; only a paid order moves a counter | correctness |
| welded check-and-decrement (one lock) | check-then-act across two steps | the noon limit holds; you can't oversell FLASH50 to forty thousand thumbs | thread-safety |
| integer paise / basis points, capped | floating-point percentages | no ₹0.01 leak, no uncapped percent — the discount granted is exact | precision |
Failure and edge handling — named, not hand-waved. Two edges decide this design, and both are handled in Step 5. The first is the limit race — the noon check-then-act — welded inside the lock. The second is payment failing after redeem: the use is provisionally burned, so redemption either joins the payment's transaction or gets a compensating unredeem (the ATM's plan → debit → commit ordering). An idempotency key makes a retry safe, so it never burns two uses. The smaller edges follow the same instinct: an unknown code throws IllegalArgumentException, an expired or ineligible one throws with a human reason, and a discount can never exceed the cart — illegal input and illegal state both speak before they corrupt a counter.
The complete implementation
package dev.fiveyear.coupons;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class CouponEngine {
public record Cart(String userId, long totalPaise, boolean firstOrder) {}
public interface Rule {
Optional<String> reject(Cart cart);
}
public record MinCartRule(long minPaise) implements Rule {
@Override
public Optional<String> reject(Cart cart) {
return cart.totalPaise() >= minPaise
? Optional.empty()
: Optional.of("add ₹" + (minPaise - cart.totalPaise()) / 100 + " more to use this code");
}
}
public record FirstOrderRule() implements Rule {
@Override
public Optional<String> reject(Cart cart) {
return cart.firstOrder() ? Optional.empty() : Optional.of("only valid on your first order");
}
}
public static final class ExpiryRule implements Rule {
private final Clock clock;
private final Instant validUntil;
public ExpiryRule(Clock clock, Instant validUntil) {
this.clock = clock;
this.validUntil = validUntil;
}
@Override
public Optional<String> reject(Cart cart) {
return clock.instant().isBefore(validUntil)
? Optional.empty()
: Optional.of("this code has expired");
}
}
public interface Discount {
long amountOff(long totalPaise);
}
public record FlatDiscount(long offPaise) implements Discount {
@Override
public long amountOff(long totalPaise) {
return Math.min(offPaise, totalPaise);
}
}
public record PercentDiscount(int basisPoints, long capPaise) implements Discount {
@Override
public long amountOff(long totalPaise) {
return Math.min(totalPaise * basisPoints / 10_000, capPaise);
}
}
public record Coupon(String code, List<Rule> rules, Discount discount,
int perUserLimit, int globalLimit) {}
private final Map<String, Coupon> coupons = new HashMap<>();
private final Map<String, Integer> globalUses = new HashMap<>();
private final Map<String, Integer> userUses = new HashMap<>();
public synchronized void register(Coupon coupon) {
coupons.put(coupon.code(), coupon);
}
public synchronized long preview(String code, Cart cart) {
Coupon coupon = couponOrThrow(code);
for (Rule rule : coupon.rules()) {
Optional<String> reason = rule.reject(cart);
if (reason.isPresent()) {
throw new IllegalStateException(reason.get());
}
}
return coupon.discount().amountOff(cart.totalPaise());
}
public synchronized long redeem(String code, Cart cart) {
long discount = preview(code, cart);
Coupon coupon = couponOrThrow(code);
if (globalUses.getOrDefault(code, 0) >= coupon.globalLimit()) {
throw new IllegalStateException("this code's budget is exhausted");
}
String userKey = code + "|" + cart.userId();
if (userUses.getOrDefault(userKey, 0) >= coupon.perUserLimit()) {
throw new IllegalStateException("you've already used this code");
}
globalUses.merge(code, 1, Integer::sum);
userUses.merge(userKey, 1, Integer::sum);
return discount;
}
public synchronized Optional<String> bestFor(Cart cart) {
return coupons.values().stream()
.filter(c -> c.rules().stream().allMatch(r -> r.reject(cart).isEmpty()))
.max(Comparator.comparingLong(c -> c.discount().amountOff(cart.totalPaise())))
.map(Coupon::code);
}
private Coupon couponOrThrow(String code) {
Coupon coupon = coupons.get(code);
if (coupon == null) {
throw new IllegalArgumentException("unknown code: " + code);
}
return coupon;
}
}Tuesday at the cinema, online:
CouponEngine engine = new CouponEngine();
engine.register(new Coupon("WELCOME100",
List.of(new MinCartRule(49_900), new FirstOrderRule(),
new ExpiryRule(clock, endOfMonth)),
new PercentDiscount(1_000, 10_000), // 10% off, capped at ₹100
1, 10_000));
Cart firstCart = new Cart("asha", 80_000, true); // ₹800, first order
engine.preview("WELCOME100", firstCart); // 8000 paise — ₹80 off, under the cap
engine.redeem("WELCOME100", firstCart); // the clicker clicks: uses = 1
engine.redeem("WELCOME100", firstCart); // throws: "you've already used this code"
Cart small = new Cart("dev", 30_000, true);
engine.preview("WELCOME100", small); // throws: "add ₹199 more to use this code"
engine.bestFor(firstCart); // Optional[WELCOME100] — a fold, not a featureThe interview corner
Clarify before you code: One coupon per order, or stacking (say no for v1, with the reason)? Cart-level only, or item/category-level discounts? Who creates coupons — is the admin path in scope?
The follow-up ladder:
- "Marketing wants 'Tuesdays only, app only, Bangalore only.'" Three more
Rulerecords — the interface absorbs them without the engine changing, which is the entire argument for predicates-as-data. Bonus: anAnyOf(rules…)combinator gives marketing OR-logic. - "Free delivery and BOGO, not just money off."
Discountreturning one number dies; it becomes anEffecton the cart (price lines, shipping line). Name the refactor cost honestly — this is why you ask the item-level question up front. - "FLASH50 at noon: 40k requests, one counter." One
synchronizedengine melts; shard the global counter into K sub-budgets (sharded LRU thinking) or move the counter to RedisDECR— the distributed rate limiter, selling discounts. - "Payment failed after redeem." The saga from the callout: redeem-in-transaction or compensating unredeem — and an idempotency key on redeem so the payment service's retry doesn't burn two uses.
- "Fraud: one human, fifty accounts." Per-user limits assume identity; the real defense is device/payment-instrument fingerprinting feeding a risk rule — which slots into the same rules list, because the architecture was always a rules list.
Mistakes that fail the round: discounts as an if-chain on code strings; boolean isEligible() (the user learns nothing); limits checked outside the lock (the noon oversell); floating-point percentages (₹0.01 leaks, Splitwise's ghost at the till).
Where to go from here — and Phase 3, closing
Pocket version: rules are small predicates that explain their refusals, discounts are capped integer math, preview is free, redeem is welded check-and-click, and marketing edits cards while engineering sleeps.
- Build the
AnyOf/AllOfcombinators and watch the rules list become a little language — you're two steps from a real rules engine. - Add the idempotency key to redeem and test the double-submit; it's the difference between a demo and a checkout.
- The queue continues with Phase 3's back half — the backlog holds the download manager, the text editor's rope, TOTP, and the web crawler.
The laminated card, the cashier who explains, the clicker that only clicks at the sale — a complete promo platform was standing at that cinema till all along. Now it's also standing in your repo, tested.