rule-engine

Integration Guide

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.


Table of Contents

  1. Adding the Dependency
  2. Core Concepts for Developers
  3. Quick Start: RuleEngineBuilder
  4. Advanced Rule Engine Preparation
  5. Tracing — Decision Tree Output
  6. Loading from Strings and Readers
  7. Thread Safety and Lifecycle
  8. Error Handling
  9. Extending the Engine
  10. Package Overview

1. Adding the Dependency

The rule engine is published as a standard JVM library. Add it to your build file:

Gradle (Kotlin DSL)

dependencies {
    implementation("com.example:ruleengine-core:1.0-SNAPSHOT")
}

Gradle (Groovy DSL)

dependencies {
    implementation 'com.example:ruleengine-core:1.0-SNAPSHOT'
}

Maven

<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.

Transitive Dependencies

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

2. Core Concepts for Developers

The engine lifecycle has two clearly separated phases:

Load Phase (happens once at startup or reload)

FieldSchemaLoader  ──►  FieldSchema
ActionSchemaLoader ──►  ActionSchema
Parser             ──►  List<RuleAst>
Validator          ──►  ValidationResult   (check for errors before proceeding)
Compiler           ──►  List<CompiledRule>
RuleEngine         ──►  ready to evaluate

Evaluation Phase (happens per input record)

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.


3. Quick Start: RuleEngineBuilder

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.

What you get back

fromManifest returns a Map<String, LoadedRuleEngine> keyed by manifest entry id. Each LoadedRuleEngine bundles everything belonging to one entry:

Because the schema travels with the engine, a single object can be passed around, stored as a bean, or swapped atomically on reload.

Loading a single entry

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"
)

Parameters

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.

What is validated

The builder fails fast instead of handing out a half-initialised engine. It raises RuleEngineBuildException (from ruleengine.core.errors) when:

The 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.

Loading from the classpath

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/classes resolves to a jar:nested:/app.jar/!BOOT-INF/classes/!/rules/manifest.yaml URL (Spring Boot 3.2+) or a jar:file:/app.jar!/BOOT-INF/classes!/… URL, and the JDK ships no FileSystemProvider for either nested form — so no Path can be constructed at all. Rules packaged under BOOT-INF/lib/*.jar are a jar inside a jar and have the same problem. Use classpath: 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:

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.


4. Advanced Rule Engine Preparation

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.

4.1 Manifest-Based Loading by Hand

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.resolveWithinBase from ruleengine.manifest when the manifest is not fully under your control.

4.2 Loading a Field Schema

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:

4.3 Loading an Action Schema

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:

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.

4.4 Parsing Rules

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.

4.5 Validating Rules

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:

Severity 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:

Two deliberate asymmetries are worth knowing when you interpret diagnostics:

4.6 Compiling Rules

import ruleengine.compiler.Compiler

val compiledRules = Compiler.compileRules(asts = ruleAsts, schema = schema)

Compilation:

4.7 Building the Engine

import 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.

Evaluation Order

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.

4.8 Evaluating Input Data

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.

Supported value types

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.

Loading input from JSON

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)

4.9 Reading the Result

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:

RuleMatch:

RuleAction:

Scoped Entries — Reading Per-Member Results

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:

MemberEvaluation:

EvaluationResult.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}") }

Reading Variables

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.


5. Tracing — Decision Tree Output

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:

DecisionNode:

A 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.


6. Loading from Strings and Readers

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.fromManifest reads from the filesystem and, with a classpath: location, from the classpath. Content that lives in memory has to go through these loaders — see section 4 — or through a custom resolver, below.

Serving a manifest from a custom location

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:


7. Thread Safety and Lifecycle

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

set variables are safe under concurrency. They look like shared state, but every LoadedRuleEngine.evaluate call builds its own PreparedRuleContext and with it its own variable map, so two threads evaluating the same engine cannot see each other’s variables. The unsafe pattern is hoisting a PreparedRuleContext and sharing that — its variable map and aggregate cache are written on every evaluation. See Performance.

Hot Reload Pattern

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)
}

8. Error Handling

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:

Validation 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})") }
}

9. Extending the Engine

9.1 Custom Normalizers

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

10. Package Overview

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

Full Example: Spring Boot Integration

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")

Checklist for Integration