rule-engine

An in-process, forward-chaining rule engine for the JVM. It decides things that depend on more than one fact, for services where the rules change more often than the code deploys, or where somebody has to justify a decision six months after it was made.

Java 25 Forward-chaining Rete-capable Jackson 3 Maven Central Apache-2.0

Overview

Rules are written in YAML or JSON, validated against a published schema, and compiled once at startup into an immutable, thread-safe CompiledRuleSet. Facts are the host application's own JSON. Firing a rule set is a pure function of the facts put in: the same facts in the same order produce the same firings, on any machine, in any year, because the engine owns no clock. That is what makes a decision reproducible long after it was made.

A pile of if-statements

  • Every new rule is another branch nested inside the last one
  • Correlating facts that arrive separately means threading state by hand
  • A policy change means a code review, a build, and a deploy
  • Explaining why something didn't fire means re-reading the whole method

rule-engine

  • Joins, absence (notExists), universals (forAll) and aggregates (accumulate) as first-class pattern kinds
  • Compile once, share the rule set, one cheap session per unit of work
  • Rules swap under load, and a typo in a rule file fails at compile rather than in production
  • MatchExplainer names the exact constraint that emptied the match set

A rule and a session

This is the rule from README, where it is a compiled fixture; where this page and the engine disagree, README is correct.

apiVersion: rules.v1
rules:
  - id: high-value-order-review
    salience: 10
    noLoop: true
    when:
      - fact: Order
        as: o
        where:
          total:  { gt: 10000 }
          status: { eq: "PENDING" }
      - fact: Customer
        as: c
        where:
          id:       { eq: { $ref: o.customerId } }
          riskTier: { in: ["HIGH", "MEDIUM"] }
    then:
      - action: setField
        target: o
        field: status
        value: "REVIEW"
      - action: emit
        event: "order.flagged"
        payload:
          orderId: { $ref: o.id }
          reason: "high value + risk tier"

The rule set is compiled once, at startup, and shared by everything. Each unit of work (a request, a message, a batch) gets its own session:

// RuleSource.of(Path) reads the file, so it declares IOException.
CompiledRuleSet rules = RuleFiles.compile(RuleSource.of(Path.of("orders.yaml")));

ObjectMapper json = new ObjectMapper();
try (RuleSession session = rules.newSession()) {
    session.insert("Order",    json.readTree("""
        {"id": 1, "total": 25000, "status": "PENDING", "customerId": 7}"""));
    session.insert("Customer", json.readTree("""
        {"id": 7, "riskTier": "HIGH"}"""));

    FireResult result = session.fireAllRules();
    // result.fired()    -> high-value-order-review, once
    // result.emitted()  -> order.flagged, stamped with the session id and the rule-set version
    // result.why()      -> DRAINED
}

Nothing performs I/O by default: emitted events come back as the return value of the fire call, so a rule set is testable with no mocking at all.

How it works

Facts as JSON

A fact is a type name plus a JsonNode payload. Field paths are dotted and map to RFC 6901 JSON Pointers. The engine persists nothing, and fact identity is the handle it hands back, not anything in the payload.

Two tiers

The split between the tiers is the whole design. A CompiledRuleSet is immutable and shared by every thread. A RuleSession is single-writer, cheap to allocate (248ns), and never shared, with one virtual thread per session.

Three matchers in agreement

A naive correctness oracle, an indexed network (the default), and a streaming Rete-style matcher for long-lived sessions. MatcherEquivalence asserts they produce identical firing sequences.

Five actions

There are five actions and no more: setField, insertFact, retractFact, emit, and callFunction. A closed vocabulary stays diffable and reviewable by someone who is not a programmer.

Semantics that impact rule writing

Absent versus null

Absent and null are different values. { eq: null } matches an explicit JSON null and never an absent field; hasField: false is the test for "the field isn't there".

ne on an absent field

ne is true for an absent field, because ne is defined as !eq. Pairing it with hasField: true expresses "present and not equal".

Collections flattened at ingestion

JSON Pointer has no wildcard. An Order with an items[] array becomes one Order fact plus N LineItem facts, joined normally.

Eviction of a negated type

A fact type a rule negates must never be evicted. An evicted fact and an absent one are indistinguishable, so a cap on a negated type stops costing a firing and starts asserting a false conclusion.

Modules

Eight modules ship to Maven Central under com.codeheadsystems and move together: a release tags one version and publishes all of them in a single deployment.

Artifact Contents
rule-engine-core Fact model, working memory, all three matchers, agenda, refraction, RHS execution, sessions, and concurrency helpers
rule-engine-compiler RuleDefinition → CompiledRuleSet: validation, accessor and pattern compilation, network build, and version hash
rule-engine-dsl JSON and YAML rule files → RuleDefinition, plus the rules.v1 schema. The starting point; it brings the compiler and core with it
rule-engine-cel Optional. The expression escape hatch, backed by dev.cel
rule-engine-schema Optional. Fact schemas, backed by JSON Schema
rule-engine-observability TracingListener, JfrListener, MatchExplainer
rule-engine-testkit Fixtures, the firing-sequence oracle, shuffle-determinism and matcher-equivalence harnesses, and JMH benchmarks. Not optional for a consumer testing its own rules
rule-engine-bom The modules above and the Jackson 3 version they are built against, as one import. No code

Documentation

This site is the entry point; the docs themselves live in the repository.

Quick start

The engine requires Java 25 at runtime. Gradle resolves a JDK 25 toolchain via the foojay plugin if none is installed.

implementation(platform("com.codeheadsystems:rule-engine-bom:1.1.0"))
implementation("com.codeheadsystems:rule-engine-dsl")
testImplementation("com.codeheadsystems:rule-engine-testkit")

The modules are on Maven Central under com.codeheadsystems. A build imports rule-engine-bom and names modules without a version; rule-engine-dsl brings the compiler and the core with it, and the BOM sets the Jackson 3 version the engine was built and tested against. The snippet above is updated by hand and can lag; the Maven Central listing has the version that is actually there. Two requirements come with it: Java 25 at runtime, and Jackson 3 (tools.jackson.*) on the consumer's classpath as a declared api dependency, which is a second Jackson for a service already on Jackson 2.

git clone https://github.com/codeheadsystems/rule-engine.git
cd rule-engine
./gradlew :rule-engine-example:run

The worked example is the fastest way to see what a reference page cannot show: what belongs in the ingestion path rather than in a rule, what a long-lived session has to do to stay bounded, and what to assert about a rule set in CI.

./gradlew build        # compile, test, and the strict-mode test run
./gradlew test         # the suite
./gradlew strictTest   # the same suite with -Drules.strict=true

CI runs both test and strictTest; strict mode turns on contract checks too expensive for production and must never run there.