This guide is for developers who want to embed the Rule Engine into a JVM application (Java, Kotlin, or any JVM language) as a library dependency.
The rule engine is published as a standard JVM library. Add it to your build file:
dependencies {
implementation("com.example:ruleengine-core:1.0-SNAPSHOT")
}
dependencies {
implementation 'com.example:ruleengine-core:1.0-SNAPSHOT'
}
<dependency>
<groupId>com.example</groupId>
<artifactId>ruleengine-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
Note: Replace the group ID and version with the values published to your organisation’s artifact repository.
The library requires the following at runtime (they are declared as implementation dependencies and will be included transitively):
| Dependency | Purpose |
|---|---|
tools.jackson.core:jackson-databind |
JSON / YAML parsing |
tools.jackson.dataformat:jackson-dataformat-yaml |
YAML support |
tools.jackson.module:jackson-module-kotlin |
Kotlin data class support |
The engine lifecycle has two clearly separated phases:
FieldSchemaLoader ──► FieldSchema
ActionSchemaLoader ──► ActionSchema
Parser ──► List<RuleAst>
Validator ──► ValidationResult (check for errors before proceeding)
Compiler ──► List<CompiledRule>
RuleEngine ──► ready to evaluate
RuleContext.of(...) ──► RuleContext
PreparedRuleContext.prepare() ──► PreparedRuleContext (normalisation applied here)
RuleEngine.evaluate() ──► EvaluationResult
Key principle: parsing, validation, and compilation happen once.
The RuleEngine instance is reused for every evaluation — it is stateless and thread-safe after construction.
RuleEngineBuilder (see section 3) runs the entire load phase for you; section 4 shows the same
phases driven one component at a time.
RuleEngineBuilder performs the whole load phase in one call: it reads the manifest, resolves
every referenced file relative to the manifest, loads the field and action schema, parses the rule
files in manifest order, validates them and compiles them into a ready engine.
A manifest is named by a location string: a classpath: prefix reads it from the classpath, anything
else from the filesystem.
import ruleengine.builder.RuleEngineBuilder
// Loads every entry of the manifest, keyed by entry id
val engines = RuleEngineBuilder.fromManifest(manifestLocation = "rules/manifest.yaml")
val loaded = engines.getValue("transactions")
val result = loaded.evaluate(
input = mapOf(
"purpose" to "Rent apartment January",
"amount" to 750.0,
"tags" to listOf("regular")
)
)
for (match in result.matches) {
println("Rule matched: ${match.ruleId}")
for (action in match.actions) {
println(" Action: ${action.name} ${action.arguments}")
}
}
That is the complete integration — no separate loader calls, no manual validation check, and no second variable holding the schema.
fromManifest returns a Map<String, LoadedRuleEngine> keyed by manifest entry id. Each
LoadedRuleEngine bundles everything belonging to one entry:
entryId: String — the manifest entry it was built fromengine: RuleEngine — the compiled engineschema: FieldSchema — the schema the rules were compiled againstactions: ActionSchema? — the action schema, or null when the entry declares nonewarnings: List<ValidationDiagnostic> — non-fatal diagnostics (errors would have failed the build)evaluate(input, includeTrace) — normalises the input against schema and evaluates itBecause the schema travels with the engine, a single object can be passed around, stored as a bean, or swapped atomically on reload.
Pass entryId to build only one entry — the result is then a single-element map, so sibling entries
are never read:
val engines = RuleEngineBuilder.fromManifest(
manifestLocation = "rules/manifest.yaml",
entryId = "transactions"
)
fromManifestEntry does the same but returns the LoadedRuleEngine directly:
val loaded = RuleEngineBuilder.fromManifestEntry(
manifestLocation = "rules/manifest.yaml",
entryId = "transactions"
)
| Parameter | Default | Purpose |
|---|---|---|
manifestLocation |
— | classpath:-prefixed resource name, or a path to the manifest YAML (or JSON) file |
entryId |
null |
Build only this entry instead of all of them |
classLoader |
thread context class loader | Loader a classpath: location is looked up in; ignored for a filesystem location |
normalizerRegistry |
NormalizerRegistry.default |
Normalizer registry used for compilation |
Both entry points also accept a manifestPath: Path instead of manifestLocation, for a caller that
already holds one — RuleEngineBuilder.fromManifest(manifestPath = Path.of("rules/manifest.yaml")). A
manifest packaged in a jar has no Path, which is what the classpath: location is for.
The builder fails fast instead of handing out a half-initialised engine. It raises
RuleEngineBuildException (from ruleengine.core.errors) when:
entryId names an entry that does not exist — the message lists the available idsschema or no rule files../../etc/passwd)ERRORThe exception message states the manifest, the affected entry and the concrete problem, and appends
one line per validation diagnostic, so the full reason is available without a logging framework. The
structured diagnostics remain accessible via RuleEngineBuildException.diagnostics:
import ruleengine.core.errors.RuleEngineBuildException
try {
val engines = RuleEngineBuilder.fromManifest(manifestPath = Path.of("rules/manifest.yaml"))
} catch (e: RuleEngineBuildException) {
logger.error("Rule engine startup failed: ${e.message}")
e.diagnostics.forEach { diagnostic -> logger.error(" ${diagnostic.severity}: ${diagnostic.message}") }
throw e
}
Warnings never fail the build; inspect loaded.warnings if you want to surface them.
When the rules ship inside the application instead of next to it, prefix the location with
classpath: — the same entry points read it:
// src/main/resources/rules/{manifest.yaml,schema.yaml,actions.yaml,rules/*.rule}
val loaded = RuleEngineBuilder.fromManifestEntry(
manifestLocation = "classpath:rules/manifest.yaml",
entryId = "transactions"
)
This reads through ClassLoader.getResourceAsStream and nothing else, which is what makes it work
identically for an exploded target/build directory, a plain library jar, a Spring Boot executable
jar and a jar nested inside one.
A filesystem location cannot read from inside an executable jar. A resource under
BOOT-INF/classesresolves to ajar:nested:/app.jar/!BOOT-INF/classes/!/rules/manifest.yamlURL (Spring Boot 3.2+) or ajar:file:/app.jar!/BOOT-INF/classes!/…URL, and the JDK ships noFileSystemProviderfor either nested form — so noPathcan be constructed at all. Rules packaged underBOOT-INF/lib/*.jarare a jar inside a jar and have the same problem. Useclasspath:for packaged rules and a plain path only for rules that live on the filesystem beside the application.
Rules for resource names after the prefix:
/-separated, and never starting with / (a leading slash is accepted and ignored).../shared/schema.yaml) is rejected.getResourceAsStream returns the first match on the classpath. If two jars both ship
rules/manifest.yaml, which one wins depends on classpath order — namespace the prefix
(com/acme/rules/manifest.yaml) when the rules are published as a library.spring-boot-devtools, where the app is reloaded by a separate class loader.RuleCatalogBuilder.fromManifest takes the same locations, for exporting rule documentation at
runtime from packaged rules.
Note: For content that is already in memory (a string, a reader, a database row) or for a partial pipeline, use the individual components described in section 4. If the rules live somewhere neither the filesystem nor the classpath covers, implement
ruleengine.manifest.ManifestFileResolver— see section 6.
Use the individual components when RuleEngineBuilder does not fit: rules that come from a database
instead of files, a validation-only tool that never compiles, a custom assembly of schemas and rule
sets, or full control over each phase.
This is what RuleEngineBuilder.fromManifest does internally, written out:
import ruleengine.manifest.ManifestLoader
import ruleengine.schema.FieldSchemaLoader
import ruleengine.schema.ActionSchemaLoader
import ruleengine.dsl.parser.Parser
import ruleengine.compiler.Validator
import ruleengine.compiler.Compiler
import ruleengine.core.domain.dto.field.FieldSchema
import ruleengine.evaluator.RuleEngine
import ruleengine.evaluator.context.RuleContext
import ruleengine.evaluator.context.PreparedRuleContext
import java.nio.file.Path
import java.nio.file.Files
data class ManualEngine(val engine: RuleEngine, val schema: FieldSchema)
fun buildEngine(manifestPath: Path): ManualEngine {
val manifest = ManifestLoader.load(path = manifestPath)
val entry = manifest.entries.first()
val baseDir = manifestPath.parent
val schema = FieldSchemaLoader.load(path = baseDir.resolve(entry.schema!!))
val actions = ActionSchemaLoader.load(path = baseDir.resolve(entry.actions!!))
val ruleAsts = entry.rules.flatMap { relativePath ->
val rulePath = baseDir.resolve(relativePath)
Parser(input = Files.readString(rulePath)).parseRules()
}
val validation = Validator.validate(asts = ruleAsts, schema = schema, actions = actions)
check(validation.isValid) {
"Rule validation failed: ${validation.diagnostics}"
}
val compiled = Compiler.compileRules(asts = ruleAsts, schema = schema)
return ManualEngine(engine = RuleEngine(compiledRules = compiled), schema = schema)
}
Note that the schema has to be carried alongside the engine: RuleEngine does not hold it, but
PreparedRuleContext.prepare needs it for normalisation. Evaluating a single record:
val manual = buildEngine(Path.of("rules/manifest.yaml"))
val result = manual.engine.evaluate(
prepared = PreparedRuleContext.prepare(
ctx = RuleContext.of(
"purpose" to "Rent apartment January",
"amount" to 750.0,
"tags" to listOf("regular")
),
schema = manual.schema
)
)
for (match in result.matches) {
println("Rule matched: ${match.ruleId}")
for (action in match.actions) {
println(" Action: ${action.name} ${action.arguments}")
}
}
Note: Unlike the builder, this hand-written version does not check that referenced paths stay inside the manifest directory. Use
ManifestPathResolver.resolveWithinBasefromruleengine.manifestwhen the manifest is not fully under your control.
Load from a file:
import ruleengine.schema.FieldSchemaLoader
import java.nio.file.Path
val schema = FieldSchemaLoader.load(path = Path.of("schemas/transaction-schema.yaml"))
Load from a string (useful in web contexts or tests):
val yamlContent = """
schema: my-schema
fields:
purpose:
type: text
operators:
- contains
amount:
type: decimal
operators:
- gte
- lte
""".trimIndent()
val schema = FieldSchemaLoader.loadFromString(content = yamlContent, nameHint = "my-schema")
Load from a Reader:
val reader = someInputStream.bufferedReader()
val schema = FieldSchemaLoader.loadFromReader(reader = reader, nameHint = "my-schema")
The returned FieldSchema contains:
schema.name — the schema nameschema.fields — a Map<FieldId, FieldDefinition>, each with .type, .alias, .normalizers, .operators, and .fields.fields on a definition holds the nested members of a COLLECTION or OBJECT field, recursively.
It is empty for scalar fields, and also empty for a structure whose members were not declared. The
FieldType.isStructure extension property tells the two structure types apart from the rest.import ruleengine.schema.ActionSchemaLoader
val actions = ActionSchemaLoader.load(path = Path.of("schemas/actions.yaml"))
// or:
val actions = ActionSchemaLoader.loadFromString(content = yamlString)
val actions = ActionSchemaLoader.loadFromReader(reader = someReader)
The returned ActionSchema contains:
actions.actions — a Map<String, ActionDefinition>, each with argTypes: List<ActionArgType>argTypes holds one entry for an action that takes a value, and is empty for an action that takes
none (declared as argTypes: [] and written in a rule as the bare action name).
ActionArgType is STRING, INTEGER, DECIMAL, VARIABLE_STRING or VARIABLE_LIST. The last two
declare that the argument is a $name reference rather than a literal — see
actions.md. They affect load-time validation and what the editor offers;
the value a VARIABLE_LIST argument delivers arrives in RuleAction.arguments as a List<Any?>, and as
null when no rule that ran published the variable.
Parse one or more rule files into ASTs:
import ruleengine.dsl.parser.Parser
import java.nio.file.Files
import java.nio.file.Path
val ruleText = Files.readString(Path.of("rules/classification.rule"))
val ruleAsts = Parser(input = ruleText).parseRules()
Parse multiple files and combine:
val ruleAsts = listOf(
Path.of("rules/classification.rule"),
Path.of("rules/fraud.rule")
).flatMap { path ->
Parser(input = Files.readString(path)).parseRules()
}
Parse a directory recursively:
val ruleAsts = Files.walk(Path.of("rules"))
.filter { Files.isRegularFile(it) && it.toString().endsWith(".rule") }
.flatMap { Parser(input = Files.readString(it)).parseRules().stream() }
.toList()
If parsing fails, a ParseException is thrown with the line and column of the error.
import ruleengine.compiler.Validator
val result = Validator.validate(
asts = ruleAsts,
schema = schema,
actions = actions // optional — omit to skip action validation
)
if (!result.isValid) {
result.diagnostics.forEach { diagnostic ->
println("[${diagnostic.severity}] ${diagnostic.message}")
diagnostic.suggestion?.let { println(" Did you mean: $it") }
}
throw IllegalStateException("Rule validation failed")
}
The ValidationResult contains:
isValid: Boolean — true only if there are no ERROR-severity diagnosticsdiagnostics: List<ValidationDiagnostic> — each with severity, message, and optional suggestionSeverity levels: ERROR (blocks loading), WARNING (informational).
Validator.validate answers “is this list of rules valid”, which is what the engine needs — it flattens
an entry’s files before compiling. A tool that has to show the problem needs the file as well, since a
line number without one cannot be pointed at. EntryValidator is that variant:
import ruleengine.compiler.EntryValidator
import ruleengine.compiler.RuleFileAsts
val result = EntryValidator.validate(
files = listOf(
RuleFileAsts(path = "rules/totals.rule", asts = totalsAsts),
RuleFileAsts(path = "rules/tiers.rule", asts = tiersAsts),
),
schema = schema,
actions = actions,
)
result.diagnostics.forEach { diagnostic -> println("${diagnostic.file}:${diagnostic.line} ${diagnostic.message}") }
files must be in manifest order. Each file is validated with the variables the files before it publish,
so a $name read in the last one resolves against the earlier ones; every diagnostic carries
ValidationDiagnostic.file, with line and column relative to that file; the schema-level checks run
once for the entry; and a rule id repeated across two files is reported naming both. ValidatorCli
--manifest is this same path from the command line.
The validator checks:
COLLECTION / OBJECT field, one segment at a
time, to any depthorders[status == "paid"]) resolve against the members of the
element being filtered, not the top-level schematrue / false for
booleans, and for a date field either ISO or the pattern the field declares in its format)regex operatorTwo deliberate asymmetries are worth knowing when you interpret diagnostics:
ERROR; a path below an undeclared structure
is not checked at all.WARNING, not an error, because the root may be a
structure read straight from the input data. sum(unknownThing.amount) > 1 therefore loads, while a
single-segment unknownThing > 1 fails.import ruleengine.compiler.Compiler
val compiledRules = Compiler.compileRules(asts = ruleAsts, schema = schema)
Compilation:
AND children by evaluation cost (cheapest first) for short-circuit optimisationimport ruleengine.evaluator.RuleEngine
val engine = RuleEngine(compiledRules = compiledRules)
The RuleEngine instance is immutable and thread-safe after construction.
Create it once and reuse it for all evaluations.
The engine evaluates every rule against every input, in the order of the compiledRules list —
manifest rules: file order, then the order the rules appear inside each file — and result.matches
is returned in that same order.
That ordering is a guarantee, and two constructs depend on it: a set clause publishes a value only
the rules after it can read, and a branch ending in stop ends the run at its own position. Build the
list through RuleEngineBuilder (or preserve manifest order yourself) and the order is correct by
construction.
Input data is provided as key-value pairs via RuleContext.
The engine accepts any Map<String, Any?> — the keys are field names, the values are the field values.
| Field type in schema | Expected JVM type |
|---|---|
TEXT |
String |
INTEGER |
Long, Int, Short, Byte |
DECIMAL |
BigDecimal, Double, Float |
BOOLEAN |
Boolean, or the String "true" / "false" |
STRING_SET |
List<String>, Set<String>, Collection<String> |
DATE |
LocalDate, LocalDateTime, Instant, or a String in the field’s format |
DATE_TIME |
LocalDateTime, LocalDate (starts at midnight), Instant, or a String in the field’s format |
COLLECTION |
List<Map<String, Any?>> — a list of records |
OBJECT |
Map<String, Any?> — a single record |
A value that cannot be read as its declared type is treated as absent, which makes conditions on it
false rather than raising an error. A DATE carrying a time is reduced to its calendar date; a
DATE_TIME keeps it. An Instant is resolved at UTC, because the engine has no timezone concept.
A String date is read with the pattern the field declares in its
format, or as ISO-8601 when it declares none. A value that is already
a LocalDate, LocalDateTime or Instant carries no text, so no pattern applies to it — those types
are always accepted as they are.
import ruleengine.evaluator.context.RuleContext
import ruleengine.evaluator.context.PreparedRuleContext
val context = RuleContext.of(
"purpose" to "Rent apartment January",
"amount" to 750.0,
"sepaCode" to "PMNT",
"tags" to listOf("regular", "verified")
)
val prepared = PreparedRuleContext.prepare(ctx = context, schema = schema)
val result = engine.evaluate(prepared = prepared)
PreparedRuleContext.prepare() applies all normalizers from the schema to the input values.
This is the only point where normalisation happens — not once per rule, making evaluation very efficient.
import ruleengine.jackson.JacksonUtil
val json = """
{
"purpose": "Rent apartment January",
"amount": 750,
"tags": ["regular"]
}
""".trimIndent()
@Suppress("UNCHECKED_CAST")
val inputMap = JacksonUtil.jsonMapper.readValue(json, Map::class.java) as Map<String, Any?>
val context = RuleContext.of(
entries = inputMap.entries.map { it.key to it.value }.toTypedArray()
)
val prepared = PreparedRuleContext.prepare(ctx = context, schema = schema)
val result = engine.evaluate(prepared = prepared)
import ruleengine.core.domain.dto.EvaluationResult
import ruleengine.core.domain.dto.RuleBranch
import ruleengine.core.domain.dto.RuleMatch
import ruleengine.core.domain.dto.RuleAction
val result: EvaluationResult = engine.evaluate(prepared = prepared)
// result.matches is a List<RuleMatch> — every rule that produced output, whichever branch ran
for (match: RuleMatch in result.matches) {
val branch = when (match.branch) {
RuleBranch.THEN -> "matched"
RuleBranch.ELSE -> "did not match (else)"
RuleBranch.NOT_EXISTS -> "could not be decided (not_exists)"
}
println("Rule ${match.ruleId}: $branch")
for (action: RuleAction in match.actions) {
println(" ${action.name}: ${action.arguments}")
}
}
EvaluationResult:
matches: List<RuleMatch> — every rule that produced output, in the order they were declared. A rule appears here when
its condition held, when the condition was false and the rule declares an else branch, or when the
condition could not be decided and the rule declares a not_exists branch; RuleMatch.branch says
which. For the rules whose condition actually held, filter on RuleBranch.THEN:
val conditionHeld = result.matches.filter { match -> match.branch == RuleBranch.THEN }
A rule with only a then block can only ever report RuleBranch.THEN, so this list means exactly what
it did before for a rule set that uses no branches.
trace: Any? — a DecisionTree if tracing was enabled (see section 5), otherwise nullvariables: Map<String, Any?> — the final value of every variable a matching rule published with a
set or add clause, keyed by name without the $. Empty for a rule set that uses none.RuleMatch:
ruleId: String — the rule’s IDactions: List<RuleAction> — the actions the branch that ran declaredassignments: Map<String, Any?> — the variables this rule published, in assignment orderbranch: RuleBranch — THEN when the rule’s condition held, ELSE when it did not and the rule
declares an else block, NOT_EXISTS when the record carried no data to decide it and the rule
declares a not_exists block (see rules.md).
Defaults to THEN, which is the only value a rule with just a then block can report — so existing
code that ignores this field keeps its meaning. A rule that declares no not_exists block still
reports ELSE for missing data, exactly as before.
A match is not by itself proof that the condition was true.RuleAction:
name: String — the action name (e.g. "label")arguments: List<Any?> — the argument values (e.g. ["rent"] or [10])When a manifest entry declares scope: <collection> (see manifest.md), the rules run
once per member of that collection. Two fields carry the extra dimension, both defaulted so existing
code compiles and keeps its meaning:
EvaluationResult.members: List<MemberEvaluation> — one entry per member, empty for a
whole-document evaluation.RuleMatch.scopeMember: String? — which member produced the match, null for a whole-document
evaluation. ruleId is no longer unique on its own once an entry is scoped.MemberEvaluation:
index: Int — the member’s position in the collectionkey: String — the member’s declared id when it has one, otherwise <collection>[index]; the
same string appears on every RuleMatch that member producedresult: EvaluationResult — that member’s own outcome, including the variables it published and
the rule whose stop ended its runEvaluationResult.matches stays flat, in member order, so a consumer that does not care about the
split needs no change. variables and stoppedBy at the top level are empty and null for a scoped
result: they mean nothing across members.
val result = engine.evaluate(input = input)
for (member in result.members) {
println("${member.key}:")
for (match in member.result.matches) {
println(" ${match.ruleId} -> ${match.actions}")
}
member.result.stoppedBy?.let { println(" halted by $it") }
}
// or ignore the split entirely
result.matches.forEach { match -> println("${match.scopeMember}: ${match.ruleId}") }
Use result.variables for the state at the end of the run, and RuleMatch.assignments when you need
to know which rule produced a value — variables only carries the last write when several rules
assign the same name.
val result = engine.evaluate(prepared = prepared)
println("orderTotal = ${result.variables["orderTotal"]}")
for (match in result.matches) {
for ((name, value) in match.assignments) {
println("${match.ruleId} set $name = $value")
}
}
Values are plain Kotlin types: BigDecimal for numbers, String for text, Boolean for booleans and
List<Any?> for a projected array. A variable no matching rule assigned is simply absent from the map.
A variable built with add arrives as a List<Any?> in the order the values were added, with
duplicates already removed by the engine:
@Suppress("UNCHECKED_CAST")
val topics = result.variables["topics"] as? List<Any?> ?: emptyList()
Note the two ways a rule set can report the same labels. result.matches carries one RuleAction per
rule that fired, so a label produced by two rules appears twice unless those rules guard each other;
an accumulator carries each value once by construction.
See rules.md for the DSL side and the ordering rules, and
the add clause for lists.
The engine can produce a decision trace — a tree showing exactly which conditions were evaluated, what the input values were, and whether each condition passed or failed. This is useful for debugging, auditing, or explaining why a rule matched.
Enable tracing by passing includeTrace = true:
val result = engine.evaluate(prepared = prepared, includeTrace = true)
The trace is available as result.trace, which is a DecisionTree object.
You can serialise it to JSON:
import ruleengine.evaluator.trace.dto.DecisionTree
import ruleengine.evaluator.trace.dto.toJson
val tree = result.trace as? DecisionTree
if (tree != null) {
println(tree.toJson())
}
Example JSON output:
{
"root": {
"id": "n1",
"type": "RULE",
"ruleId": "rent-payment",
"result": true,
"evaluationTimeMs": 0,
"children": [
{
"id": "n2",
"type": "AND",
"result": true,
"children": [
{
"id": "n3",
"type": "CONDITION",
"field": "purpose",
"operator": "contains",
"expected": "rent",
"result": true
},
{
"id": "n4",
"type": "CONDITION",
"field": "amount",
"operator": "gte",
"expected": 500,
"result": true
}
]
}
]
},
"matchedRules": ["rent-payment"]
}
DecisionTree:
root: DecisionNode? — the root node of the evaluation treematchedRules: List<String> — IDs of the rules whose condition held. A rule whose else or
not_exists branch fired is not listed here: the trace answers “did the condition hold”, which the
result flag on that rule’s own node also reports. Read EvaluationResult.matches for what the run
produced.DecisionNode:
id — unique node identifier within the tracetype — one of EVALUATION (the synthetic root), RULE, AND, OR, NOT, CONDITIONfield / operator / expected — present on CONDITION nodes. For a condition whose operand is
an expression (an aggregate, arithmetic, or another field), field is that operand rendered back
to DSL text — e.g. count(orders[status equals "paid"]) — and expected is the evaluated right
operand, so a comparison against another field shows the concrete value it was measured againstactual: Any? — the value actually found. Present on aggregate, arithmetic and field-to-field
conditions; omitted from the JSON on nodes that do not report oneresult: Boolean — whether this node evaluated to trueverdict: ConditionVerdict — what the node answered: TRUE, FALSE, or UNKNOWN when the record
carried no data to decide it. result is verdict == TRUE, so it keeps its old meaning; read
verdict to tell “did not hold” from “could not be decided”branch: RuleBranch? — on a RULE node, the block the verdict selected. Null on every other nodeevaluationTimeMs: Long? — how long this node took to evaluateruleId: String? — present on RULE nodeschildren: List<DecisionNode> — child nodesA filter predicate inside a path (orders[status equals "paid"]) is evaluated once per element and
is deliberately not traced; the enclosing comparison contributes a single node regardless of how
many elements the collection holds.
All loader classes (FieldSchemaLoader, ActionSchemaLoader, ManifestLoader) support loading from String, Reader, or file Path:
// FieldSchemaLoader
FieldSchemaLoader.load(path = Path.of("schema.yaml"))
FieldSchemaLoader.loadFromString(content = yamlString, nameHint = "my-schema")
FieldSchemaLoader.loadFromReader(reader = reader, nameHint = "my-schema")
// ActionSchemaLoader
ActionSchemaLoader.load(path = Path.of("actions.yaml"))
ActionSchemaLoader.loadFromString(content = yamlString)
ActionSchemaLoader.loadFromReader(reader = reader)
// ManifestLoader
ManifestLoader.load(path = Path.of("manifest.yaml"))
ManifestLoader.loadFromString(content = yamlString)
All loaders accept both YAML and JSON content.
Note:
RuleEngineBuilder.fromManifestreads from the filesystem and, with aclasspath:location, from the classpath. Content that lives in memory has to go through these loaders — see section 4 — or through a custom resolver, below.
If the manifest and its files live somewhere neither the filesystem nor the classpath covers — a
database table, an object store, a config server — implement ManifestFileResolver instead of
reassembling the load pipeline by hand:
import ruleengine.manifest.ManifestFile
import ruleengine.manifest.ManifestFileResolver
class DatabaseManifestFileResolver(private val projectId: Long) : ManifestFileResolver {
override fun resolve(relativePath: String, label: String): ManifestFile {
val content = repository.findFile(projectId = projectId, path = relativePath)
?: return ManifestFile.Unavailable(message = "$label file '$relativePath' not found")
return ManifestFile.InMemory(content = content, nameHint = relativePath.substringAfterLast('/'))
}
}
Contract:
ManifestFile.Unavailable rather than throwing, so the builder can attribute
the failure to the manifest entry it belongs to.relativePath that leaves the manifest’s own location instead of following it. Both built-in
resolvers do; a resolver that does not turns a manifest into an arbitrary-read primitive.ManifestFile.OnDisk when the content really is a file, so the Path-based loaders are used
unchanged; ManifestFile.InMemory otherwise.| Object | Thread-safe? | Recommended lifetime |
|---|---|---|
RuleEngineBuilder |
✅ (stateless object) |
Call it from anywhere, including concurrently |
FieldSchema |
✅ (immutable) | Application lifetime / per rule reload |
ActionSchema |
✅ (immutable) | Application lifetime / per rule reload |
List<CompiledRule> |
✅ (immutable) | Application lifetime / per rule reload |
RuleEngine |
✅ (stateless) | Application lifetime — create once, reuse |
LoadedRuleEngine |
✅ (immutable) | Application lifetime / per rule reload |
RuleContext |
❌ (per-call) | Per evaluation |
PreparedRuleContext |
❌ (per-call) | Per evaluation |
setvariables are safe under concurrency. They look like shared state, but everyLoadedRuleEngine.evaluatecall builds its ownPreparedRuleContextand with it its own variable map, so two threads evaluating the same engine cannot see each other’s variables. The unsafe pattern is hoisting aPreparedRuleContextand sharing that — its variable map and aggregate cache are written on every evaluation. See Performance.
To support rule updates without restarting the application, keep the LoadedRuleEngine in an
AtomicReference and swap it after a successful rebuild. Because the builder throws on any problem,
a failed reload leaves the previous engine in place:
import ruleengine.builder.LoadedRuleEngine
import ruleengine.builder.RuleEngineBuilder
import ruleengine.core.domain.dto.EvaluationResult
import ruleengine.core.errors.RuleEngineBuildException
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicReference
class RuleEngineService(private val manifestPath: Path, private val entryId: String) {
private val engineRef: AtomicReference<LoadedRuleEngine> = AtomicReference(build())
private fun build(): LoadedRuleEngine =
RuleEngineBuilder.fromManifestEntry(manifestPath = manifestPath, entryId = entryId)
/** Returns true when the new rules were applied; the old engine stays active otherwise. */
fun reload(): Boolean =
runCatching { build() }.fold(
onSuccess = { reloaded -> engineRef.set(reloaded); true },
onFailure = { failure ->
if (failure !is RuleEngineBuildException) throw failure
logger.error("Rule reload rejected, keeping the current rules: ${failure.message}")
false
}
)
fun evaluate(input: Map<String, Any?>): EvaluationResult =
engineRef.get().evaluate(input = input)
}
The engine uses typed exceptions for different failure modes:
| Exception | When thrown | Package |
|---|---|---|
RuleEngineBuildException |
RuleEngineBuilder cannot build an engine from a manifest |
ruleengine.core.errors |
SchemaLoadException |
A schema YAML file cannot be read or is invalid | ruleengine.core.errors |
ParseException |
A .rule file contains a syntax error |
ruleengine.dsl.diagnostics |
CompilationException |
A rule passes validation but cannot be compiled | ruleengine.core.errors |
InputTooLargeException |
A manifest, schema, rule or input file exceeds the 25 MB read limit | ruleengine.core.errors |
All exceptions extend RuleEngineException. When RuleEngineBuilder is used, every load-phase
failure surfaces as a RuleEngineBuildException that keeps the original failure as its cause and
exposes rule diagnostics via diagnostics, so a single catch block covers the whole load phase.
import ruleengine.core.errors.SchemaLoadException
import ruleengine.dsl.diagnostics.ParseException
import ruleengine.core.errors.RuleEngineException
try {
val schema = FieldSchemaLoader.load(path = Path.of("schema.yaml"))
} catch (e: SchemaLoadException) {
logger.error("Failed to load schema: ${e.message}")
}
try {
val asts = Parser(input = Files.readString(rulePath)).parseRules()
} catch (e: ParseException) {
logger.error("Syntax error in rule file at line ${e.line}, column ${e.column}: ${e.message}")
}
ParseException provides:
line: Int — line number of the errorcolumn: Int — column number of the errorValidation errors are returned as a ValidationResult (not thrown):
val result = Validator.validate(asts = ruleAsts, schema = schema, actions = actions)
if (!result.isValid) {
result.diagnostics
.filter { it.severity == Severity.ERROR }
.forEach { println("[ERROR] ${it.message} (suggestion: ${it.suggestion})") }
}
Register a custom normalizer in the NormalizerRegistry so that schemas can reference it by name:
import ruleengine.core.normalizer.NormalizerRegistry
import ruleengine.core.domain.dto.NormalizerId
// Register before loading any schema
NormalizerRegistry.register(
id = NormalizerId("remove_spaces"),
normalizer = { value -> value.replace(" ", "") }
)
Note: The built-in normalizers (
trim,lowercase,uppercase,collapse_whitespace,remove_punctuation,german_umlaut_fold) are always available without registration.
After registration, use the normalizer in a field schema YAML:
fields:
accountNumber:
type: text
normalizers:
- trim
- remove_spaces
operators:
- equals
| Package | Contents |
|---|---|
ruleengine.builder |
RuleEngineBuilder, LoadedRuleEngine — one-call manifest loading from the filesystem or the classpath |
ruleengine.core.domain.dto |
Evaluation results: RuleMatch, EvaluationResult, RuleAction, RuleBranch, plus OperatorId, NormalizerId |
ruleengine.core.domain.dto.field |
Field model: FieldSchema, FieldDefinition, FieldType, FieldId |
ruleengine.core.domain.dto.action |
Action model: ActionSchema, ActionDefinition, ActionArgType |
ruleengine.core.domain |
Logic over that model: FieldPathResolver / FieldPathResolution (dotted-path resolution), TemporalFormat (date pattern parsing), DefaultActionSchema |
ruleengine.core.normalizer |
NormalizerRegistry, NormalizerProfile, built-in normalizers |
ruleengine.core.errors |
RuleEngineException, RuleEngineBuildException, SchemaLoadException, CompilationException, ValidationDiagnostic |
ruleengine.dsl.parser |
Parser — parses .rule text into List<RuleAst> |
ruleengine.dsl.ast |
AST node types: RuleAst, ConditionAst, AndAst, OrAst, NotAst, ActionAst, etc. |
ruleengine.dsl.diagnostics |
ParseException |
ruleengine.compiler |
Validator, Compiler |
ruleengine.evaluator |
RuleEngine, CompiledRule |
ruleengine.evaluator.context |
RuleContext, PreparedRuleContext |
ruleengine.evaluator.trace |
TraceCollector |
ruleengine.evaluator.trace.dto |
DecisionTree, DecisionNode, toJson() |
ruleengine.schema |
FieldSchemaLoader, ActionSchemaLoader |
ruleengine.manifest |
ManifestLoader, ProjectManifest, ManifestEntry, ManifestPathResolver, ManifestFileResolver, ManifestFile |
ruleengine.manifest.classpath |
ClasspathManifestFileResolver — resolves a manifest’s files as classpath resources |
ruleengine.manifest.source |
ManifestSource — reads a location string (classpath: prefix or filesystem path) into a manifest plus its resolver |
ruleengine.jackson |
JacksonUtil — shared ObjectMapper instance |
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import ruleengine.builder.LoadedRuleEngine
import ruleengine.builder.RuleEngineBuilder
import ruleengine.core.domain.dto.RuleMatch
@Configuration
class RuleEngineConfig {
// Rules live in src/main/resources/rules/, so they travel inside the executable jar.
@Bean
fun transactionRules(): LoadedRuleEngine =
RuleEngineBuilder.fromManifestEntry(
manifestLocation = "classpath:rules/manifest.yaml",
entryId = "transactions"
)
}
@Service
class TransactionClassificationService(private val transactionRules: LoadedRuleEngine) {
fun classify(transaction: Transaction): List<RuleMatch> =
transactionRules.evaluate(
input = mapOf(
"purpose" to transaction.purpose,
"amount" to transaction.amount,
"sepaCode" to transaction.sepaCode,
"iban" to transaction.iban,
"tags" to transaction.tags
)
).matches
}
A single LoadedRuleEngine bean carries the engine and its schema together, so no second bean is
needed. A RuleEngineBuildException during bean creation fails application startup, which is the
intended behaviour: the application never serves traffic with rules that did not validate.
A classpath: location is the right choice here even during development: it works under bootRun,
inside java -jar build/libs/app.jar and from any working directory, because no path is involved. Use
a filesystem location only for rules deliberately kept outside the jar so they can be changed
without a rebuild — and then use an absolute path or one derived from configuration, never a bare
config/rules/manifest.yaml: that resolves against the process working directory, so it works under
bootRun and breaks as soon as the jar is started from somewhere else.
Because both are the same parameter, the location can come straight from configuration and switch between packaged and externalised rules without a code change:
// rules.manifest = classpath:rules/manifest.yaml (packaged)
// rules.manifest = /etc/app/rules/manifest.yaml (externalised)
@Bean
fun transactionRules(@Value("\${rules.manifest}") manifest: String): LoadedRuleEngine =
RuleEngineBuilder.fromManifestEntry(manifestLocation = manifest, entryId = "transactions")
dependenciessrc/main/resources) or on the
filesystem beside the applicationRuleEngineBuilder.fromManifest with a classpath:
location for packaged rules or a path for rules on the filesystem, a manual pipeline only if
neither fitsLoadedRuleEngine / RuleEngine stored as a singleton/bean — not re-created per requestLoadedRuleEngine.evaluate (or RuleContext +
PreparedRuleContext in the manual setup)EvaluationResult.matches consumed by the application layerRuleEngineBuildException (or SchemaLoadException and ParseException in
the manual setup) in place