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.
import ruleengine.builder.RuleEngineBuilder
import java.nio.file.Path
// Loads every entry of the manifest, keyed by entry id
val engines = RuleEngineBuilder.fromManifest(manifestPath = Path.of("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(
manifestPath = Path.of("rules/manifest.yaml"),
entryId = "transactions"
)
fromManifestEntry does the same but returns the LoadedRuleEngine directly:
val loaded = RuleEngineBuilder.fromManifestEntry(
manifestPath = Path.of("rules/manifest.yaml"),
entryId = "transactions"
)
| Parameter | Default | Purpose |
|---|---|---|
manifestPath |
— | Path to the manifest YAML (or JSON) file |
entryId |
null |
Build only this entry instead of all of them |
shortCircuitByOutput |
false |
Enable the output-based short-circuit optimisation (see section 4.7) |
normalizerRegistry |
NormalizerRegistry.default |
Normalizer registry used for compilation |
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.
Note: The builder loads from the filesystem. For custom sources (strings, readers, classpath resources) or partial pipelines, use the individual components described in section 4.
Use the individual components when RuleEngineBuilder does not fit: rules that come from a database
or a classpath resource 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.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).
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).
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.
By default the engine evaluates every rule against every input. When a large ruleset
contains many rules that produce the same output (e.g. hundreds of rules all assigning
the same set of labels), you can skip redundant work by enabling shortCircuitByOutput:
val engine = RuleEngine(compiledRules = compiledRules, shortCircuitByOutput = true)
When enabled, the engine groups rules by every static output they declare
(actionName:value, e.g. label:rent). Within each group, evaluation stops at the first
matching rule — further rules in that group would only re-produce an output that is
already settled. This is most effective when the heaviest conditions (e.g. regex) are the
ones that get skipped.
Behaviour notes:
extract) cannot be grouped
and are always evaluated.result.matches is ordered by output group rather than by rule
declaration order.Leave the flag at its default (false) when you need every matching rule reported or must
preserve declaration order — behaviour is then identical to previous versions.
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.EvaluationResult
import ruleengine.core.domain.RuleMatch
import ruleengine.core.domain.RuleAction
val result: EvaluationResult = engine.evaluate(prepared = prepared)
// result.matches is a List<RuleMatch>
for (match: RuleMatch in result.matches) {
println("Rule matched: ${match.ruleId}")
for (action: RuleAction in match.actions) {
println(" ${action.name}: ${action.arguments}")
}
}
EvaluationResult:
matches: List<RuleMatch> — all rules that matched, in the order they were declared (or in output-group order when shortCircuitByOutput is enabled — see section 4.7)trace: Any? — a DecisionTree if tracing was enabled (see section 5), otherwise nullRuleMatch:
ruleId: String — the rule’s IDactions: List<RuleAction> — the actions the rule declaredRuleAction:
name: String — the action name (e.g. "label")arguments: List<Any?> — the argument values (e.g. ["rent"] or [10])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.DecisionTree
import ruleengine.evaluator.trace.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 matched rulesDecisionNode:
id — unique node identifier within the tracetype — one of RULE, AND, OR, NOT, CONDITIONfield / operator / expected — present on CONDITION nodesresult: Boolean — whether this node evaluated to trueevaluationTimeMs: Long? — how long this node took to evaluateruleId: String? — present on RULE nodeschildren: List<DecisionNode> — child nodesAll 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:
RuleEngineBuilderreads from the filesystem only. Content that lives in a database, a classpath resource or memory has to go through these loaders — see section 4.
| Object | Thread-safe? | Recommended lifetime |
|---|---|---|
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 |
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.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.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 |
ruleengine.core.domain |
Domain model: FieldSchema, FieldDefinition, FieldType, ActionSchema, RuleMatch, EvaluationResult, RuleAction |
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, DecisionTree, DecisionNode, toJson() |
ruleengine.schema |
FieldSchemaLoader, ActionSchemaLoader |
ruleengine.manifest |
ManifestLoader, ProjectManifest, ManifestEntry, ManifestPathResolver |
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.RuleMatch
import java.nio.file.Path
@Configuration
class RuleEngineConfig {
@Bean
fun transactionRules(): LoadedRuleEngine =
RuleEngineBuilder.fromManifestEntry(
manifestPath = Path.of("config/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.
dependenciesRuleEngineBuilder.fromManifest unless a manual
pipeline is requiredLoadedRuleEngine / 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