Discount Engine
Composable eligibility rules via Specification pattern, discount strategies for different calculation types, Decorator-layered pricing, and Null Object for graceful no-discount handling.
Key Abstractions
Interface with is_satisfied_by(context) returning bool, plus and_spec/or_spec/not_spec combinators for composing business rules
Concrete specifications that each encapsulate a single eligibility check against the order context
Composite specs that compose child specifications into boolean expression trees evaluated recursively
Strategy interface for discount calculation : PercentageDiscount, FixedAmountDiscount, BuyXGetYDiscount
Decorator that wraps a base discount to layer additional logic : LoyaltyBonus adds extra percentage, StackableDiscount layers multiple discounts
Null Object that returns the original price unchanged, eliminating null checks throughout the discount pipeline
Combines a Specification with a DiscountStrategy : if the spec matches the context, apply this discount
Class Diagram
How It Works
The Specification Pattern turns eligibility rules into first-class objects that compose with boolean algebra. Instead of writing if user.isPremium() && order.total > 50 && category == "electronics", you build a specification tree: PremiumUserSpec().and_spec(MinOrderAmountSpec(50)).and_spec(CategorySpec("electronics")). Each node in the tree has a single method, is_satisfied_by(context), and the composite nodes (AND, OR, NOT) evaluate their children recursively.
A DiscountRule pairs a Specification with a DiscountStrategy. The engine iterates over all rules, checks which specifications are satisfied by the current order context, and applies the corresponding discount strategy to compute the final price.
When no rule matches, the engine does not return null or throw an exception. Instead, NullDiscount (the Null Object pattern) returns the original price unchanged. Downstream code never needs to check whether a discount was found. The pipeline always produces a valid price.
Decorators like LoyaltyBonusDecorator wrap a base strategy to layer additional discounts. The decorator applies the base discount first, then applies its own adjustment on top. You can stack promotions without modifying existing strategy classes.
Requirements
Functional
- Define eligibility rules as composable specifications (AND, OR, NOT)
- Support multiple discount calculation types: percentage off, fixed amount off
- Evaluate all rules against an order context and apply the best discount
- Return the original price unchanged when no rules match (Null Object)
- Layer additional discounts via decorators (loyalty bonus, stacking)
- Support date-range-based promotional rules
Non-Functional
- Specifications must be immutable and safe to share across threads
- Adding a new eligibility rule requires only a new Specification class, no changes to the engine
- Adding a new discount type requires only a new Strategy class, no changes to rules
- Rule evaluation must be deterministic: same context always produces same result
- Engine should handle hundreds of rules without performance degradation
Design Decisions
What's wrong with boolean eligibility methods?
Boolean methods like isEligibleForPremiumDiscount() couple the eligibility logic to a specific discount. When marketing wants "premium users OR orders over $100," you write a new method. When they want "premium users AND electronics AND not during blackout dates," you write another. The methods multiply and intertwine. Specification pattern makes each condition a standalone, testable object. Composition happens at configuration time, not in code.
Couldn't we just return Optional or null when nothing matches?
Returning Optional<Discount> or null when no discount applies forces every caller to unwrap or check. If the discount pipeline has five stages, each stage needs a null guard. NullDiscount eliminates this entirely. It implements the same interface and returns the price unchanged. The pipeline does not know or care whether a "real" discount or a NullDiscount is flowing through it.
Does it matter if eligibility and calculation are in the same class?
Eligibility and calculation are orthogonal concerns. "Premium users get 20% off" and "Premium users get $15 off" share the same eligibility spec but use different strategies. By separating the two, you can reconfigure promotions without touching eligibility logic. During a flash sale, you swap the strategy attached to an existing rule. The specification tree stays untouched.
How do layered promotions work without modifying strategies?
Some promotions layer on top of others: "loyalty members get an extra 5% after the base discount." A decorator wraps the base DiscountStrategy and applies its own adjustment to the already-discounted price. You can stack multiple decorators without modifying any existing strategy class. Each decorator is independently testable and removable.
Interview Follow-ups
- Priority and conflict resolution between discounts. When multiple rules match, which discount wins? The current engine picks the best (lowest) price. Alternatives include priority-based ordering (first match wins), stacking all matching discounts sequentially, or letting the business configure a conflict resolution strategy per campaign.
- Maximum discount caps. Add a CappedDiscountDecorator that enforces a ceiling.
new CappedDiscountDecorator(baseStrategy, maxDiscountAmount)ensures the discount never exceeds the cap regardless of the base calculation. - Time-limited promotions with automatic expiry. DateRangeSpec already handles this at the specification level. For finer control, add a
valid_untilfield to DiscountRule and have the engine skip expired rules during evaluation. A background job can archive expired rules periodically. - A/B testing discount strategies. Attach a variant tag to each DiscountRule. The engine filters rules by the user's assigned variant before evaluation. This lets you test whether "20% off" outperforms "$15 off" for the same eligibility group without code changes.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass
3 from datetime import date
4
5
6 # ── Specification Pattern ─────────────────────────────────────────
7
8 class Specification(ABC):
9 """Base specification with composable combinators."""
10
11 @abstractmethod
12 def is_satisfied_by(self, context: "OrderContext") -> bool: ...
13
14 def and_spec(self, other: "Specification") -> "Specification":
15 return AndSpecification(self, other)
16
17 def or_spec(self, other: "Specification") -> "Specification":
18 return OrSpecification(self, other)
19
20 def not_spec(self) -> "Specification":
21 return NotSpecification(self)
22
23
24 # ── Composite Specifications (AND / OR / NOT) ─────────────────────
25
26 class AndSpecification(Specification):
27 def __init__(self, left: Specification, right: Specification):
28 self._left = left
29 self._right = right
30
31 def is_satisfied_by(self, context: "OrderContext") -> bool:
32 return self._left.is_satisfied_by(context) and self._right.is_satisfied_by(context)
33
34 def __repr__(self) -> str:
35 return f"({self._left} AND {self._right})"
36
37
38 class OrSpecification(Specification):
39 def __init__(self, left: Specification, right: Specification):
40 self._left = left
41 self._right = right
42
43 def is_satisfied_by(self, context: "OrderContext") -> bool:
44 return self._left.is_satisfied_by(context) or self._right.is_satisfied_by(context)
45
46 def __repr__(self) -> str:
47 return f"({self._left} OR {self._right})"
48
49
50 class NotSpecification(Specification):
51 def __init__(self, spec: Specification):
52 self._spec = spec
53
54 def is_satisfied_by(self, context: "OrderContext") -> bool:
55 return not self._spec.is_satisfied_by(context)
56
57 def __repr__(self) -> str:
58 return f"(NOT {self._spec})"
59
60
61 # ── Concrete Specifications ────────────────────────────────────────
62
63 class MinOrderAmountSpec(Specification):
64 def __init__(self, min_amount: float):
65 self._min_amount = min_amount
66
67 def is_satisfied_by(self, context: "OrderContext") -> bool:
68 return context.total >= self._min_amount
69
70 def __repr__(self) -> str:
71 return f"MinOrder(${self._min_amount})"
72
73
74 class PremiumUserSpec(Specification):
75 def is_satisfied_by(self, context: "OrderContext") -> bool:
76 return context.user_type == "premium"
77
78 def __repr__(self) -> str:
79 return "PremiumUser"
80
81
82 class CategorySpec(Specification):
83 def __init__(self, category: str):
84 self._category = category
85
86 def is_satisfied_by(self, context: "OrderContext") -> bool:
87 return self._category in context.categories
88
89 def __repr__(self) -> str:
90 return f"Category({self._category})"
91
92
93 class DateRangeSpec(Specification):
94 def __init__(self, start: date, end: date):
95 self._start = start
96 self._end = end
97
98 def is_satisfied_by(self, context: "OrderContext") -> bool:
99 return self._start <= context.date <= self._end
100
101 def __repr__(self) -> str:
102 return f"DateRange({self._start} to {self._end})"
103
104
105 # ── Order Context ──────────────────────────────────────────────────
106
107 @dataclass(frozen=True)
108 class OrderContext:
109 total: float
110 user_type: str # "regular" or "premium"
111 categories: list[str]
112 date: date
113
114
115 # ── Discount Strategy ──────────────────────────────────────────────
116
117 class DiscountStrategy(ABC):
118 @abstractmethod
119 def calculate(self, price: float) -> float: ...
120
121 @abstractmethod
122 def description(self) -> str: ...
123
124
125 class PercentageDiscount(DiscountStrategy):
126 def __init__(self, percentage: float):
127 self._percentage = percentage
128
129 def calculate(self, price: float) -> float:
130 return round(price * (1 - self._percentage / 100), 2)
131
132 def description(self) -> str:
133 return f"{self._percentage}% off"
134
135
136 class FixedAmountDiscount(DiscountStrategy):
137 def __init__(self, amount: float):
138 self._amount = amount
139
140 def calculate(self, price: float) -> float:
141 return round(max(0, price - self._amount), 2)
142
143 def description(self) -> str:
144 return f"${self._amount} off"
145
146
147 class NullDiscount(DiscountStrategy):
148 """Null Object: returns price unchanged. No null checks needed."""
149
150 def calculate(self, price: float) -> float:
151 return price
152
153 def description(self) -> str:
154 return "no discount"
155
156
157 # ── Decorator ──────────────────────────────────────────────────────
158
159 class DiscountDecorator(DiscountStrategy):
160 def __init__(self, wrapped: DiscountStrategy):
161 self._wrapped = wrapped
162
163 def calculate(self, price: float) -> float:
164 return self._wrapped.calculate(price)
165
166 def description(self) -> str:
167 return self._wrapped.description()
168
169
170 class LoyaltyBonusDecorator(DiscountDecorator):
171 """Adds an extra loyalty percentage on top of the base discount."""
172
173 def __init__(self, wrapped: DiscountStrategy, bonus_pct: float):
174 super().__init__(wrapped)
175 self._bonus_pct = bonus_pct
176
177 def calculate(self, price: float) -> float:
178 after_base = self._wrapped.calculate(price)
179 return round(after_base * (1 - self._bonus_pct / 100), 2)
180
181 def description(self) -> str:
182 return f"{self._wrapped.description()} + {self._bonus_pct}% loyalty bonus"
183
184
185 # ── Discount Rule (Specification + Strategy) ───────────────────────
186
187 class DiscountRule:
188 def __init__(self, name: str, spec: Specification, strategy: DiscountStrategy):
189 self.name = name
190 self._spec = spec
191 self._strategy = strategy
192
193 def matches(self, context: OrderContext) -> bool:
194 return self._spec.is_satisfied_by(context)
195
196 def apply(self, price: float) -> float:
197 return self._strategy.calculate(price)
198
199 @property
200 def strategy_description(self) -> str:
201 return self._strategy.description()
202
203
204 # ── Discount Engine ────────────────────────────────────────────────
205
206 class DiscountEngine:
207 def __init__(self):
208 self._rules: list[DiscountRule] = []
209 self._default = NullDiscount()
210
211 def add_rule(self, rule: DiscountRule) -> None:
212 self._rules.append(rule)
213
214 def evaluate(self, context: OrderContext, original_price: float) -> float:
215 """Evaluate all rules; apply the best (lowest price) match."""
216 best_price = original_price
217 best_rule = None
218
219 for rule in self._rules:
220 if rule.matches(context):
221 discounted = rule.apply(original_price)
222 if discounted < best_price:
223 best_price = discounted
224 best_rule = rule
225
226 if best_rule:
227 print(f" Matched: {best_rule.name} ({best_rule.strategy_description})")
228 print(f" ${original_price} -> ${best_price}")
229 else:
230 # Null Object in action: NullDiscount flows through seamlessly
231 best_price = self._default.calculate(original_price)
232 print(f" No rules matched. NullDiscount applied: ${best_price}")
233
234 return best_price
235
236
237 # ── Demo ───────────────────────────────────────────────────────────
238
239 if __name__ == "__main__":
240 engine = DiscountEngine()
241
242 # Rule 1: Premium users with orders > $50 get 20% off
243 premium_high_order = PremiumUserSpec().and_spec(MinOrderAmountSpec(50))
244 engine.add_rule(DiscountRule(
245 "Premium High-Value", premium_high_order, PercentageDiscount(20)
246 ))
247
248 # Rule 2: Electronics category gets $10 off
249 engine.add_rule(DiscountRule(
250 "Electronics Promo", CategorySpec("electronics"), FixedAmountDiscount(10)
251 ))
252
253 # Rule 3: Premium user + electronics -> 25% off with 5% loyalty bonus (Decorator)
254 premium_electronics = PremiumUserSpec().and_spec(CategorySpec("electronics"))
255 loyalty_discount = LoyaltyBonusDecorator(PercentageDiscount(25), bonus_pct=5)
256 engine.add_rule(DiscountRule(
257 "Premium Electronics + Loyalty", premium_electronics, loyalty_discount
258 ))
259
260 # Rule 4: Holiday sale (date-based OR premium) for orders > $30
261 holiday_or_premium = DateRangeSpec(
262 date(2026, 3, 1), date(2026, 3, 31)
263 ).or_spec(PremiumUserSpec())
264 holiday_eligible = holiday_or_premium.and_spec(MinOrderAmountSpec(30))
265 engine.add_rule(DiscountRule(
266 "March Madness / Premium", holiday_eligible, PercentageDiscount(15)
267 ))
268
269 # ── Scenario 1: Premium user, electronics, $120 order ─────────
270 print("=" * 60)
271 print("Scenario 1: Premium user, electronics, $120")
272 ctx1 = OrderContext(
273 total=120, user_type="premium",
274 categories=["electronics", "accessories"], date=date(2026, 3, 15)
275 )
276 final1 = engine.evaluate(ctx1, 120)
277 print(f" Final price: ${final1}\n")
278
279 # ── Scenario 2: Regular user, books, $80 order ────────────────
280 print("Scenario 2: Regular user, books category, $80")
281 ctx2 = OrderContext(
282 total=80, user_type="regular",
283 categories=["books"], date=date(2026, 3, 15)
284 )
285 final2 = engine.evaluate(ctx2, 80)
286 print(f" Final price: ${final2}\n")
287
288 # ── Scenario 3: Regular user, small order -> NullDiscount ──────
289 print("Scenario 3: Regular user, $15 order (no rules match)")
290 ctx3 = OrderContext(
291 total=15, user_type="regular",
292 categories=["clothing"], date=date(2026, 6, 10)
293 )
294 final3 = engine.evaluate(ctx3, 15)
295 print(f" Final price: ${final3}\n")
296
297 # ── Demonstrate spec composition ──────────────────────────────
298 print("=" * 60)
299 print("Specification Composition Demo:")
300 premium = PremiumUserSpec()
301 big_order = MinOrderAmountSpec(100)
302 electronics = CategorySpec("electronics")
303
304 composed = premium.and_spec(big_order).or_spec(electronics.not_spec())
305 print(f" Spec: {composed}")
306
307 test_ctx = OrderContext(
308 total=200, user_type="premium",
309 categories=["electronics"], date=date(2026, 3, 15)
310 )
311 print(f" Premium user, $200, electronics -> {composed.is_satisfied_by(test_ctx)}")
312
313 test_ctx2 = OrderContext(
314 total=20, user_type="regular",
315 categories=["clothing"], date=date(2026, 3, 15)
316 )
317 print(f" Regular user, $20, clothing -> {composed.is_satisfied_by(test_ctx2)}")
318
319 # ── Demonstrate NullDiscount explicitly ────────────────────────
320 print(f"\nNullDiscount standalone: ${NullDiscount().calculate(99.99)}")
321 print(" (Returns price unchanged - no null checks needed)")Common Mistakes
- ✗Hardcoding eligibility rules in if/else chains: you can't compose rules, can't test independently, and adding a new rule means editing a growing conditional block.
- ✗Returning null when no discount matches: forces null checks everywhere downstream and makes the pipeline brittle.
- ✗Coupling eligibility logic to discount calculation: you can't reuse the premium user check for other features like priority support or early access.
- ✗Not making specs immutable: shared specs modified in one context silently corrupt another, leading to non-deterministic discount behavior.
Key Points
- ✓Specification Pattern: business rules become first-class composable objects. MinOrderSpec(50).and_spec(PremiumUserSpec()) reads like a business rule. Each spec is independently testable.
- ✓Null Object: when no discount applies, NullDiscount flows through the pipeline without friction. No if-null checks, no special-casing for when no discount is found.
- ✓Composite: complex eligibility is a tree of AND/OR/NOT specs. The tree evaluates recursively, and new leaf specs snap in without touching existing composition logic.
- ✓Strategy: discount calculation is decoupled from eligibility. The same spec can trigger different discount strategies depending on the promotion campaign.