The refactor turns each coupon rule into a tree of small Expression objects. The coupon service no longer knows any rule. It only asks each tree, "does this order match?".
Step 1: one interface for every piece
interface Expression {
boolean interpret(Context context);
}Context is a small map of facts for one order: cartTotal, city, isPrime, itemCount. It throws an error if a rule asks for a variable that is not there, so a typo fails loudly.
Step 2: leaves read the context
class GreaterThan implements Expression {
private final String variable;
private final double value;
public boolean interpret(Context context) {
return ((Number) context.get(variable)).doubleValue() > value;
}
}EqualsTo and Variable (for plain true or false values like isPrime) work the same way. These are the terminal expressions: they do not hold other expressions. In GreaterThan, the cast (Number) and .doubleValue() turn the stored value into a number so it can be compared.
Step 3: inner nodes combine children
class And implements Expression {
private final Expression left;
private final Expression right;
public boolean interpret(Context context) {
return left.interpret(context) && right.interpret(context);
}
}Or and Not have the same shape. These are the non-terminal expressions. And holds two Expressions, so it can hold another And, an Or or a leaf. That is the Composite pattern at work. In And, the && means the right side is skipped when the left side is already false.
Step 4: rules read like the brief
A few static helper methods in Rules keep the building code short:
rules.put("BLR100", or(
and(gt("cartTotal", 499), eq("city", "Bengaluru")),
is("isPrime")
));
rules.put("BULK50", and(
and(gt("itemCount", 2), gt("cartTotal", 999)),
not(eq("city", "Mumbai"))
));gt("itemCount", 2) means "3 or more items", because item counts are whole numbers. A GreaterOrEqual node would read better. It would be one more small class.
Compare that with the nine-line nested if from the Before code. Mumbai to Delhi is a one-word change. In a real system that word would live in a database, not in Java.
Step 5: the service only evaluates
List<String> eligibleCoupons(Context order) {
return rules.entrySet().stream()
.filter(rule -> rule.getValue().interpret(order))
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}CouponService has no if about cities or totals. The stream keeps each rule whose interpret returns true and collects the coupon names. If you add a fourth coupon, this method does not change.
Bonus: rules can describe themselves
Each node has a toString(), so printing a tree prints the rule. This is the full output of the run:
Active rules
BLR100: ((cartTotal > 499 AND city == "Bengaluru") OR isPrime)
BULK50: ((itemCount > 2 AND cartTotal > 999) AND NOT city == "Mumbai")
FESTIVE20: (isPrime AND (city == "Bengaluru" OR city == "Chennai"))
Results
{cartTotal=650.0, city=Bengaluru, isPrime=false, itemCount=2} -> [BLR100]
{cartTotal=320.0, city=Pune, isPrime=true, itemCount=1} -> [BLR100]
{cartTotal=1200.0, city=Mumbai, isPrime=false, itemCount=4} -> []
{cartTotal=1500.0, city=Chennai, isPrime=true, itemCount=3} -> [BLR100, BULK50, FESTIVE20]
That answers marketing's "what's live?" question without anyone reading Java. Each order gets the same coupons as in the Before run. Only the way an order is printed changed, because an order is now a Context map.
What we did not build
There is no parser here. We build the trees by hand. In production you would store cartTotal > 499 AND city == "Bengaluru" as text and parse it into this same tree. You would use ANTLR or a small hand-written parser. Parsing and evaluating are separate jobs. The expression classes stay the same either way.
Also be honest about size. For three coupons owned by developers, Predicate<Order> with .and() and .or() would be enough. This design pays off once rules come from outside the code.
How the problems went away
| Before | After |
|---|---|
Rules hidden in nested ifs | Each rule is one readable tree |
| Every tweak needs a deploy | Change the tree (or the stored text, once parsed) |
| Can't list live rules | toString() prints them |
| No reusable pieces | GreaterThan, EqualsTo, And, Or, Not are each tested once and reused |
Run it:
javac -d out src/DesignPatterns/BehaviouralPatterns/InterpreterPattern/After/Main.java
java -cp out DesignPatterns.BehaviouralPatterns.InterpreterPattern.After.MainThe hard-coded-tree limitation
This refactor improves the shape of each rule, but the sample still builds every tree in Java. So “Mumbai to Delhi is a one-word change” still needs recompiling and a deploy here. Rules can be edited from outside only after you add a parser, or another checked loader, that turns stored input into these expression objects.
A real system needs more than parsing. Validate variable names and types, limit tree depth and size, keep versions of active rules, and report clear syntax errors. The sample's Map<String, Object> catches a missing name, but a wrong type can still fail with a cast exception (a ClassCastException). A typed context, or a validation step, should catch that before evaluation.