go-patterns
esc

Interpreter

Define a small grammar for recurring business rules and evaluate sentences of that grammar with an expression tree.

Not read yet ~4 min · 3 files · go1.24
family Behavioral complexity High in the wild Low example src/code/behavioral/interpreter

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

Interpreter Pattern
Step 1 of 5

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.

  • Abstract expression: Expression declares Evaluate(env) for every node in the tree.
  • Terminal expression: Comparison resolves one variable against a literal.
  • Non-terminal expressions: And, Or, and Not compose child expressions.
  • Context: Env supplies 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/OR the 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.