Interpreter is the pattern people skip. It sounds like something for compiler writers. But the moment your app stores rules as text, like SPEND>100 AND CATEGORY=Shoes, you’ve written a tiny language. Interpreter is how you give that language a proper grammar, instead of chopping strings apart and hoping.

   
Category Behavioral
Smell fixed S15: Promo rules are parsed with string operations
Code patterns/behavioral/interpreter

The Scene

CornerStore has three promo codes, each defined by a rule written as text:

        ["SHOES10"] = ("SPEND>100 AND CATEGORY=Shoes", 10m, 0m),
        ["BIGSPEND"] = ("SPEND>250", 0m, 20m),
        ["NZFRIEND"] = ("REGION=NZ AND QTY>2", 5m, 0m),

Customers try them during the day, with mixed results:

[029] cart.promo customer=cu-1 code=NZFRIEND
  rejected: promo NZFRIEND does not apply to this cart
[030] cart.promo customer=cu-1 code=SHOES10
  promo SHOES10 applied (SPEND>100 AND CATEGORY=Shoes)

The marketing team would love to write their own rules. Before we let them, let’s see what happens to the text.


The Smell

In the baseline, every time a promo is checked, the rule string is split and searched by hand. Here’s a slice of it:

        // SMELL S15: the rule text is parsed by hand with Split and IndexOf on every call
        var terms = Promos[code].Rule.Split(" AND ");
        foreach (var term in terms)
        {
            var greater = term.IndexOf('>');
            if (greater > 0)
            {
                var name = term.Substring(0, greater);
                var limit = decimal.Parse(term.Substring(greater + 1), CultureInfo.InvariantCulture);
                if (name == "SPEND")
                {
                    if (subtotal <= limit)
                    {
                        return false;
                    }
                }

And it goes on, about 70 lines of it, with branches for QTY, CATEGORY and REGION.

Think about what this code doesn’t handle. A rule with an extra space around AND? The split leaves a stray space in front of CATEGORY, the name no longer matches, and the promo quietly stops applying. A typo like SPNED>100? It falls into an else and silently returns false, so the promo just never works, and nobody’s told why. And it re-parses the same text on every single call.

Parsing and evaluating are tangled together, and the “grammar” exists only in someone’s head.


The Idea

Interpreter does two things:

  1. It defines the grammar of your little language explicitly.
  2. It represents each rule as a tree of expression objects, one class per grammar element. Each object knows how to evaluate itself.

You parse the text once into a tree. After that, evaluating a rule is just asking the root of the tree for an answer.

Think of a recipe card. The written words are the text. When you understand it, you turn it into steps in your head: “preheat oven” and “mix dry ingredients”, and only then do you start cooking. You don’t re-read and re-interpret the whole card every time you reach for an ingredient.


The Refactor

First, the grammar is written down, right in the README:

rule       ::= predicate (whitespace "AND" whitespace predicate)*
predicate  ::= "SPEND>" number
             | "QTY>" number
             | "CATEGORY=" identifier
             | "REGION=" identifier
number     ::= digits ("." digits)?
identifier ::= letter (letter | digit | "_" | "-")*

Then each grammar element becomes a small class. They’re C# records, which keeps each one tiny:

internal sealed record AndExpression(PromoExpression Left, PromoExpression Right) : PromoExpression
{
    public override bool Interpret(PromoEvaluationContext context)
    {
        return Left.Interpret(context) && Right.Interpret(context);
    }
}

internal sealed record SpendGreaterThanExpression(decimal Limit) : PromoExpression
{
    public override bool Interpret(PromoEvaluationContext context)
    {
        return context.Subtotal > Limit;
    }
}

A recursive-descent parser turns the text into a tree, once, when the promos are set up. And this time, bad input fails loudly:

            if (whitespace == 0 || !TryReadKeyword("AND"))
            {
                throw Error("Expected AND or the end of the rule");
            }

Evaluating a promo is now two lines:

var context = new PromoEvaluationContext(cart, customer, subtotal);
return Promos[code].Expression.Interpret(context);

For SPEND>100 AND CATEGORY=Shoes, the tree looks like this:

flowchart TD
    And[AndExpression] --> Spend["SpendGreaterThanExpression (100)"]
    And --> Category["CategoryEqualsExpression (Shoes)"]

And here’s the class structure:

classDiagram
    PromoEngine --> PromoRuleParser : parses rules during setup
    PromoRuleParser ..> PromoExpression : builds
    PromoExpression <|-- AndExpression
    PromoExpression <|-- SpendGreaterThanExpression
    PromoExpression <|-- QuantityGreaterThanExpression
    PromoExpression <|-- CategoryEqualsExpression
    PromoExpression <|-- RegionEqualsExpression
    PromoExpression --> PromoEvaluationContext : interprets
    PromoEvaluationContext --> Cart
    PromoEvaluationContext --> Customer

Notice something familiar? AndExpression holds other expressions, and every expression has the same Interpret method. That’s Composite. An Interpreter’s expression tree is a composite structure with a language on top.


What It Cost

The most lines added of any pattern in the series: 6 files, about 348 lines added and 78 removed. A 165-line parser, the expression classes and tests for both valid and invalid rules.

That’s a real cost for three promo codes. It only makes sense because rules are data: the moment marketing wants to type their own, a proper parser with clear errors is worth every line.


When Not to Use It

  • The rules are fixed and few. Three hard-coded rules could simply be three methods. No grammar needed.
  • The language is big. Interpreter works for small grammars. Once you need operator precedence, OR, brackets and functions, reach for a real parser library or an existing expression engine rather than hand-writing one class per rule.
  • Performance is critical. Walking an object tree is slower than compiled code. For hot paths, .NET can compile expressions into delegates with System.Linq.Expressions.

Easily Confused With

Composite. The expression tree is a composite, so the two look identical in a class diagram. The difference is purpose: Composite is about treating parts and wholes the same way. Interpreter adds a grammar and a parser, and uses the composite structure to evaluate sentences in that language.


Try It Yourself

dotnet build patterns/behavioral/interpreter/Interpreter.slnx
dotnet test patterns/behavioral/interpreter/Interpreter.slnx
dotnet run --project patterns/behavioral/interpreter/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json

Exercise: add a CUSTOMER=Ava predicate to the grammar. You’ll need one new expression class and one new case in the parser. Then try writing a rule with a typo and watch the parser tell you exactly where it went wrong.

The Interpreter README documents the full grammar and evaluation rules. The full change is in DIFF.md.


Previous: Command · Next: Iterator