Marketing wants to launch promotions without waiting for the next deployment: "if the total is over €30 and it's Friday, 10% off"; "if it's the first order or the total is over €50, free delivery". Today every new rule is a new Promotion class written by a programmer. The Promotion interface from module 1 gave us OCP — add without modifying — but marketing wants more: to write the rules as text and have the system understand them. That requires defining a mini-language: a grammar, expression trees, and an interpreter to evaluate them. That is Interpreter — and this lesson includes a deliberate dose of honesty, because it is the GoF pattern you will use least, and it's worth knowing exactly why.

Contents

  1. The problem in PideYa: promotion rules as text
  2. The grammar of the mini-language
  3. Intent and structure of the pattern
  4. Complete Java implementation
  5. And who builds the tree? The parser
  6. When to use it and when not to (honesty included)
  7. Relationship with other patterns
  8. Common mistakes
  9. Exercises and conclusion

The problem in PideYa: promotion rules as text

What marketing wants to type into the admin panel:

total > 30 AND day == FRIDAY
firstOrder OR total > 50
(total > 20 AND day == MONDAY) OR zipCode == 28001

Each line is an applicability condition for a promotion. The discount itself (10%, free delivery) is a separate piece of data; the hard part is evaluating the condition against each order. The forces:

  • The rules combine fixed pieces (total, day, first order) with connectors (AND, OR, comparisons, parentheses): it's not a closed list of cases, it's a small but composable language — there are infinitely many possible rules.
  • Whoever writes the rules doesn't code; whoever codes doesn't want to deploy for every rule.
  • The rules must be evaluated against each order's data at checkout time.

One if per rule doesn't scale (the deployment comes back); a hand-rolled parser with split and regular expressions collapses under parentheses and precedence. The structured solution: treat each rule as a sentence of a language, represent it as a tree, and give each node type the ability to interpret itself.

The grammar of the mini-language

Every language, however small, has a grammar. Ours, in informal notation (each line is a production rule; whatever can still be "expanded" is a non-terminal, whatever can't is a terminal):

expression  ::= comparison | expression "AND" expression | expression "OR" expression | "(" expression ")"
comparison  ::= variable operator literal
variable    ::= "total" | "day" | "firstOrder" | "zipCode"
operator    ::= ">" | "<" | "=="
literal     ::= number | day of the week | boolean

And the sentence total > 30 AND day == FRIDAY becomes this abstract syntax tree (AST):

flowchart TD
    Y["AND (non-terminal)"] --> C1["&gt; (comparison)"]
    Y --> C2["== (comparison)"]
    C1 --> V1["total (terminal)"]
    C1 --> L1["30 (terminal)"]
    C2 --> V2["day (terminal)"]
    C2 --> L2["FRIDAY (terminal)"]

The central idea of Interpreter: one class per grammar rule. The leaf nodes (variables, literals) are terminal expressions; the nodes with children (AND, OR, comparisons) are non-terminal expressions that interpret themselves by recursively interpreting their children. A tree of pieces with a uniform recursive operation? Yes: structurally, Interpreter is a Composite whose operation is interpret.

Intent and structure of the pattern

Intent (GoF): given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.

classDiagram
    class RuleExpression {
        <<interface>>
        +interpret(ctx: OrderContext) boolean
    }
    class Comparison {
        -variable: String
        -operator: String
        -literal: String
        +interpret(ctx) boolean
    }
    class AndExpression {
        -left: RuleExpression
        -right: RuleExpression
        +interpret(ctx) boolean
    }
    class OrExpression {
        -left: RuleExpression
        -right: RuleExpression
        +interpret(ctx) boolean
    }
    class OrderContext {
        +valueOf(variable: String) String
    }

    RuleExpression <|.. Comparison
    RuleExpression <|.. AndExpression
    RuleExpression <|.. OrExpression
    AndExpression o-- RuleExpression : children
    OrExpression o-- RuleExpression : children
    RuleExpression ..> OrderContext : reads
GoF role In PideYa
AbstractExpression RuleExpression
TerminalExpression (leaves) Comparison (encapsulates variable-operator-literal)
NonterminalExpression (nodes with children) AndExpression, OrExpression
Context (external data the evaluation needs) OrderContext
Client (builds/obtains the AST and interprets it) the promotion engine

Complete Java implementation

The context: the window through which the expressions see the order. It isolates the grammar from the domain model — if Order changes tomorrow, only the context changes:

public class OrderContext {

    private final Map<String, String> values;

    public OrderContext(Order order, Customer customer) {
        this.values = Map.of(
            "total",      order.getTotal().toPlainString(),
            "day",        LocalDate.now().getDayOfWeek().name(), // "FRIDAY"...
            "firstOrder", String.valueOf(customer.getOrderCount() == 0),
            "zipCode",    order.getDeliveryAddress().getZipCode()
        );
    }

    public String valueOf(String variable) {
        String v = values.get(variable);
        if (v == null) {
            throw new InvalidRuleException("Unknown variable: " + variable);
        }
        return v;
    }
}

The interface and the non-terminal expressions. Look how small they are: each implements one grammar rule by delegating to its children:

public interface RuleExpression {
    boolean interpret(OrderContext ctx);
}

public class AndExpression implements RuleExpression {
    private final RuleExpression left, right;

    public AndExpression(RuleExpression left, RuleExpression right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public boolean interpret(OrderContext ctx) {
        return left.interpret(ctx) && right.interpret(ctx);
    }
}

public class OrExpression implements RuleExpression {
    private final RuleExpression left, right;

    public OrExpression(RuleExpression left, RuleExpression right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public boolean interpret(OrderContext ctx) {
        return left.interpret(ctx) || right.interpret(ctx);
    }
}

The terminal expression does the dirty work of comparing against the context:

public class Comparison implements RuleExpression {

    private final String variable, operator, literal;

    public Comparison(String variable, String operator, String literal) {
        this.variable = variable;
        this.operator = operator;
        this.literal = literal;
    }

    @Override
    public boolean interpret(OrderContext ctx) {
        String value = ctx.valueOf(variable);
        return switch (operator) {
            case "==" -> value.equalsIgnoreCase(literal);
            case ">"  -> new BigDecimal(value).compareTo(new BigDecimal(literal)) > 0;
            case "<"  -> new BigDecimal(value).compareTo(new BigDecimal(literal)) < 0;
            default   -> throw new InvalidRuleException("Unknown operator: " + operator);
        };
    }
}

Building and evaluating the rule total > 30 AND day == FRIDAY by hand:

RuleExpression rule = new AndExpression(
    new Comparison("total", ">", "30"),
    new Comparison("day", "==", "FRIDAY")
);

boolean applies = rule.interpret(new OrderContext(order, customer));

And with that, the rule fits cleanly into the usual Promotion interface: a RuleBasedPromotion that holds its RuleExpression and its discount, and interprets in appliesTo(order). The promotion engine doesn't know there's a language inside: it only sees Promotion. OCP intact — and now, no deployment needed.

And who builds the tree? The parser

Here's the pattern's fine print: Interpreter says nothing about getting from text to tree. GoF explicitly declares it out of scope. Turning "(total > 20 AND day == MONDAY) OR zipCode == 28001" into objects requires a parser: chopping the text (lexical analysis), respecting precedence and parentheses (syntactic analysis), and building the AST. For our tiny grammar, a recursive descent parser is a manageable ~60 lines (exercise 2 has you write a minimal version). But it is the part that ages worst: every grammar extension touches the parser and adds classes.

For real grammars, don't reinvent: parser generators like ANTLR or JavaCC generate the analyzer from the declared grammar, and ready-made embedded languages (Spring's SpEL, MVEL, JEXL, even embedded JavaScript with GraalVM) give you variables, operators, functions, and security already solved. The practical boundary: if your grammar fits in five production rules and will barely grow, a homegrown Interpreter is reasonable; otherwise, you are building a compiler by accident.

When to use it and when not to (honesty included)

Use it when:

  • There is a small, stable, composable language that users or configuration need to write: business rules, search filters, permission expressions.
  • The grammar is simple (few production rules) and efficiency is not critical.
  • The sentences represent well as trees and evaluate recursively.

Avoid it when:

  • The grammar is or will become complex: the class hierarchy grows with every production rule, and maintenance (classes + parser) explodes. Use ANTLR/JavaCC or an existing embedded language.
  • What you need is a closed list of options, not a composable language: that's Strategy or plain configuration.
  • Performance matters a lot: interpreting object trees is slow compared to compiled code.

The promised dose of honesty. Interpreter is, by far, the GoF pattern you will apply by hand the least, for three compounding reasons: (1) few domains justify inventing your own language; (2) when they do, modern tools (parser generators, rule engines, embedded languages) do the job better than a homegrown hierarchy; (3) its original 1990s niche — ad hoc mini-languages — is covered today by JSON/YAML for configuration and standard expression languages for rules. So why study it? Because its mental model — grammar → AST → recursive evaluation — is exactly how the regular expressions you use daily work, how SQL is parsed, how template engines and compilers themselves work. Understanding Interpreter is understanding what's inside those black boxes; writing it from scratch is almost never the right call in production.

Relationship with other patterns

  • Composite: the AST is a composite; Interpreter adds the recursive evaluation operation over that structure.
  • Visitor: if besides evaluating you want to print, optimize, or validate the tree, instead of fattening each expression class with more methods, a visitor adds operations without touching them — a classic combination over ASTs.
  • Iterator: traversing the expression tree without exposing its structure.
  • Flyweight: repeated terminal symbols (the variable total across a thousand rules) can be shared.
  • Strategy: the alternative when there's no language, just variants.

Common mistakes

  • Inventing a language when a list of options was enough: if marketing only needs to choose among five predefined conditions, a dropdown and a Map solve it; the language is justified when combinatorics is the requirement.
  • Letting the grammar grow "just a little more" every month: functions, arithmetic, strings... By the third extension you have accidentally built a programming language. Put the boundary in writing on day one.
  • Evaluating without validating: rules written by humans arrive badly written. The parser must produce understandable errors ("unclosed parenthesis at position 24"), not a NullPointerException at checkout.
  • Forgetting security: if you ever embed a real language (JS, reflection-capable expressions like SpEL), you are executing user code — a sandbox and a whitelist of variables are mandatory.
  • Coupling the expressions to the domain: if Comparison receives Order directly, every model change breaks the grammar. That's what OrderContext is for.

Exercises

Exercise 1: negation

Marketing asks for rules like NOT firstOrder AND total > 15. Add the production expression ::= "NOT" expression to the grammar and implement the corresponding class. Is it terminal or non-terminal? How many existing classes did you touch?

Exercise 2: minimal parser

Write RuleParser.parse(String) for the subset without parentheses or OR: comparisons joined by AND (e.g. total > 30 AND day == FRIDAY AND firstOrder == true). Hint: split on " AND ", parse each comparison by spaces, and fold the list with AndExpression.

Exercise 3: judgment call

For each case, decide: homegrown Interpreter, existing tool, or not even a language? (a) couriers filter orders by "zone == CENTER AND total > 20"; (b) finance wants spreadsheet-style formulas with functions, dates, and arithmetic to compute commissions; (c) operations wants to enable/disable the fraud check per country.

Solutions

Solution 1: non-terminal (it has a child subexpression). Classes touched: zero — only one is added (OCP in the grammar, inherited from the Composite design):

public class NotExpression implements RuleExpression {
    private final RuleExpression inner;

    public NotExpression(RuleExpression inner) { this.inner = inner; }

    @Override
    public boolean interpret(OrderContext ctx) {
        return !inner.interpret(ctx);
    }
}

(The parser would indeed need touching — exactly the dual cost the lesson points out.)

Solution 2:

public class RuleParser {

    public static RuleExpression parse(String text) {
        String[] parts = text.split(" AND ");
        RuleExpression result = parseComparison(parts[0]);
        for (int i = 1; i < parts.length; i++) {
            result = new AndExpression(result, parseComparison(parts[i]));
        }
        return result;
    }

    private static RuleExpression parseComparison(String text) {
        String[] t = text.trim().split("\\s+");   // ["total", ">", "30"]
        if (t.length != 3) {
            throw new InvalidRuleException("Malformed comparison: '" + text + "'");
        }
        return new Comparison(t[0], t[1], t[2]);
    }
}

Notice how even this toy already validates and gives useful messages. Adding OR with lower precedence than AND, plus parentheses, would require a recursive descent parser with one level per precedence — the complexity jump that pushes you toward ANTLR.

Solution 3: (a) a reasonable homegrown Interpreter: a tiny, composable, stable grammar — it's our lesson's case with different variables; (b) an existing tool (an expression engine like SpEL/JEXL or even a spreadsheet library): functions + arithmetic + dates is a big grammar you'd maintain forever; (c) not even a language: it's one boolean per country — pure configuration in PideYaConfig.

Conclusion

Interpreter closes the trio of patterns that reify requests: the chain routed them, Command froze them, and Interpreter goes further and reifies whole sentences of a language: one class per grammar rule, expression trees evaluated recursively against a context, and marketing writing promotions without deploying. You also take away its fine print: the parser isn't included, the hierarchy grows with the grammar, and in production ANTLR or an embedded language almost always wins — but the grammar → AST → evaluation model is one of the most profitable ideas you'll take from the catalog, because it lives inside every regex, every SQL statement, and every compiler you use.

Our AST was a tree we knew how to walk because we had built it ourselves. But what about structures we don't want anyone to know from the inside? The Composite menu of La Bella Napoli has sections inside sections, and today every screen that walks it reimplements the recursion on its own. Traversing without exposing: that is the next pattern, the humblest and most ubiquitous of them all — you use it every time you write a for-each. See you in Iterator.

© Copyright 2026. All rights reserved