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

What is rule-engine?

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 your own JSON. Firing a rule set is a pure function of the facts you put in — the same facts in the same order produce the same firings, on any host, 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 — a typo in a rule file fails to compile, not in production
  • MatchExplainer names the exact constraint that emptied the match set

A rule, and running it

This is the rule from README, where it is a compiled fixture — if this page and the engine disagree, trust README.

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"

Compiled once, at startup, and shared by everything. Then a session per unit of work — a request, a message, a batch:

// 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 are JSON

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

Two tiers, and the split 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 — one virtual thread per session.

Three matchers, held to 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 and no more

setField, insertFact, retractFact, emit, callFunction — a closed vocabulary that stays diffable and reviewable by someone who is not a programmer.

Semantics that impact rule writing

Absent and null are different values

{ eq: null } matches an explicit JSON null and never an absent field. Use hasField: false for "the field isn't there".

ne is true for an absent field

Because ne is defined as !eq. Pair it with hasField: true when you mean "present and not equal".

Flatten collections at ingestion

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

Never evict a fact type a rule negates

An evicted fact and an absent one are indistinguishable. A cap on a negated type stops costing a firing and starts asserting a false conclusion.

Modules

Seven 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, concurrency helpers
rule-engine-compiler RuleDefinitionCompiledRuleSet: validation, accessor and pattern compilation, network build, version hash
rule-engine-dsl JSON and YAML rule files → RuleDefinition, plus the rules.v1 schema. Start here — 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, JMH benchmarks. Not optional for testing your own rules

Documentation

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

Quick start

Requires Java 25 at runtime. Gradle resolves a JDK 25 toolchain via the foojay plugin if it is not installed.

implementation("com.codeheadsystems:rule-engine-dsl:1.0.0")
testImplementation("com.codeheadsystems:rule-engine-testkit:1.0.0")

On Maven Central under com.codeheadsystems. One line is usually the whole dependency — rule-engine-dsl brings the compiler and the core with it. The snippet above is bumped 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 your classpath as a declared api dependency — a second Jackson if you're already on Jackson 2.

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

The fastest way to see what a reference page cannot show you: 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.