This Rule Engine implementation is composed of basic classes that manage rules and process inputs. This is an overview of the system design.
The diagram below shows the main ownership relationships between the top-level engine classes.
flowchart TD
engine["RulesEngine\nGlobal workspace registry"]
workspace["Workspace\nOwns & invokes components"]
direction TD
subgraph registries[" "]
direction LR
rules["RuleRegistry\nStores rules"]
functions["FunctionRegistry\nCustom functions"]
commands["CommandRegistry\nCustom commands"]
types["TypeRegistry\nDeclared types"]
end
subgraph tools[" "]
direction LR
dependency["DependencyGraph\nRule dependency ordering"]
rete["ReteGraph\nConditional selection graph"]
checker["WorkingTypeChecker\nValidation and coercion"]
end
subgraph contexts[" "]
direction TD
ctx1["WorkingMemory\nInvocation 1"]
ctx2["WorkingMemory\nInvocation 2"]
ctx3["WorkingMemory\nInvocation N"]
end
engine --> workspace
workspace -- on startup --> registries
workspace -- on startup --> tools
workspace -- on invocation --> contexts
types --> checker
checker -- checks --> contextsAt runtime, a workspace acts as the main composition root. It owns one long-lived set of registries, graph structures, and a standalone type-checking service, while creating a new WorkingMemory context for each invocation.
The top-level manager of the rules engine is a manager of workspaces.
It provides a registry of named workspaces, enabling you divide work into business domains, each with its own set of declared components. By default you have a global registry of workspaces with one default workspace (if you only need one):
// The following is the default workspace for simple deployments
const defaultSpace = RulesEngine.defaultSpace();
// The following is a targeted named workspace, previously added on startup
const salesSpace = RulesEngine.getWorkspace('SALES');
On startup, add the workspace you will need, load its declarations, check it, then use it anywhere in your code:
const space = RulesEngine.getWorkspace('SALES');
const ctx = space.loadContext({ ...inputData });
space.process(ctx);
To develop or test rules, you can clone an existing workspace so your changes do not affect requests being served by the system.
const space = RulesEngine.getWorkspace('SALES');
const volatile = space.clone();
// You can modify the cloned instance freely.
...
// If you want to keep the cloned workspace available to others, you should name it:
const duplicate = RulesEngine.cloneWorkspace(space, 'TESTING SALES');
If you want to combine declarations from one workspace into another, you can import them instead of cloning the whole object:
const base = RulesEngine.getWorkspace('COMMON');
const sales = RulesEngine.getWorkspace('SALES');
sales.import(base);
Use clone() when you want an isolated copy that can diverge safely. Use import() when you want to merge rules, functions, types, and constants from one workspace into another workspace you are already building.
RulesEngine also exposes a global annotation registry through RulesEngine.annotationRegistry(). Use it during startup to declare custom annotations before loading rules, functions, or test cases that use them. See Annotations.
A workspace holds a set of rules and any types, constants, and functions they may need.
You can have one global workspace RulesEngine.defaultSpace() or multiple, dividing your business logic into manageable domains (e.g. Sales, HR, etc.).
Normally the lifetime of a workspace in production is the entire uptime of the application. However, a testing workspace can be created and then closed without ill-effect.
A workspace internally handles forward chaining (running rules in iterations until no more changes are possible), checks declarations for type-safety, resolves conflicts in priorities (salient rules override less salient ones), and tracks executed actions and exceptions.
Workspace behaviour can be tuned for specific use cases using options passed to the constructor:
const newSpace = new Workspace({
strict_syntax: true, // type check all expressions, operands, and function arguments
strict_inputs: false, // type check all inputs against declared types
strict_outputs: false, // type check all outputs against declared types
strict_conflicts: false, // whether to throw errors on salience conflicts
max_iterations: 20 // allowed number of iterations for forward-chaining
})
The next diagram shows the usual runtime path from declarations to a processed context.
flowchart TD
declarations["Rules, functions,\ntypes, constants"]
load["Workspace\nloads declarations"]
validate["Registries and type checker\nvalidate declarations"]
input["Application provides data"]
ctx["WorkingMemory\ncreates a Context"]
subgraph process["Workspace processes the context"]
direction LR
select["Select candidate rules\nDependencyGraph and ReteGraph"]
eval["Evaluate conditions\nand execute consequences"]
update["Update outputs, cache,\nlog, and exceptions"]
end
result["Application reads output,\naudit trail, and exceptions"]
declarations -- read --> load
load -- on startup --> validate
load -- on invocation --> ctx
input -- process(input) --> ctx
ctx --> process
select --> eval
eval --> update
process --> result
update -. repeats while changes remain .-> selectForward chaining repeats the select -> evaluate -> update cycle until the context stabilizes or the iteration limit is reached. Backward chaining follows the same services, but starts from a requested target and resolves only the rules needed for that target.
The normal processing mode is forward chaining.
In forward chaining, the workspace starts from the data already present in the context, finds relevant rules, executes satisfied consequences, then keeps iterating while new outputs make more rules applicable.
const space = RulesEngine.defaultSpace();
const ctx = space.loadContext({ order: { total: 1200 } });
space.process(ctx);
console.log(ctx.getOutput());
This is the right mode when you want the engine to calculate all reachable consequences for a given input.
The workspace also supports a goal-oriented evaluation mode through workspace.evaluate(variable, context).
In backward chaining, you ask for one specific variable and the workspace evaluates rules that can contribute to that target value.
const space = RulesEngine.defaultSpace();
space.addRule('SET invoice.tax_rate = invoice.total > 1000 ? 0.10 : 0.14');
space.addRule('SET invoice.tax = invoice.total * invoice.tax_rate');
const ctx = space.loadContext({
invoice: {
total: 1200,
},
});
const tax = space.evaluate('invoice.tax', ctx);
console.log(tax);
console.log(ctx.getOutput('invoice.tax_rate'));
Use backward chaining when:
Practical notes:
evaluate() still uses the same context, logging, exceptions, and type validation flow as normal processing.evaluate() returns undefined and the context keeps any logged exceptions.A context is a holder of data used in evaluating conditions and executing actions. The main implementation of context is the WorkingMemory.
A Working Memory is a context that holds inputs for an engine run, and the outputs that result.
Each context knows:
On receiving data, use a workspace to wrap that data in a Working Memory. When you ask the workspace to process that object, you can then query that object to inspect output, errors, and an audit trail of the last run.
const space = RulesEngine.defaultSpace();
const ctx = space.loadContext({ ...input Data });
space.process(ctx);
if (ctx.getExceptions().length) {
// report error messages
} else {
const output = ctx.getOutput();
// perform actions as necessary
}
A Scope Context is created by a function or a closure (from a lambda function) to isolate local data from the Working Memory. This object is discarded after the function returns.
This class holds type defintions to support validation.
This is where we register declared types for type-checking. If no types are to be registered we can configure a workspace to skip strict_inputs and strict_outputs so types will be largely unnecessary (highly discouraged).
This class uses the Type Registry to identify the type of variables used in input or output (supporting validation).
This is what we use to validate rules for type-safety and validate input data.
This class must be passed to every method that requires type checking, e.g. in expressions, functions, etc.
This class holds function declarations and helps with the creation and validation of function calls.
This is where we register declared custom functions. It will guarantee name uniqueness.
This class holds custom commands and helps with the creation and validation of command actions.
This is where we register declared custom commands. It will guarantee name uniqueness.
This class holds rules for use by a workspace, and provides dependency based sorting and conflict resolution during engine processing.
We register rules through the workspace directly. You should not need to use this class directly.
This class builds a graph of rules to enable faster selection of rules applicable to a given context.
It also detects cyclic dependencies on demand (recommended after rules are loaded or changed).
You should not normally have to deal with this class.
This class builds a graph of conditional expressions to enable faster evaluation of rules on a given context.
You should not normally have to deal with this class.
There are multiple ways to provide declarations to a workspace. You can parse declarative syntax through code or from loaded files.
File readers read specific flavours of files, each supporting a possible use case. Select the one you decide to use:
General File Reader (if you decide to separate components into business domains, each with a mized set of declarations)
Markdown File Reader (if you decide to maintain your documentation in sync with your declarations)
Specific File Readers (if you decide to separate components into files by type: Constants, Functions, Types, and Rules)
Read about Declaration Files
If you decide to declare components directly in code instead of loading files, the engine provides parser classes that translate syntax strings into in-memory objects.
In practice, parsers are the bridge between the declarative DSL and the executable classes used by a workspace.
The Rule Parser reads full rule syntax and creates concrete rule objects in memory. It also reads rule annotations such as @name(...), @hint(...), @disabled(), and @salience(...).
Example:
const parser = new RuleParser({ workspace });
const rule = parser.parse('@name(Adult Status) if person.age >= 18 then person.is_adult = true');
workspace.addRule(rule!);
Read about Rules Syntax
Normally you do not need to instantiate a Rule Parser directly because workspace.addRule() accepts rule syntax and parses it for you.
The Expression Parser reads expressions used inside conditions, assignments, function bodies, and lambdas.
It supports:
Example:
const parser = new ExpressionParser({ workspace });
const expr = parser.parse('x > 10 && (y < 5 || z == 0)');
Read about Expression Syntax
This parser is used internally by both the Rule Parser and the Executable Parser.
The Executable Parser reads the action side of rules.
It handles:
SET x = valuex = valueExample:
const parser = new ExecutableParser({ workspace });
const action = parser.parse('person.child_count = count(person.children); person.family_size = "large"');
This parser is used by the Rule Parser for then and else branches, and by the Function Parser for intermediate lines in custom function bodies.
The Function Parser reads custom function declarations.
It supports both simple single-expression forms and block forms with intermediate lines and a final return.
Simple example:
const parser = new FunctionParser({ workspace });
const func = parser.parse('greeting(name: string) = concat("Hello ", name)');
workspace.functionRegistry().addFunction(func!);
Block example:
const parser = new FunctionParser({ workspace });
const func = parser.parse(`sales_tax(total: number) {
tax_rate = total < 100 ? 0.12 : 0.14;
tax = total * tax_rate;
return max(1, tax)
}`);
Read about Custom Functions
The Function Parser uses the Expression Parser for returned expressions and the Executable Parser for intermediate body lines.
The Type Parser reads declared types from JSON or relaxed JSON5-style syntax.
Example:
const parser = new TypeParser({ workspace });
const type = parser.parseRootType(`{
key: 'Person',
properties: {
name: 'string',
age: 'number'
}
}`);
workspace.typeRegistry().addRootType(type);
This parser validates that the provided structure is a legal root type before returning it.
Use parser classes directly when:
If you are simply loading declarations into a workspace, the higher-level APIs and file readers are usually the better entry point.