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.

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.

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

Parameters

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

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.

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.


4. Advanced Rule Engine Preparation

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.

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

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

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.

Output-Based Short-Circuit (Optional Performance Optimisation)

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:

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.

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

RuleMatch:

RuleAction:


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

DecisionNode:


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 reads from the filesystem only. Content that lives in a database, a classpath resource or memory has to go through these loaders — see section 4.


7. Thread Safety and Lifecycle

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

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

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


Checklist for Integration