A Coupon Engine That Scales: Strategy, Factory, and a Validator Chain
Almost every checkout starts the same way: a single coupon, one hardcoded check, if (code === 'SAVE10') total -= 10. It works on day one. By the time you need a percentage discount with a cap, a per-user usage limit, and a minimum order value, that one line has become a swamp of branches that nobody wants to touch.
While building ByteBites, a hyperlocal delivery platform, I gave coupons their own small subsystem. The goal was simple: adding a new kind of promotion should mean writing one new class, not editing checkout.
One entry point, five responsibilities
The whole subsystem sits behind a single facade — CouponEngine.apply(code, context) — so checkout never knows how discounts are computed. Behind it, five pieces each do one job:
The CouponRepository fetches a coupon by code and records usage. The CouponValidator runs an ordered chain of checks. The DiscountStrategyFactory picks the right algorithm, and each DiscountStrategy computes the actual number. A typed CouponError carries an HTTP status so the API layer can respond without guessing.
Separating these means every layer is independently testable: I can unit-test the validator chain without a database, and test a strategy without a request.
Two strategies behind one interface
The Strategy pattern is the heart of it. Every discount type implements the same apply contract, so the engine treats them interchangeably:
1$interface DiscountStrategy {2$apply(subtotal: number, coupon: Coupon): number; // returns discount amount3$}4$5$class FlatDiscountStrategy implements DiscountStrategy {6$apply(subtotal: number, coupon: Coupon) {7$return Math.min(coupon.value, subtotal); // never discount below zero8$}9$}10$11$class PercentWithCapStrategy implements DiscountStrategy {12$apply(subtotal: number, coupon: Coupon) {13$const raw = (subtotal * coupon.value) / 100;14$return Math.min(raw, coupon.maxDiscount ?? raw);15$}16$}
Two algorithms today — flat and percent_cap — but a buy-one-get-one or free-delivery promo is just a new class registered in the factory. Checkout doesn't change.
A validator chain, not a mega-if
Validation is where coupon logic usually rots. I modelled it as an explicit ordered chain: is the coupon active, not expired, above the minimum order, under its global usage limit, and under this user's per-coupon limit? Each check either passes or throws a typed CouponError with the exact reason and status code. Reordering or adding a rule is a one-line change, and the failure message is always precise instead of a generic "coupon not valid".
Record usage only after the money lands
The subtle bug in most coupon systems is decrementing usage at validation time. If the payment then fails, you've burned a redemption. In ByteBites the order stores the couponId and discountAmount when it's created, but recordUsage() only runs when the RabbitMQ PAYMENT_SUCCESS event arrives. No payment, no redemption — usage counts stay honest.
What the structure buys you
None of this is clever for its own sake. It buys three concrete things: new promo types are additive (a class, not a checkout rewrite), every rule is unit-testable in isolation, and failures are typed and precise. That is the whole return on a little upfront structure — the code stays boring while the feature set grows.
From the project
ByteBites
Microservices + Full Stack