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
100to200), 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):
- 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. - 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

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: true1. 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 aBinaryExpressionnode, where the left child node isIdentifier(price), the right child node isLiteral(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 > numberis 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 interfaceevaluate(Context ctx). -
ValueNoderetrieves values from the Context. -
LogicalNoderecursively calls theevaluateof 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:
- IdentifierNode: Represents variables, such as
price. - LiteralNode: Represents constants, such as
100. - 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:
-
BinaryNode(>)callsLeft.Eval,IdentifierNode(price)returns120. -
BinaryNode(>)callsRight.Eval,LiteralNode(100)returns100. -
BinaryNodegets 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
Nodetrees, without the need to recompile and release. - Testability: Each
Nodeimplementation 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 newInNodestruct and implement theEvalmethod, 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:
- Identifier: Represents variables in the context, such as
order.amount. - Literal: Represents static values, such as
100or"VIP". - 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
Literalnode possessing aLeftchild node). - Difficulty in Debugging: When debugging, you face a massive struct full of
nilfields, 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

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 *Identifierbranch. This is the only "interface" for interaction between the rule engine and business data. By passingctxto 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 languageinterface{}. 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(&&) orOR(||) logic, it is essential to implement short-circuit logic. For example, inA && B, ifEval(A)isfalse, thenEval(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'sinstanceofis 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) Resultclosure 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:
- Listen for changes in the configuration center (e.g., Etcd, ZooKeeper) via a background thread.
- Pull the new rule script, complete Parse and Compile in memory, and generate a new AST instance.
- 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. - 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
WHILEor recursive calls, a "Step Limit" or Timeout mechanism must be introduced. Pass acontext.Contextwithin theEvalfunction's context, and checkctx.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
Userobject; instead, expose data through clearly definedGetterinterfaces 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)

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.
- Fingerprint Calculation: Calculate a hash value (such as MD5 or SHA-256) of the rule string as the Key.
- Cache Lookup: Before execution, check if the parsed AST structure exists in the LRU cache.
- 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 > 100can be directly compiled intofunc(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

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:
- Pre-compile: Upon receiving an update notification, the service fetches the new rule and performs lexical and syntax analysis in an independent memory space.
- 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.
- Atomic Swap: Once the new AST is built, use language-level atomic operations (such as
atomic.StorePointeroratomic.Valuein Go,AtomicReferencein Java) to point the global rule pointer to the new AST instance. - 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
- 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).
- 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.
- 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.







