The Problem
Some rules change faster than code ships. A promotions team wants to express “orders over $100 with 3+ items get the bulk discount” today and tweak the threshold tomorrow — without a deploy. Hardcoding each rule as a Go function means every change goes through the full release cycle, and the people who own the rules can’t read or edit them.
The Solution
The Interpreter pattern defines a tiny language for the rules and represents each rule as an expression tree. Terminal expressions (a comparison like total >= 100) evaluate directly against an environment of variables; non-terminal expressions (AND, OR, NOT) combine the results of their children. A small recursive-descent parser turns rule strings into trees once, at load time, and malformed rules are rejected before anything runs.
Structure
Parse Once
Parse() tokenizes the rule string and builds an expression tree with a small recursive-descent parser. Syntax errors surface here, at load time, not during evaluation.
flowchart TD Rule["Rule string 'total >= 100 AND items >= 3'"] Parser["Parse()"] And["And (non-terminal)"] CompA["Comparison total >= 100"] CompB["Comparison items >= 3"] Env["Env order facts"] Result["bool result"] Rule --> Parser Parser --> And And --> CompA And --> CompB Env -->|"Evaluate(env)"| And And --> Result
- Abstract expression:
ExpressiondeclaresEvaluate(env)for every node in the tree. - Terminal expression:
Comparisonresolves one variable against a literal. - Non-terminal expressions:
And,Or, andNotcompose child expressions. - Context:
Envsupplies the variable values for one evaluation. - Parser:
Parse()turns rule text into a validated tree before first use.
Implementation
This example interprets discount-eligibility rules for an order. Rules are plain strings a product team could own, parsed once into expression trees and evaluated against each order’s facts.
package main
import "fmt"
// Env supplies variable values (order facts) to the expression tree.
type Env map[string]float64
// Expression is the interpreter contract: every AST node — terminal or
// composite — evaluates itself against the environment.
type Expression interface {
Evaluate(env Env) (bool, error)
String() string
}
// Comparison is a terminal expression: <variable> <op> <number>.
type Comparison struct {
Variable string
Operator string
Value float64
}
func (c *Comparison) Evaluate(env Env) (bool, error) {
actual, ok := env[c.Variable]
if !ok {
return false, fmt.Errorf("unknown variable %q", c.Variable)
}
switch c.Operator {
case ">":
return actual > c.Value, nil
case ">=":
return actual >= c.Value, nil
case "<":
return actual < c.Value, nil
case "<=":
return actual <= c.Value, nil
case "==":
return actual == c.Value, nil
default:
return false, fmt.Errorf("unknown operator %q", c.Operator)
}
}
func (c *Comparison) String() string {
return fmt.Sprintf("%s %s %g", c.Variable, c.Operator, c.Value)
}
// And is a non-terminal expression that is true when both operands are true.
type And struct {
Left, Right Expression
}
func (a *And) Evaluate(env Env) (bool, error) {
left, err := a.Left.Evaluate(env)
if err != nil {
return false, err
}
if !left {
return false, nil
}
return a.Right.Evaluate(env)
}
func (a *And) String() string {
return fmt.Sprintf("(%s AND %s)", a.Left, a.Right)
}
// Or is a non-terminal expression that is true when either operand is true.
type Or struct {
Left, Right Expression
}
func (o *Or) Evaluate(env Env) (bool, error) {
left, err := o.Left.Evaluate(env)
if err != nil {
return false, err
}
if left {
return true, nil
}
return o.Right.Evaluate(env)
}
func (o *Or) String() string {
return fmt.Sprintf("(%s OR %s)", o.Left, o.Right)
}
// Not is a non-terminal expression that inverts its operand.
type Not struct {
Operand Expression
}
func (n *Not) Evaluate(env Env) (bool, error) {
value, err := n.Operand.Evaluate(env)
if err != nil {
return false, err
}
return !value, nil
}
func (n *Not) String() string {
return fmt.Sprintf("NOT %s", n.Operand)
} Real-World Analogy
Think of a spreadsheet formula. The person typing =IF(AND(A1>100, B1>=3), ...) isn’t a programmer, and the spreadsheet doesn’t ship a new binary for each formula. It parses the formula into a tree once and re-evaluates it whenever the cells change.
Pros and Cons
| Pros | Cons | | --- | --- | | Rules become data: stored, versioned, and edited without redeploying. | A grammar, parser, and AST are heavy machinery for a handful of static rules. | | Each grammar rule maps to one small type that is easy to test in isolation. | Complex grammars make the class-per-rule approach sprawl quickly. | | Malformed rules fail at parse time, before they can affect an order. | Tree-walking interpreters are slower than compiled code on hot paths. | | New operators are added by writing one new expression type. | Error messages need deliberate care to be useful to rule authors. |
Best Practices
- Parse once, evaluate many times. Cache the tree per rule; never re-parse inside a request loop.
- Keep the grammar as small as the domain allows. Every operator you add is grammar you must parse, document, and support forever.
- Return errors, don’t panic. Rule text is user input; a typo in a rule must never take down the process.
- Short-circuit
AND/ORthe way rule authors expect from every other language. - Reach for an existing engine (
text/template, CEL, expr) before writing your own once the grammar grows past simple boolean logic.
When to Use
- Business rules change often, are owned by non-engineers, and must not require a deploy.
- The language is genuinely small: boolean logic, comparisons, a fixed set of variables.
- You need rules to be validated up front and stored as data.
When NOT to Use
- The rules are stable — plain Go functions are simpler, faster, and type-checked.
- The grammar is growing toward a real language — use an existing expression engine or embed a scripting runtime instead.
- Evaluation sits on a hot path where tree-walking overhead matters.