The ticket says “save compound promos: 20% off Outdoor, or 15% off Hiking stacked with 5% if a SKU is also in Clearance.” Checkout does not need a language. OrderProcessor already asks DiscountPolicy.payable(order) and charges. You still do not want a new Java class — and a new case in DiscountPolicies.from — every time marketing nests another AND. The fastest path is a growing nest of ifs that looks like a dialect. You also just became the compiler.

That is Interpreter’s entire complaint. From the Design Patterns Roadmap: a small language should be a grammar you evaluate, not a growing nest of ifs.

This post uses a second tiny example on purpose. The shared Order / PaymentGateway / DiscountPolicy lab still charges at the end. We do not re-lecture the three families. Wave 3 is the set that is easy to over-apply; Interpreter is last in the published catalog because most shops needed Strategy, not a grammar.

The dialect that grew inside the mapper

Here is the promo mapper after Outdoor, Hiking, and a stacked clearance extra landed as branches. Fine for two campaigns. Unkind to a nest:

public final class DiscountPolicies {

    public static DiscountPolicy from(String rule) {
        if (rule.startsWith("OUTDOOR20")) {
            return new DepartmentOff("Outdoor", new BigDecimal("20"));
        }
        if (rule.startsWith("HIKING15")) {
            return new DepartmentOff("Hiking", new BigDecimal("15"));
        }
        if (rule.contains("AND") && rule.contains("CLEARANCE")) {
            // parse with splits, hope the order of tokens stays lucky
            return new StackedDiscount(
                    new DepartmentOff("Hiking", new BigDecimal("15")),
                    new DepartmentOff("Clearance", new BigDecimal("5")));
        }
        return new NoDiscount();
    }
}

String sniffing is not a grammar. Now price the next rule — and the one marketing will type into admin:

"20% Outdoor OR 15% Hiking"     -> another contains(), another branch
Nest AND under OR               -> the splits no longer mean what you hoped
Typo in a saved rule            -> a silent NoDiscount at the register
Test Hiking15 without Outdoor   -> you cannot; from() owns every dialect

Four costs, and none of them are about charging a card. Marketing’s sentences have become control flow inside checkout configuration.

Note: The problem is not if. PERCENT10 vs FLAT50 is Strategy plus a dumb mapper. The problem is combinators — AND, OR, a predicate on category — arriving faster than you want to ship classes, still small enough that a parser generator would be a second product.

What Interpreter actually is

Three parts, one promise:

PartIn this labJob
Abstract expressionPromoExprevaluate(Order) — one meaning for the whole grammar.
TerminalPercentOff, SkuInCategoryA leaf rule. No children.
Non-terminalAnd, OrHolds expressions. Evaluates them by the combinator’s rule.

The tree is the program. You evaluate it; you do not switch on a string in process. If OrderProcessor starts reading "OR", you did not interpret; you inlined the language into the paid-order path.

You do not need a class named Interpreter. You need a verb the rule already understands — here, “how much money off does this expression contribute”:

public interface PromoExpr {

    BigDecimal evaluate(Order order);
}

That is the grammar’s type. Everything else is a node.

Terminals, then combinators

PercentOff is percent of the whole order. SkuInCategory is percent of lines whose SKU sits in that category — the predicate and the amount in one leaf, because the DSL is about money off, not a boolean sidebar:

public final class PercentOff implements PromoExpr {

    private final BigDecimal percent;

    public PercentOff(BigDecimal percent) {
        this.percent = percent;
    }

    @Override
    public BigDecimal evaluate(Order order) {
        return order.total()
                .multiply(percent.movePointLeft(2))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

public final class SkuInCategory implements PromoExpr {

    private final String category;
    private final BigDecimal percent;

    public SkuInCategory(String category, BigDecimal percent) {
        this.category = category;
        this.percent = percent;
    }

    @Override
    public BigDecimal evaluate(Order order) {
        BigDecimal eligible = order.subtotalIn(category);
        return eligible
                .multiply(percent.movePointLeft(2))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Or picks the better discount. And stacks both — marketing’s “Hiking 15% and Clearance 5%”:

public final class Or implements PromoExpr {

    private final PromoExpr left;
    private final PromoExpr right;

    public Or(PromoExpr left, PromoExpr right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public BigDecimal evaluate(Order order) {
        return left.evaluate(order).max(right.evaluate(order));
    }
}

public final class And implements PromoExpr {

    private final PromoExpr left;
    private final PromoExpr right;

    public And(PromoExpr left, PromoExpr right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public BigDecimal evaluate(Order order) {
        return left.evaluate(order).add(right.evaluate(order));
    }
}

The ticket’s compound promo is an object graph, not a sentence you split:

PromoExpr hikingOrOutdoor = new Or(
        new SkuInCategory("Outdoor", new BigDecimal("20")),
        new And(
                new SkuInCategory("Hiking", new BigDecimal("15")),
                new SkuInCategory("Clearance", new BigDecimal("5"))));

Checkout still speaks DiscountPolicy. A thin wrapper turns the tree into payable. That wrapper is not the grammar:

public final class PromoTree implements DiscountPolicy {

    private final PromoExpr expr;

    public PromoTree(PromoExpr expr) {
        this.expr = expr;
    }

    @Override
    public BigDecimal payable(Order order) {
        BigDecimal off = expr.evaluate(order);
        BigDecimal payable = order.total().subtract(off);
        return payable.signum() < 0 ? BigDecimal.ZERO : payable;
    }
}

OrderProcessor does not change. Wiring lives at the edge — admin saves a tree (or a tiny builder), config hands new PromoTree(hikingOrOutdoor) to the processor the same way it already hands PercentOff.

Note: Keep evaluate dumb. The moment And charges a card, writes SQL, or asks “if Outdoor, skip fraud,” the dialect has swallowed the workflow. Money off is data. process is still charge-and-persist.

What the diff looks like now

Same feature request, both designs:

Before — add Outdoor-OR-Hiking
  M DiscountPolicies.java    new contains() / split, existing rules at risk
  M DiscountPoliciesTest.java  more strings, same mapper

After — add Outdoor-OR-Hiking
  A Or.java                  (once) combinator
  A SkuInCategory.java       (once) terminal
  M PromoConfig.java         one tree of new PercentOff / SkuInCategory / Or

A new nest is a new graph, not a new parser branch. OrderProcessor is closed against “marketing nested another AND” and still fully open when the workflow changes. Building the tree from a string is a separate job — a 20-line recursive descent, or a JSON list of nodes. Do not pretend startsWith is that job.

Proving a tree without charging a card

The seam pays a second dividend: each node is testable with an Order that has categories on its lines, and no gateway.

@Test
void orPicksTheLargerCategoryDiscount() {
    Order order = orderWith(
            line("TENT", "Outdoor", "80.00"),
            line("BOOTS", "Hiking", "100.00"));
    PromoExpr expr = new Or(
            new SkuInCategory("Outdoor", new BigDecimal("20")),
            new SkuInCategory("Hiking", new BigDecimal("15")));

    assertEquals(new BigDecimal("16.00"), expr.evaluate(order));
}

@Test
void andStacksTwoCategoryPercents() {
    Order order = orderWith(lineIn("BOOTS", "100.00", "Hiking", "Clearance"));
    PromoExpr expr = new And(
            new SkuInCategory("Hiking", new BigDecimal("15")),
            new SkuInCategory("Clearance", new BigDecimal("5")));

    assertEquals(new BigDecimal("20.00"), expr.evaluate(order));
}

PromoTree can be tested as a DiscountPolicy the Strategy post already taught: payable subtracts evaluate, never goes below zero. A processor test still uses FixedDiscount. It should not construct an Or. If a declined-charge test needs Hiking’s 15%, you have mixed two reasons to change.

When Interpreter is the wrong move

Skip the grammar when:

  • A few ifs cover the campaigns. PERCENT10, FLAT50, BLACKFRIDAY as PercentOff with different arguments is Strategy. An expression tree for three promo codes is four types and a composition root that never nests.
  • One compound rule is the whole product. "Outdoor 20% else list price" is one if in DepartmentOff — Composite already walked that tree. Or / And classes for a single campaign that will not grow combinators are ceremony.
  • You actually need a language. Customer-typed rules, operator precedence, syntax errors, a grammar that will still be here in five years: use a parser library, a rules engine, or a JSON schema you validate. Hand-built Interpreter nodes are for a tiny DSL you construct in code (or from a trivial builder). They are not ANTLR.
  • You are parsing with split and calling it Interpreter. An AST you cannot build except by sniffing substrings is the dialect we started with. Either build the tree in Java or parse it properly. Do not live in the middle.
  • The “language” is the paid-order workflow. Validate, charge, persist is not a grammar. That is a method — or Template Method, or a chain. Do not evaluate a ChargeExpr.

The healthy trigger is combinators you can point at — AND/OR (or a predicate wrapping a percent) showing up in more than one saved promo — and a tree you are willing to construct without a general parser. Two terminals and two combinators is that shape. A second percent value is a constructor argument.

Interpreter also is not Visitor. Visitor adds operations to a stable data tree (CsvExport on Sku / Category). Interpreter’s nodes are the operations. Do not accept(PromoVisitor) to compute evaluate. If you need a second walk over the same promo tree (pretty-print the rule, explain the discount on the receipt), that second walk is Visitor — and only if pretty-print and explain keep arriving. One toString on PromoExpr is a method.

It is not Composite, even though And holds children. Composite’s children share a domain verb (subtotal) on merchandise. Interpreter’s children share a language verb (evaluate) on a grammar. A Category is not a promo combinator.

Cheat sheet

Expr        PromoExpr.evaluate(Order)   one return type for the whole DSL
Terminals   PercentOff, SkuInCategory   leaves; money off, not a boolean maze
Combinators And (stack), Or (best of)   hold PromoExpr children
Context     PromoTree / DiscountPolicy  payable = total - evaluate; process() unaware

Trigger to apply: AND/OR (or equivalent combinators) show up in saved promos
Trigger to stop:  a few ifs, one compound campaign, or a real language/parser
Scoreboard:       new nest = new graph; OrderProcessor tests do not change
Not a parser:     you build the tree; you do not sniff strings in from()

Do:

  • Name nodes after the grammar (And, SkuInCategory), not PromoExprImpl.
  • Keep evaluate pure: Order in, BigDecimal out. No gateway, no repository.
  • Test terminals and combinators without OrderProcessor; test process with a fake policy.
  • Build trees in code or from structured config (JSON). Treat free text as a parser problem.

Don’t:

  • Grow DiscountPolicies.from with contains(“AND”) and call it a DSL.
  • Interpret three promo codes that Strategy already mapped.
  • Hand-roll a customer-facing language when a library or rules engine exists.
  • Put evaluate inside OrderProcessor.process. The wrapper is DiscountPolicy.

Wrap-up

Interpreter is a grammar as objects, and evaluate as the only verb the context needs. Checkout never became a language: PromoTree implements DiscountPolicy, OrderProcessor still charges, and marketing’s AND/OR is a graph of PercentOff, SkuInCategory, And, and Or. Three promo codes stay a mapper. A real language deserves a real parser. A nest of ifs that only you can extend is the dialect this pattern retires.

This is the last post in the published catalog. Wave 3 is easy to over-apply — Visitor and Interpreter especially. The map of all 23, with the pain each one targets and when to skip it, is the Design Patterns Roadmap.

Optional next Back to the map of every pattern and the pain it targets. Design Patterns Roadmap