Compiler Mindset: How to Use AST (Abstract Syntax Tree) to Solve Complex Business Rule Engines?

Jimmy Lauren

Jimmy Lauren

Updated onJan 30, 2026
Read time17 min read

Share

Ace your next interview with real-time, on-screen guidance from GankInterview.

Try GankInterview
Compiler Mindset: How to Use AST (Abstract Syntax Tree) to Solve Complex Business Rule Engines?

In modern microservice architectures and rapid iteration scenarios, the speed of logic changes often outpaces software release cycles, making traditional hard-coding a core bottleneck for rapid market response. Facing complex risk control strategies, dynamic pricing, or marketing campaigns, engineers face a dilemma: frequent code deployments to modify if-else logic increase system instability, while relying on regex or simple string parsing creates maintenance nightmares due to a lack of type constraints. The key to resolving this conflict lies not in patching logic flaws, but in adopting compiler thinking, treating business rules as a Domain Specific Language (DSL) for standardized governance.

Why Do Complex Business Rules Need AST Instead of Hardcoding or Regex?

When building business systems, the frequency of logic changes is often much higher than the frequency of code releases. When a Product Manager requests changing "order amount greater than 100 yuan and is a VIP user" to "order amount greater than 100 yuan, or during a promotion period and is a new user," engineers usually face three choices: Hardcoding, Regular Expressions/String Parsing, or building a rule engine based on AST (Abstract Syntax Tree).

For simple scenarios, the first two might suffice, but as business complexity increases, they quickly become a maintenance nightmare.

The Deployment Bottleneck of Hardcoding

Hardcoding is the highest-performance solution, but also the most rigid. Writing business logic in if-else or switch statements means that every rule change equates to a complete software release lifecycle (Development -> Testing -> Build -> Deployment).

  • Pain Point: Business response speed is limited by CI/CD processes.
  • Risk: To modify a threshold (e.g., changing 100 to 200), the entire service needs to be redeployed, increasing the risk of introducing regression bugs.

The Limitations of Regular Expressions and String Parsing

To bypass the release process, developers often attempt to store rules as strings (such as JSON configurations in a database) and parse them at runtime. The simplest approach is to use Regex or simple split operations.

  • The Trap of Nested Logic: Regular expressions excel at linear text matching but struggle significantly with recursive or nested structures. For example, parsing nested boolean logic like (A OR B) AND (C OR (D AND E)) makes Regex incredibly complex and difficult to maintain.
  • Lack of Type Safety: String parsing is usually "weakly typed." You cannot predict during the parsing phase that price > "abc" is an illegal operation; often, you only discover the issue when a runtime error occurs.

AST: The Standard Solution for Structured Logic

The core of compiler thinking lies in treating code (or rules) as data structures, rather than simple text. AST (Abstract Syntax Tree) can precisely represent the hierarchical structure and precedence of logic.

Martin Fowler pointed out in a discussion about rule engines that rule engines essentially provide an alternative computational model. Through AST, we convert business rules into a tree structure (Tree Node):

  1. Static Analysis and Pre-validation: Unlike Regex, AST allows us to perform type checking when the rule is saved. For example, if a rule references a non-existent variable user.age, or attempts to add a string to a number, the compiler can throw an error during the Parse phase, preventing dirty data from entering the production environment.
  2. Safety: An AST engine only executes the instruction set (DSL) you define, naturally isolating dangerous operations of the host language (such as file I/O or system calls). This is much safer than directly eval()-ing a piece of JavaScript or Python script.

Solution Comparison: Why Choose AST?

The table below compares the differences between common rule implementation solutions across core dimensions:

Solution

Flexibility

Safety & Validation

Performance

Maintenance Cost

Applicable Scenarios

Hardcoding

Very Low (Requires redeployment)

High (Compile-time checks)

Very High (Native machine code)

High (Grows linearly with rule count)

Core fixed logic, validations that almost never change

Regex / String Parsing

Medium (Dynamically configurable)

Very Low (No type checks, prone to runtime crashes)

Medium (Depends on parsing complexity)

Very High (Hard to debug nested logic)

Extremely simple flat rules (e.g., black/white lists)

Embedded Scripts (Lua/JS/Python)

High (Turing complete)

Low (Weak typing, risk of sandbox escape)

Medium/Low (VM overhead)

Medium (Requires script lifecycle management)

Extremely complex dynamic logic where performance is not critical

Custom AST Engine

High (DSL customization)

High (Supports static type checking, dead code elimination)

High (Optimizable, no VM overhead)

Medium (High initial dev cost, low long-term maintenance)

Business systems with high-frequency changes, nested logic, and performance requirements

By building an AST, we are essentially implementing a domain-specific "micro-compiler." Although the initial investment is higher than writing a few Regex patterns, it empowers the system to handle complex nested logic while retaining static-language-level type safety. This is precisely the key to solving the "flexible yet stable" dilemma of complex rule engines.

Core Architecture: The Complete Lifecycle from DSL to Execution

Core Architecture: The Complete Lifecycle from DSL to Execution

Building a robust business rule engine is essentially implementing a Domain-Specific micro-compiler. Unlike directly using eval() or regex matching, an AST-based architecture strictly divides the rule processing flow into "compile-time" and "runtime". This separation not only improves performance (parse once, execute many times) but, more importantly, provides opportunities for type safety and logic validation.

To help developers establish a clear mental model, we break down the rule engine's lifecycle into a standard 5-step workflow. The diagram below shows how data is progressively transformed from a raw string into a final boolean result:

[DSL String] "order.total > 1000 AND user.isvip"
      │
      ▼
1. Grammar & Lexer (Lexical Analysis)
      │ Output: [IDENT:order.total] [OP:>] [NUM:1000] [OP:AND] ...
      ▼
2. Parser (Syntax Analysis)
      │ Output: AST Root Node (BinaryExpression: AND)
      ▼
3. Validator (Semantic Validation/Type Checking)
      │ Check: Does 'order.total' exist? Is it a Number?
      ▼
4. Compiler/Optimizer (Optional Optimization)
      │ Action: Constant folding, caching
      ▼
5. Runtime Interpreter (Execution Interpreter)
      │ Input: Context { order: { total: 1500 }, user: { isvip: true } }
      │ Output: true

1. Grammar Definition

Everything starts with grammar definition. Before writing any code, you must determine which operators, data types, and precedence your DSL (Domain-Specific Language) supports. In engineering practice, BNF (Backus-Naur Form) or EBNF is usually used to describe this.

  • Input: Business requirements (e.g., "We need to support addition, subtraction, multiplication, division, and nested parentheses").
  • Output: Formalized grammar rules, such as expression = term { ("+" | "-") term }. This step determines the capability boundaries of the engine.

2. Lexical Analysis / Tokenizing

This step breaks down the raw rule string (String) into the smallest units understandable by a computer—Tokens.

  • Core Task: Remove whitespace, identify keywords (AND, OR), identifiers (price), literals (100), and operators (>=).
  • Engineering Details: At this stage, if the user inputs illegal characters (such as price @ 100), the lexer should throw an error immediately, rather than waiting until execution time.

3. Syntax Analysis / Parsing

This is the core process of building the AST. The Parser traverses the Token stream and builds a tree structure based on predefined grammar rules.

  • Structure: Usually adopts the Recursive Descent algorithm.
  • Output: An Abstract Syntax Tree (AST). The root node of the tree is usually a logical expression (such as AND), and leaf nodes are variables or constants.
  • Example: For price > 100, the parser will create a BinaryExpression node, where the left child node is Identifier(price), the right child node is Literal(100), and the operator is >.

4. Semantic Validation and Type Checking (Type Checking / Validation)

This is the biggest advantage of the AST approach compared to regular expressions or simple script engines. Before the rule is actually executed, we can traverse the AST for static analysis.

  • Variable Existence Check: Discussions on Stack Overflow point out that the interpreter can check if variable names exist in the bound context during the parsing phase. If a variable does not exist, an error can be reported in advance to avoid NPEs (Null Pointer Exceptions) in the production environment.
  • Type Safety: Ensure data types on both sides of operators are compatible. For example, logic like string > number is prohibited. This step ensures that "rules that pass compilation" are relatively safe at runtime.

5. Recursive Execution / Interpreter

The final step is runtime evaluation. The engine adopts the Interpreter Pattern, recursively calling the evaluate() method starting from the AST root node.

  • Context: A context object (Context) needs to be injected during execution, containing specific data for the current request (such as {"price": 500}).
  • Implementation Logic: As demonstrated in Nected.ai's Java AST tutorial, each AST node class (such as LogicalNode, ValueNode) implements a common interface evaluate(Context ctx).
    • ValueNode retrieves values from the Context.
    • LogicalNode recursively calls the evaluate of left and right child nodes and combines the results with the operator.

This layered architecture is not only clear but also highly extensible. When support for new syntax (such as a contains operator) is needed, you only need to add corresponding logic in the grammar definition, Token types, and node implementations respectively, without refactoring the entire engine.

Step-by-Step Implementation: Building a Lightweight Rule Engine

After understanding the core architecture of the AST, we will enter the code implementation phase. To demonstrate the core principles, we will build a lightweight Tree-Walking engine based on the Interpreter Pattern. While Java (Drools) or C++ are commonly used in the industry, Go is very popular for building modern microservice rule engines due to its native support for a strong type system and efficient interface design.

The implementation in this section will focus on the "heart" of the engine—how to recursively traverse the AST and evaluate results.

1. Defining the AST Node Interface (The Contract)

Everything starts with interfaces. In the Interpreter Pattern, every node on the AST (whether it is an operator, variable, or literal) must obey the same contract: evaluate in a given context.

We define a Node interface, which contains an Eval method. This method receives a "Context" to look up variable values (such as price or uid).

// Context: stores business data, e.g., {"price": 100, "is_vip": true}
type Context map[string]interface{}

// Node interface: all AST nodes must implement this interface
type Node interface {
    Eval(ctx Context) (interface{}, error)
}

2. Building Core Node Types

In this minimalist model, we need three basic nodes to express logic like price > 100:

  1. IdentifierNode: Represents variables, such as price.
  2. LiteralNode: Represents constants, such as 100.
  3. BinaryNode: Represents operation logic, such as > or &&.
// IdentifierNode: retrieves value from context
type IdentifierNode struct {
    Key string
}

func (n IdentifierNode) Eval(ctx Context) (interface{}, error) {
    if val, ok := ctx[n.Key]; ok {
        return val, nil
    }
    return nil, fmt.Errorf("variable '%s' not found", n.Key)
}

// BinaryNode: recursively evaluates left and right subtrees
type BinaryNode struct {
    Left     Node
    Right    Node
    Operator string
}

func (n BinaryNode) Eval(ctx Context) (interface{}, error) {
    // Recursively evaluate left node
    leftVal, err := n.Left.Eval(ctx)
    if err != nil { return nil, err }

// Recursively evaluate right node
    rightVal, err := n.Right.Eval(ctx)
    if err != nil { return nil, err }

// Execute specific logic based on the operator
    switch n.Operator {
    case ">":
        return compareGt(leftVal, rightVal) // Type assertion logic needs to be implemented manually
    case "&&":
        return logicAnd(leftVal, rightVal)
    default:
        return nil, fmt.Errorf("unknown operator %s", n.Operator)
    }
}

3. Recursive Evaluation and Type Safety

The core of a Tree-Walking interpreter lies in the recursive call of Eval. When the engine executes price > 100, the call chain is as follows:

  1. BinaryNode(>) calls Left.Eval, IdentifierNode(price) returns 120.
  2. BinaryNode(>) calls Right.Eval, LiteralNode(100) returns 100.
  3. BinaryNode gets two values and executes comparison logic.

The biggest challenge here is runtime type checking. Unlike simple regex matching, an AST engine must handle type conversion. For example, Go's reflect package is very useful here, but it must be used cautiously to avoid performance penalties. As stated in the Go Blog on Laws of Reflection, reflection is the conversion from interface values to reflection objects; while powerful, it should only be used when necessary (such as handling dynamic type comparison logic).

In production-grade code, it is recommended to add strict Type Assertions in helper functions like compareGt to ensure that a string is not compared with an int:

func compareGt(left, right interface{}) (bool, error) {
    // Simple example: assume both are int
    l, ok1 := left.(int)
    r, ok2 := right.(int)
    if ok1 && ok2 {
        return l > r, nil
    }
    // In actual business, types like float64, string, etc., need to be handled
    return false, fmt.Errorf("type mismatch for > operator")
}

4. Why is this Pattern Better than Hardcoding?

With the few dozen lines of code above, we have built a prototype engine that separates business logic from code.

  • Flexibility: New rules only require assembling different Node trees, without the need to recompile and release.
  • Testability: Each Node implementation can be unit tested individually.
  • Extensibility: If the business needs to support "user belongs to a set" (user_id IN [1, 2, 3]), you only need to add a new InNode struct and implement the Eval method, without affecting the existing comparison logic at all.

This interface-based Interpreter Pattern is the cornerstone of building complex rule engines. It is safer than simple script evaluation and easier to implement and debug than a full bytecode virtual machine.

Step 1: Defining AST Node Structures (Node Definitions)

The core of building a rule engine lies in how to transform business rules (such as order.amount > 100 && user.level == "VIP") from strings into a data structure understandable by computers. This structure is the Abstract Syntax Tree (AST). The quality of the AST design directly determines the performance and extensibility of the subsequent executor.

Core Interface Design (The Base Interface)

In strongly typed languages (such as Go or Java), we first define a base Node interface. This ensures the homogeneity of all nodes in the tree while allowing specific logic to be handled through type assertions.

// Node is the base interface for all nodes in the AST
type Node interface {
    // String method returns the literal representation of the node, facilitating debugging and logging
    String() string
}

// Expr (Expression) is a special Node representing a node that can be evaluated to produce a result
type Expr interface {
    Node
    exprNode() // Marker method used to distinguish between Expression and Statement at compile time
}

Specific Node Implementations (Specific Implementations)

To accurately express business logic, we need to define independent structs for different types of syntactic units, rather than mixing generic objects. The following are the most common node types in rule engines:

  1. Identifier: Represents variables in the context, such as order.amount.
  2. Literal: Represents static values, such as 100 or "VIP".
  3. BinaryExpr (Binary Expression): Represents logic connected by operators, such as > or &&.
// Identifier represents a variable name
type Identifier struct {
    Name string
}
func (i Identifier) String() string { return i.Name }
func (i Identifier) exprNode() {}

// Literal represents basic data type values (Int, String, Bool)
type Literal struct {
    Kind  token.Token // Type of the value, e.g., INT, STRING
    Value string      // Original string of the value
}
func (l Literal) String() string { return l.Value }
func (l Literal) exprNode() {}

// BinaryExpr represents a binary operation, such as Left > Right
type BinaryExpr struct {
    Left     Expr        // Left subtree
    Operator token.Token // Operator, e.g., >, ==, &&
    Right    Expr        // Right subtree
}
func (b BinaryExpr) String() string {
    return fmt.Sprintf("(%s %s %s)", b.Left.String(), b.Operator, b.Right.String())
}
func (b BinaryExpr) exprNode() {}

Avoiding the "Generic Node" Pitfall (The Generic Node Pitfall)

In engineering practice, a common mistake developers make is attempting to define a "Generic Node" containing all possible fields in hopes of reducing code volume.

// ❌ Bad Example: Bloated generic node
type BadNode struct {
    Type     string
    Value    string      // Only used for literals
    Left     BadNode    // Only used for binary expressions
    Right    BadNode    // Only used for binary expressions
    Children []*BadNode  // Only used for function calls
}

Although this design seems simple, it is extremely dangerous in production-grade systems:

  • Loss of Type Safety: The compiler cannot prevent you from building a malformed tree (for example, a Literal node possessing a Left child node).
  • Difficulty in Debugging: When debugging, you face a massive struct full of nil fields, making it hard to identify the true identity of the current node at a glance.
  • Performance Risks: Oversized structs and useless pointer fields increase the overhead of memory allocation, thereby increasing the pressure on Garbage Collection (GC). As mentioned in Go Reflection: Taming Memory Costs, inefficiencies in memory layout are amplified in high-concurrency scenarios.

By defining precise structs (such as BinaryExpr and Literal), we leverage the type system of static languages to rule out invalid tree structures at compile time. This not only aids in debugging but also lays a solid foundation for subsequent recursive evaluation.

Step 3: Recursive Evaluation and Context Binding

Step 3: Recursive Evaluation and Context Binding

After the AST construction is complete, we obtain a static syntax tree, but it does not contain any runtime data itself. To bring the rules to life, we need to write an Evaluator. In the interpreter pattern, the most intuitive implementation approach is Recursive Traversal (Recursive Descent).

The core idea is: The calculation of a parent node depends on the calculation results of its child nodes. The evaluation function Eval starts from the root node, dispatches logic based on the node type, until it reaches leaf nodes (such as literals or variables) and returns results layer by layer.

1. Defining the Context

In a business rule engine, rules are usually static (e.g., order.amount > 100), while data is dynamic (every order amount is different). Context Binding is the bridge that injects business data into the AST.

In the simplest implementation, Context can be a map[string]interface{}, but in production environments, to support nested access like user.age or order.items[0].price, it usually requires the use of Reflection or pre-generated accessors.

// Context defines the runtime variable environment
type Context map[string]interface{}

// GetValue simulates simple variable lookup; production environments need to support path resolution like "user.age"
func (c Context) GetValue(name string) (interface{}, error) {
    val, ok := c[name]
    if !ok {
        return nil, fmt.Errorf("variable '%s' not found in context", name)
    }
    return val, nil
}

2. Implementing the Recursive Evaluator (The Eval Function)

The evaluator is essentially a massive switch-case structure. It receives an AST node and the current context, returning the calculation result.

The following is a simplified Go language implementation example, demonstrating how to handle binary operations and variable binding:

func Eval(node Node, ctx Context) (interface{}, error) {
    switch n := node.(type) {

// 1. Literal Node (Leaf Node): Directly return the stored value
    case Literal:
        return n.Value, nil

// 2. Identifier Node (Leaf Node): Look up real data from the context
    case Identifier:
        return ctx.GetValue(n.Name)

// 3. Binary Expression Node (Intermediate Node): Recursively calculate left and right subtrees, then apply the operator
    case *BinaryExpr:
        leftVal, err := Eval(n.Left, ctx)
        if err != nil { return nil, err }

rightVal, err := Eval(n.Right, ctx)
        if err != nil { return nil, err }

// Convert interface{} to concrete types for calculation (simplified handling here)
        return applyOperator(n.Op, leftVal, rightVal)

default:
        return nil, fmt.Errorf("unknown node type")
    }
}

// applyOperator handles specific business logic, such as > (greater than), == (equal to), && (AND)
func applyOperator(op Token, left, right interface{}) (interface{}, error) {
    // In actual engineering, type safety must be handled here, e.g., preventing comparison between strings and numbers
    switch op {
    case ADD:
        return left.(float64) + right.(float64), nil
    case GT: // Greater Than
        return left.(float64) > right.(float64), nil
    // ... other operators
    }
    return nil, fmt.Errorf("unsupported operator")
}

3. Key Design Details

  • Timing of Variable Binding: Pay attention to the case *Identifier branch. This is the only "interface" for interaction between the rule engine and business data. By passing ctx to every layer of recursion, deeply nested expressions can also access the outermost data.
  • Type Assertion and Safety: In applyOperator, we must handle type assertions for the Go language interface{}. This is one of the main sources of performance overhead in interpreters. As pointed out in research regarding Interpreter Optimization, recursive traversal combined with extensive dynamic Type Checking will significantly increase CPU execution time.
  • Short-circuiting: When handling AND (&&) or OR (||) logic, it is essential to implement short-circuit logic. For example, in A && B, if Eval(A) is false, then Eval(B) should not be executed. This is not only a performance optimization but also a necessary measure to prevent null pointer exceptions (e.g., user != nil && user.age > 18).

Advanced Practice: Solving Performance and Scalability Challenges in Production Environments

After completing the definition of the AST and basic Recursive Evaluation, we usually obtain a functional "toy-level" interpreter. However, when we deploy this engine to a high-concurrency production environment (such as risk control systems, real-time pricing, or ad recommendation), the real challenges begin. Basic Switch-Case recursive traversal often encounters significant performance bottlenecks and architectural pain points when handling tens of thousands of requests per second (10k+ QPS).

This section will move beyond the basic implementation to explore performance optimization, hot update architectures, and security issues that must be addressed in production engineering.

1. Performance Optimization: Breaking the "Slow" Curse of Interpreters

A naive AST Interpreter essentially performs a large amount of memory allocation and type assertion at runtime. If your rule engine is based on static languages like Go or Java, frequent Reflection operations will become a killer for the CPU and GC.

Avoiding Reflection Overhead

When writing generic node evaluation logic (e.g., comparing a > b), developers tend to rely on reflection to handle different types of input (int, float, string). Research indicates that reflection operations bring significant performance costs, especially in hot code paths.

Optimization Strategies:

  • Type Prediction and Caching: Do not dynamically determine types during every evaluation. You can perform Static Type Checking during the AST parsing phase (Parse Time) and solidify type information within the nodes.
  • Interface Assertions Instead of Reflection: Using Go's Type Switch (.(type)) or Java's instanceof is usually much faster than generic reflection libraries.
  • Object Reuse: For frequently created context objects or temporary calculation results, consider using Object Pools to reduce GC pressure. As relevant analysis points out, understanding memory allocation traps and moderately reusing memory can reduce memory usage by over 50%.

Compilation Mode vs. Interpretation Mode

If the performance of the interpreter still cannot meet requirements, the next step is to "compile" the AST into executable code instead of traversing it.

  • Closure Compilation: During the initialization phase, traverse the AST and return a strongly typed func(ctx Context) Result closure function. In this way, runtime rule execution becomes a direct function call, eliminating the overhead of tree traversal.
  • Bytecode or Native Code: For extreme performance scenarios, you can refer to JIT (Just-In-Time) concepts and use LLVM or the language's native Plugin mechanism to generate native code. However, this will greatly increase system complexity, and one must weigh the maintenance costs vs. benefits of JIT.

2. Architecture Design: Dynamic Hot Updates and Version Management

A core requirement of business rules is "rapid change." If modifying a promotion rule requires restarting the service, then a self-developed engine loses its meaning. In distributed systems, implementing dynamic loading and real-time activation of rules requires precise architectural design.

  • Atomic Swap:
    Do not modify the AST during the evaluation process. The correct approach is:
    1. Listen for changes in the configuration center (e.g., Etcd, ZooKeeper) via a background thread.
    2. Pull the new rule script, complete Parse and Compile in memory, and generate a new AST instance.
    3. Use pointer atomic operations (such as Go's atomic.StorePointer) or read-write locks to point the global rule engine reference to the new instance.
    4. The old instance is automatically reclaimed by the GC when there are no more references.
  • Serialization and Version Control:
    The AST itself is a tree that can be serialized into JSON or YAML for storage. This not only facilitates persistence but also supports "time machine" rollbacks for rules. It is recommended to calculate a Hash signature for each published rule set to ensure that multi-node clusters load the same version of the rules.

3. Security and Resource Isolation

Allowing business personnel to write logic (even if the user interface is a low-code platform) is equivalent to allowing the execution of external code, which poses potential risks.

  • Infinite Loop Protection: If the rules support WHILE or recursive calls, a "Step Limit" or Timeout mechanism must be introduced. Pass a context.Context within the Eval function's context, and check ctx.Done() before evaluating each node to prevent malicious or erroneous rules from freezing worker threads.
  • Sandbox Isolation: Strictly limit the context data that the rule engine can access. Do not pass the entire User object; instead, expose data through clearly defined Getter interfaces to avoid the rule engine accidentally modifying business data or calling dangerous system methods.

By solving the aforementioned performance and architectural challenges, your AST engine will no longer be a simple algorithmic exercise, but an industrial-grade infrastructure capable of supporting core business flows.

Performance Optimization: AST Caching and Pre-compilation (JIT)

Performance Optimization: AST Caching and Pre-compilation (JIT)

When building high-performance rule engines, the most common performance bottleneck usually lies not in the "execution" of rules, but in their "parsing". The process of converting string-based business rules (such as price > 100 AND user.level == 'VIP') into an Abstract Syntax Tree (AST) involves extensive string scanning, tokenization, and memory allocation. In contrast, once the AST is constructed, the overhead of traversing tree nodes for logical evaluation is extremely low.

To address this discrepancy, it is necessary to separate Compile Time (Parsing) from Runtime (Execution) and introduce caching and Just-In-Time (JIT) compilation mechanisms.

1. AST Caching Strategy (LRU Cache)

In production environments, the same rules are often called frequently. If the rule string is re-parsed for every request, CPU resources will be wasted on repetitive tokenization.

The best practice is to introduce an LRU (Least Recently Used) Cache.

  1. Fingerprint Calculation: Calculate a hash value (such as MD5 or SHA-256) of the rule string as the Key.
  2. Cache Lookup: Before execution, check if the parsed AST structure exists in the LRU cache.
  3. Instance Reuse: If the cache is hit, directly reuse the existing AST root node for Evaluate(context); if missed, parse it and write it to the cache.

This strategy can significantly reduce latency. In benchmark tests, cached execution speeds are typically over 100 times faster than the complete process involving parsing. Using LRU instead of a simple Map is intended to prevent memory leaks in scenarios with excessive dynamic rules (e.g., user-defined rules).

2. Pre-compilation and JIT (Just-In-Time) Optimization

Caching solves the parsing overhead, but for extreme high-concurrency scenarios (such as risk control systems with millions of QPS), the recursive traversal (Recursive Descent) of the AST itself may become a bottleneck. Every node evaluation involves virtual function calls or interface dispatch, which incurs certain CPU costs in languages like Go or Java.

An advanced optimization scheme adopts a mindset similar to JIT (Just-In-Time compilation), converting the AST into native closures or functions:

  • Closure Compilation:
    During the parsing phase, instead of directly generating a tree-structured object, a native function closure is returned. For example, in the Go language, price > 100 can be directly compiled into func(ctx Context) bool { return ctx.GetInt("price") > 100 }. In this way, runtime rule execution becomes a direct function call, leveraging the optimization capabilities of the host language's compiler.
  • AST Flattening:
    If closures cannot be used, deeply nested ASTs can be converted into a linear sequence of instructions (similar to bytecode). The execution engine only needs to process instructions sequentially in a loop, eliminating the risk of stack overflow caused by deep recursion and the overhead of function calls.

As mentioned in the discussion on the advantages and disadvantages of JIT compilation, the core advantage of JIT lies in utilizing runtime information for optimization. Although it increases implementation complexity, for compute-intensive rule engines, the benefit of "compile once, run many times" is immense.

By combining AST caching with JIT technology, rule engines can smoothly transition from Interpreter Mode to an execution efficiency close to native code.

Architecture Design: Dynamic Rule Hot-Reloading and Version Control

Architecture Design: Dynamic Rule Hot-Reloading and Version Control

When building an AST-based rule engine, the most significant architectural challenge lies in bridging the gap between "business change speed" and "code release cycles." Traditional hard-coded logic requires recompilation and deployment to take effect, whereas a mature rule engine must support Dynamic Hot-Reloading, enabling real-time updates of business logic without restarting the service.

1. Rule Storage and Distribution Architecture

To achieve high-availability rule management, an architecture separating the Control Plane and Data Plane is typically adopted:

  • Storage Layer (Source of Truth): The raw definitions of rules (usually DSL strings or JSON configurations) should be persisted in a relational database (such as MySQL). This ensures data durability and traceability.
  • Notification Mechanism: Use Etcd, Consul, or Redis Pub/Sub as a configuration center. When the backend management system updates rules, it not only writes to the database but also pushes change events to the configuration center (e.g., PUT /rules/pricing/v2).
  • Engine-side Listening: When the business service starts, it loads the full set of rules from the database and compiles them into an AST. During runtime, the service uses a Watch mechanism to listen for change events from the configuration center.

This architecture is similar to the dynamic loading path adopted by Tencent Cloud in large model auditing, ensuring that business logic can rapidly adapt to emerging risks or changes in operational strategies through real-time update mechanisms without downtime maintenance.

2. Safe Atomic Hot-Swap

Replacing an AST in a multi-threaded or high-concurrency environment is a high-risk operation. Directly modifying a shared AST structure during execution can easily trigger Race Conditions or even cause service crashes.

In production environments, it is recommended to use Atomic Pointer Swap or Copy-On-Write strategies. The specific process is as follows:

  1. Pre-compile: Upon receiving an update notification, the service fetches the new rule and performs lexical and syntax analysis in an independent memory space.
  2. Validation: If the new rule contains syntax errors, the compilation process will fail. In this case, the update is directly discarded and an alert is triggered, ensuring that erroneous configurations do not pollute the live environment.
  3. Atomic Swap: Once the new AST is built, use language-level atomic operations (such as atomic.StorePointer or atomic.Value in Go, AtomicReference in Java) to point the global rule pointer to the new AST instance.
  4. Lock-free Read: Since read operations only acquire a pointer snapshot, requests currently executing old rules will continue to use the old AST to complete calculations, while newly entering requests will seamlessly switch to the new AST. This mechanism avoids the performance jitter caused by using global Mutex locks.

3. Version Control and Canary Rollback

Merely implementing hot-reloading is not enough; an enterprise-grade engine must have an "undo" mechanism. As emphasized by Thoughtworks in distributed system patterns, version control is the cornerstone of system reliability.

  • Immutable Versioning: Whenever a rule changes, do not overwrite the original record; instead, generate a new version number (e.g., ruleid: "pricingv102").
  • Canary Release: The new version of the AST can initially take effect for only 1% of traffic or for specific User IDs. By monitoring the exception rate and execution time of this traffic, the stability of the new rule is verified.
  • Second-level Rollback: Once monitoring metrics show abnormalities (e.g., the new rule causes a CPU spike or logic error), the system can immediately switch the pointer back to the previous stable version (v101) via atomic operations.

This design makes the rule engine not just a logic executor, but a distributed configuration system with fault tolerance capabilities, capable of maintaining the flexibility of dynamic updates and stateless efficiency while handling millions of evaluations, much like Myntra's scalable rule executor service.

Handling Complex Logic: Nested Loops and External API Calls

When business rules go beyond simple boolean operations (such as A > B) and enter into set operations (such as "all items in the shopping cart have a price greater than 100") or require external data (such as "query whether the user is a VIP"), a simple AST interpreter needs architectural extensions. This usually involves the implementation of two core features: scope-aware iteration nodes and safe external function symbol tables.

1. Set Operations and Scope Management (Nested Loops & Scope)

When processing rules like ALL(orders, item.price > 100), the AST needs to introduce Higher-Order Logic Nodes. This is not merely a syntactic extension; it also imposes requirements on the design of the Context.

In such rules, item is a temporary variable valid only within the iteration of the ALL function. To support this logic, the AST's Evaluate method cannot just accept a flat Map, but must support a Scope Chain or Stack Frame.

  • Implementation Logic: Define an IteratorNode. When the interpreter visits this node, it iterates through the target collection (orders) and creates a new temporary scope in each round of iteration, binding the current element to the variable name (item). The child node (item.price > 100) is recursively evaluated within this temporary scope.
  • Performance Considerations: Frequent creation and destruction of scope objects will generate a large amount of memory allocation. In high-performance scenarios, it is recommended to reuse Context objects by modifying variable bindings via "push/pop" operations, rather than new-ing a Context each time.

2. External Function Calls (External API & Symbol Table)

Business rules often need to obtain status in real-time, such as isVip(user_id) or getStock(sku_id). In the AST, this usually appears as a CallExpression node. To enable static rule scripts to invoke the capabilities of the host language (Go/Java), the engine must maintain a Symbol Table or function registry.

Implementation Example (Go Pseudo-code):

// Define function registry type
type FunctionRegistry map[string]interface{}

// Register business function
registry["isVip"] = func(userId int) bool {
    // Call actual business Service or DB
    return userRepo.IsVip(userId)
}

// AST node evaluation logic
func (n CallNode) Evaluate(ctx Context) interface{} {
    fn, exists := registry[n.FunctionName]
    if !exists {
        panic("undefined function: " + n.FunctionName)
    }
    // Get argument values
    args := n.evalArgs(ctx)

// Call via reflection (note performance overhead)
    return reflect.ValueOf(fn).Call(args)
}

During implementation, developers must be wary of the performance penalty caused by Reflection. As pointed out in Go Reflection: Taming Memory Costs, reflection operations generate additional heap memory allocations and increase GC pressure. For high-frequency rules called tens of thousands of times per second, it is recommended to replace runtime reflection with predefined interfaces (Interface Wrappers) or Code Generation techniques.

3. Security Warnings (Security & Sandboxing)

Allowing rule engines to call external functions is extremely risky and must follow the Principle of Least Privilege.

  • Whitelisting Mechanism: Strictly prohibit rules from calling arbitrary system functions. The symbol table should only contain explicitly registered business functions and must never expose functions like os.Exit, Runtime.exec, or file system operations.
  • Read-Only and Side-effect Free: Functions registered to the rule engine should be Read-Only and Side-effect Free. If a rule script can modify database state (e.g., deleteOrder(id)), a single erroneous rule release could lead to catastrophic data loss.
  • Resource Limits: For logic involving loops or external I/O, timeouts or maximum loop count limits must be set to prevent malicious or erroneous rules from exhausting host process CPU or causing deadlocks.

Summary and Selection Advice: Build In-house or Use Drools/Gengine?

After gaining a deep understanding of AST-based compiler thinking, we return to the practical issues of engineering implementation: facing complex business rule requirements, should we introduce an off-the-shelf rule engine (like Drools, Gengine), or invest resources to build a lightweight AST engine in-house?

This is not a black-and-white choice, but a trade-off process based on performance requirements, rule complexity, and tech stack compatibility. Below is a selection decision matrix and advice based on engineering practice.

Core Solution Comparison Matrix

Dimension

Drools / JBoss Rules

Gengine / Expr (Go Ecosystem)

In-house AST Engine (Compiler Thinking)

Core Algorithm

Rete Algorithm (Stateful reasoning)

Interpreter Pattern / Virtual Machine (VM)

Recursive Descent Parsing + Interpretation / JIT

Performance

Medium/Low (Overhead exists in large-scale stateless matching)

High (Libraries like Expr utilize bytecode optimization)

Extremely High (Can customize pruning and caching for business)

Integration Cost

High (Need to learn DRL syntax, depends on JVM)

Low (Out-of-the-box, SQL-like/Code-like syntax)

Medium/High (Requires foundation in compiler principles)

Flexibility

Extremely High (Supports complex reasoning, backtracking, conflict resolution)

Medium (Supports standard logic and custom functions)

Fully Controllable (Supports private DSL, specific operators)

Maintenance Complexity

Heavy Ops (High memory usage, difficult troubleshooting)

Relies on open source community maintenance

Requires internal team to maintain core code

Applicable Scenarios

Traditional financial risk control, insurance claims (Strong state dependency)

General business logic, simple configuration

High-concurrency microservices, real-time strategy calculation, custom DSL

In-depth Selection Analysis

1. Drools: The "Double-Edged Sword" for Heavy Enterprise Scenarios

Drools is the most mature rule engine in the Java ecosystem. Its core is the Rete Algorithm, which excels in Stateful scenarios where it "deduces new facts based on current Facts."

  • Pros: If your business involves complex reasoning chains (e.g., If A happens, trigger B; if B and C exist simultaneously, trigger D), Drools' forward/backward chaining capabilities are ready-made.
  • Cons: For modern microservice architectures, Drools often proves too heavy. When building a service for evaluating millions of facts, the Myntra engineering team found that Drools became a bottleneck in terms of memory consumption and startup time during large-scale, high-concurrency stateless rule calculations, and its complex API increased system maintenance costs.

2. Gengine / Expr: The Middle Ground in the Go Ecosystem

If your tech stack is Go, directly introducing a JVM to run Drools is obviously impractical. In this case, Gengine or Expr are excellent compromise solutions.

  • Pros: Libraries like Expr achieve near-native execution speeds through bytecode compilation, and their API design is concise, making them suitable for rapid integration. Gengine provides higher-level encapsulation such as rule pool management.
  • Cons: The syntax of such open-source libraries is usually fixed (Go-like or SQL-like). If the business side requires a specific DSL (e.g., "Amount > 500 AND User Level is VIP"), or needs extremely special operators (such as geo-fencing calculations, bitwise optimization), generic open-source libraries may require heavy modification to satisfy these needs.

3. In-house AST Engine: Pursuing Ultimate Performance and Control

Adopting the "compiler thinking" introduced in this article to build an in-house engine essentially treats business rules as code, constructing an AST through Lexical Analysis (Lexer) and Syntax Analysis (Parser) for execution.

  • Pros:
    • High Performance Ceiling: You can control the execution logic of every AST node, and even introduce JIT (Just-In-Time compilation) technology to compile rules into machine code.
    • Full DSL Customization: You can define a Domain Specific Language that completely aligns with the intuition of business personnel, shielding them from code details.
    • Lightweight: Contains only the logic required by the business, without the extra overhead of a Rete network, making it very suitable for Sidecar patterns or high-density compute nodes.
  • Cost: Requires team members to have a foundation in compiler principles, and necessitates self-handling of AST serialization, version management, and hot reloading mechanisms.

Final Decision Advice

  1. Prefer Lightweight Solutions: For 90% of internet microservice businesses (such as marketing campaigns, simple risk control filtering, dynamic configuration switches), rules are usually stateless (i.e., Input Context -> Output Boolean or Value). In these scenarios, do not use Drools. Prioritize language-native lightweight libraries (such as Go's Expr, Java's Aviator/QLExpress).
  2. When to Choose an In-house AST?
    • When existing open-source libraries cannot meet specific DSL syntax requirements (e.g., needing non-technical operations staff to write rules directly).
    • When performance requirements are extremely stringent (e.g., single machine QPS > 100k), and the reflection or interpretation overhead of generic libraries becomes a bottleneck.
    • When rule logic contains a large amount of domain-specific complex calculations (such as complex set operations, custom mathematical models) that require optimizing execution paths through custom AST nodes.
  1. When to Stick with Drools?
    • Legacy system renovation, and the team is primarily Java-based.
    • Business logic falls into the category of "Expert Systems," highly dependent on the mutual triggering of rules and state reasoning (Stateful Session).

Conclusion: In modern architectures pursuing high concurrency and low latency, a lightweight AST engine based on "compiler thinking" (whether built in-house or wrapped around Expr) is usually superior to heavy engines based on the Rete algorithm. It allows you to exchange minimal runtime overhead for maximum business flexibility.

Ace your next interview with real-time, on-screen guidance from GankInterview.

Try GankInterview

Related articles

Stop the prompt superstition: in 2026, the core moat of top Agents is “Harness (control wiring harness)” engineering
Technical TopicJimmy Lauren

Stop the prompt superstition: in 2026, the core moat of top Agents is “Harness (control wiring harness)” engineering

If you’re still repeatedly refining prompts for the stability of production-grade AI Agents, the conclusion of this article may overturn you...

Jun 6, 2026
DeepSeek V4 released: a critical first step for open‑source models to “approach GPT.”
Technical TopicJimmy Lauren

DeepSeek V4 released: a critical first step for open‑source models to “approach GPT.”

The release of DeepSeek V4 is seen as a key milestone in the history of open-source models because, for the first time, a publicly deployabl...

Apr 27, 2026
DeepSeek V4 Technical Breakdown: What Do MoE + 1M Context Actually Mean?
Technical TopicJimmy Lauren

DeepSeek V4 Technical Breakdown: What Do MoE + 1M Context Actually Mean?

DeepSeek V4 introduces a new architecture centered on MoE sparse activation and a 1M context. Its significance for long-sequence reasoning g...

Apr 27, 2026
Behind DeepSeek V4: Chinese AI is taking a different path.
Technical TopicJimmy Lauren

Behind DeepSeek V4: Chinese AI is taking a different path.

The emergence of DeepSeek V4 marks China AI’s move onto a path markedly different from mainstream international approaches under constrained...

Apr 26, 2026
Pet System, Internal Codenames, and Employee Emotion Regex: 3 Wild Easter Eggs in Claude Code's Leaked Source Code
Technical TopicJimmy Lauren

Pet System, Internal Codenames, and Employee Emotion Regex: 3 Wild Easter Eggs in Claude Code's Leaked Source Code

Recently, the accidental exposure of Anthropic's experimental terminal tool caused an uproar in the developer community. This high-profile C...

Mar 31, 2026
Stop just watching the drama and start learning: From Claude Code's 510,000 leaked lines of code, I learned the state machine architecture of a top-tier Agent.
Technical TopicJimmy Lauren

Stop just watching the drama and start learning: From Claude Code's 510,000 leaked lines of code, I learned the state machine architecture of a top-tier Agent.

The recent Claude Code leak is not merely industry gossip, but an invaluable industrial-grade AI engineering blueprint. Deep analysis of the...

Mar 31, 2026