Embedded Expression Evaluators for Configurable Rules

Web & API Engineering intermediate Building with AI ~30 min read

Overview

Many applications need rules that change more often than the deployment cycle. A monitoring product needs customer-specific alert thresholds, an ingestion pipeline needs configurable validation checks, a workflow engine needs routing predicates, and an API gateway may need conditional policy logic. Hard-coding those expressions into application code makes every rule change a software release.

An embedded expression evaluator solves that problem by accepting a small, bounded expression language at runtime, parsing it into a reusable program representation, and evaluating it against request, event, metric, or document parameters. The govaluate library demonstrates this pattern in Go with C-like arithmetic, comparison, boolean, regex, ternary, null-coalescing, array-membership, accessor, and custom-function support.

The transferable design is not the exact syntax. It is the boundary between configurable user-authored logic and trusted host application code. The host owns parsing, type checks, parameter binding, function registration, performance budgets, and error reporting. The user controls only the expression text and input values that the host explicitly exposes.

Core Concepts

Expression as Configuration

An expression evaluator is useful when the rule is business- or tenant-specific but the data model and execution point are stable. Instead of shipping a new binary for response_time <= 100 or requests_succeeded / requests_made >= 0.99, the application stores the expression as configuration and supplies parameters at evaluation time.

Parse Once, Evaluate Many

Parsing is the expensive phase. The expression text should be parsed and validated when a rule is created or updated, then reused for many events. govaluate exposes an EvaluableExpression that can be evaluated repeatedly with different parameter maps, making it suitable for alert checks, validation rules, and routing predicates that run in hot paths.

Host-Controlled Extension Points

Custom functions allow the host application to expose narrowly scoped behavior such as string length, numeric aggregation, or domain-specific lookups. Functions are registered during parsing and cannot be added to an already parsed expression, which keeps the execution environment stable for the lifetime of that compiled rule.

Key Concept: Small DSL, Large Boundary

The expression language should be powerful enough for configurable decisions, but not powerful enough to become arbitrary code execution. Keep the grammar small, bind only approved parameters, register only safe functions, and make unsupported type/operator combinations fail instead of guessing.

How It Works

Compile Rule Text into an Evaluator

The application accepts expression text at configuration time, parses it, and stores the compiled object with the rest of the rule metadata. If parsing fails, reject the rule before it can enter production evaluation. The runtime path should only bind values and evaluate.

package rules

import (
    "fmt"

    "github.com/casbin/govaluate"
)

type Rule struct {
    Name string
    Expr *govaluate.EvaluableExpression
}

func CompileRule(name, expressionText string) (*Rule, error) {
    expr, err := govaluate.NewEvaluableExpression(expressionText)
    if err != nil {
        return nil, fmt.Errorf("invalid expression for %s: %w", name, err)
    }
    return &Rule{Name: name, Expr: expr}, nil
}

func (r *Rule) Matches(params map[string]interface{}) (bool, error) {
    value, err := r.Expr.Evaluate(params)
    if err != nil {
        return false, err
    }
    matched, ok := value.(bool)
    if !ok {
        return false, fmt.Errorf("rule %s returned %T, want bool", r.Name, value)
    }
    return matched, nil
}

Bind a Parameter Surface

Parameters are the contract between host code and rule authors. The evaluator should receive simple, documented names such as response_time_ms, status_code, tenant_plan, or requests_succeeded. Avoid passing entire request objects when a small flattened map is enough.

rule, err := CompileRule(
    "healthy-success-rate",
    "(requests_succeeded / requests_made) >= 0.90 && response_time_ms <= 250",
)
if err != nil {
    return err
}

params := map[string]interface{}{
    "requests_made":      100.0,
    "requests_succeeded": 97.0,
    "response_time_ms":   180.0,
}

ok, err := rule.Matches(params)

Register Safe Functions Explicitly

When operators are not enough, register a finite function map. This is safer than exposing a general scripting runtime because every function is named, typed by convention, and owned by host code. Treat these functions as API surface: version them carefully and keep side effects out of hot-path evaluators.

Practical Applications

Alerting and SLO Checks

Monitoring systems can let teams define threshold logic without changing the collector. A rule such as error_rate > 0.02 || p95_latency_ms > 500 can be parsed once when the alert is saved and evaluated whenever new metrics arrive.

Validation and Ingestion Gates

Data pipelines often need configurable checks before accepting records. Expression evaluators let operators write validations over extracted fields, such as required confidence scores, format checks, or tenant-specific limits, while the ingestion service remains generic.

Routing and Feature Eligibility

API platforms can use expressions to select workflows, features, or downstream queues. For example, a rule might route enterprise tenants with large payloads to a high-memory worker pool, while routing smaller requests through the default path.

Implementation Patterns

Rule Registry with Compile-Time Validation

Store rules in a registry that validates expressions before activation. Keep both the original text and the compiled expression. The original text is needed for audit logs and editing; the compiled expression is needed for efficient evaluation.

type Registry struct {
    rules map[string]*Rule
}

func (r *Registry) Put(name, expressionText string) error {
    compiled, err := CompileRule(name, expressionText)
    if err != nil {
        return err
    }
    r.rules[name] = compiled
    return nil
}

func (r *Registry) Evaluate(name string, params map[string]interface{}) (bool, error) {
    rule, ok := r.rules[name]
    if !ok {
        return false, fmt.Errorf("unknown rule %q", name)
    }
    return rule.Matches(params)
}

Parameter Name Escaping

Expression grammars often treat hyphens, slashes, and operators as syntax. govaluate supports bracketed names such as [response-time] &lt; 100 and backslash escaping. A more robust production pattern is to normalize externally visible fields into expression-safe parameter names before evaluation.

Accessor Avoidance in Hot Paths

Accessors over structs or maps are convenient, but govaluate documents that they use reflection and can be several times slower than direct parameter lookup. For frequently evaluated rules, extract required values into a flat parameter map or implement a custom parameter provider that avoids reflective traversal.

Performance Considerations

Common Pitfalls

Further Reading