Skip to content

Interpreter Pattern: Turn Business Rules into a Tiny Language

Model rules like "cartTotal > 499 AND city == Bengaluru OR isPrime" as a tree of small expression objects, so changing an offer means changing data, not code.

Builds on Design Pattern: Composite (Structural)

Stage 3advanced3 min read

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

BeforeAfter
Rules hidden in nested ifsEach rule is one readable tree
Every tweak needs a deployChange the tree (or the stored text, once parsed)
Can't list live rulestoString() prints them
No reusable piecesGreaterThan, 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.Main

The 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.

In this topic

After code1 file

After/Main.java267 linesGitHub
package DesignPatterns.BehaviouralPatterns.InterpreterPattern.After;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.stream.Collectors;// ============================================================================// 1. CONTEXT: the facts about one order that rules can read.// ============================================================================class Context {    private final Map<String, Object> facts = new LinkedHashMap<>();    Context with(String name, Object value) {        facts.put(name, value);        return this;    }    Object get(String name) {        if (!facts.containsKey(name)) {            throw new IllegalArgumentException("Unknown variable: " + name);        }        return facts.get(name);    }    @Override    public String toString() {        return facts.toString();    }}// ============================================================================// 2. ABSTRACT EXPRESSION// ============================================================================interface Expression {    boolean interpret(Context context);}// ============================================================================// 3. TERMINAL EXPRESSIONS: the leaves. They read the context directly.// ============================================================================class GreaterThan implements Expression {    private final String variable;    private final double value;    GreaterThan(String variable, double value) {        this.variable = variable;        this.value = value;    }    @Override    public boolean interpret(Context context) {        return ((Number) context.get(variable)).doubleValue() > value;    }    @Override    public String toString() {        return variable + " > " + (long) value;    }}class EqualsTo implements Expression {    private final String variable;    private final Object value;    EqualsTo(String variable, Object value) {        this.variable = variable;        this.value = value;    }    @Override    public boolean interpret(Context context) {        return value.equals(context.get(variable));    }    @Override    public String toString() {        return variable + " == \"" + value + "\"";    }}class Variable implements Expression {    private final String name;    Variable(String name) {        this.name = name;    }    @Override    public boolean interpret(Context context) {        return (Boolean) context.get(name);    }    @Override    public String toString() {        return name;    }}// ============================================================================// 4. NON-TERMINAL EXPRESSIONS: they hold other expressions (Composite shape).// ============================================================================class And implements Expression {    private final Expression left;    private final Expression right;    And(Expression left, Expression right) {        this.left = left;        this.right = right;    }    @Override    public boolean interpret(Context context) {        return left.interpret(context) && right.interpret(context);    }    @Override    public String toString() {        return "(" + left + " AND " + right + ")";    }}class Or implements Expression {    private final Expression left;    private final Expression right;    Or(Expression left, Expression right) {        this.left = left;        this.right = right;    }    @Override    public boolean interpret(Context context) {        return left.interpret(context) || right.interpret(context);    }    @Override    public String toString() {        return "(" + left + " OR " + right + ")";    }}class Not implements Expression {    private final Expression inner;    Not(Expression inner) {        this.inner = inner;    }    @Override    public boolean interpret(Context context) {        return !inner.interpret(context);    }    @Override    public String toString() {        return "NOT " + inner;    }}// ============================================================================// 5. SMALL BUILDER HELPERS so rules read close to how marketing writes them.// ============================================================================final class Rules {    private Rules() {}    static Expression gt(String variable, double value) {        return new GreaterThan(variable, value);    }    static Expression eq(String variable, Object value) {        return new EqualsTo(variable, value);    }    static Expression is(String variable) {        return new Variable(variable);    }    static Expression and(Expression left, Expression right) {        return new And(left, right);    }    static Expression or(Expression left, Expression right) {        return new Or(left, right);    }    static Expression not(Expression inner) {        return new Not(inner);    }}// ============================================================================// 6. CLIENT: the coupon service only knows "Expression". New rules need no new code.// ============================================================================class CouponService {    private final Map<String, Expression> rules;    CouponService(Map<String, Expression> rules) {        this.rules = rules;    }    List<String> eligibleCoupons(Context order) {        return rules.entrySet().stream()            .filter(rule -> rule.getValue().interpret(order))            .map(Map.Entry::getKey)            .collect(Collectors.toList());    }    void printRules() {        rules.forEach((code, rule) -> System.out.println("  " + code + ": " + rule));    }}public class Main {    public static void main(String[] args) {        // In production these trees would come from a parser reading rule text        // stored in a DB or config. Parsing is a separate job; here we build them by hand.        Map<String, Expression> rules = new LinkedHashMap<>();        rules.put("BLR100", Rules.or(            Rules.and(Rules.gt("cartTotal", 499), Rules.eq("city", "Bengaluru")),            Rules.is("isPrime")        ));        rules.put("BULK50", Rules.and(            Rules.and(Rules.gt("itemCount", 2), Rules.gt("cartTotal", 999)),            Rules.not(Rules.eq("city", "Mumbai"))        ));        rules.put("FESTIVE20", Rules.and(            Rules.is("isPrime"),            Rules.or(Rules.eq("city", "Bengaluru"), Rules.eq("city", "Chennai"))        ));        CouponService service = new CouponService(rules);        System.out.println("Active rules");        service.printRules();        List<Context> orders = List.of(            order(650, "Bengaluru", false, 2),            order(320, "Pune", true, 1),            order(1200, "Mumbai", false, 4),            order(1500, "Chennai", true, 3)        );        System.out.println("Results");        for (Context order : orders) {            System.out.println("  " + order + " -> " + service.eligibleCoupons(order));        }    }    private static Context order(double cartTotal, String city, boolean isPrime, int itemCount) {        return new Context()            .with("cartTotal", cartTotal)            .with("city", city)            .with("isPrime", isPrime)            .with("itemCount", itemCount);    }}

Done with this topic?

How well do you know it?