An in-process, forward-chaining rule engine for the JVM. It decides things that depend on more than one fact, such as "an order over $10,000 from a high-risk customer", "an order whose every line item is in stock", or "a customer with three unpaid orders". It is 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.
Rules are written in YAML or JSON, validated against a published schema, and compiled once at startup. 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.
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. The
badge above shows the version that is actually published. The snippets below are updated by hand, so
where the two disagree the badge is correct:
implementation(platform("com.codeheadsystems:rule-engine-bom:1.1.0"))
implementation("com.codeheadsystems:rule-engine-dsl")
testImplementation("com.codeheadsystems:rule-engine-testkit")<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.codeheadsystems</groupId>
<artifactId>rule-engine-bom</artifactId>
<version>1.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.codeheadsystems</groupId>
<artifactId>rule-engine-dsl</artifactId>
</dependency>
</dependencies>The engine requires Java 25 at runtime and puts Jackson 3 (tools.jackson) on the consumer's
classpath: a fact is a JsonNode, handed to the engine unconverted. The BOM also sets that Jackson
version, to the one the engine was built and tested against, so the host's own Jackson code uses the
same one. docs/embedding.md has the details.
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"salience is an author-assigned priority, used when several rules are eligible at once. noLoop
stops a rule re-triggering itself when its own actions write to a fact it matched.
The rule set is compiled once, at startup, and shared by everything:
// RuleSource.of(Path) reads the file, so it declares IOException; the text-taking
// factories (RuleSource.yaml / RuleSource.json) do not.
CompiledRuleSet rules = RuleFiles.compile(RuleSource.of(Path.of("orders.yaml")));Each unit of work (a request, a message, a batch) gets its own session:
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
}Emitted events come back as the return value of the fire call. Nothing performs I/O by default: the
default sink discards, and FireResult.emitted() is sourced from the firing records. A rule set is
therefore testable with no mocking at all.
DocExamplesTest compiles the rule file above, and SmokeTest.readmeExample runs the session
shown. The same rule can be built in Java rather than parsed from a file, and the two produce an
identical rule set, down to the version hash;
docs/embedding.md has that form.
A rule is not limited to setting constants. One optional module,
rule-engine-cel, adds an expression escape hatch. In an action it is the
cheaper of its two positions: it runs once per firing, where the same expression filtering a pattern
runs once per candidate:
apiVersion: rules.v1
rules:
- id: price-order
noLoop: true
when:
- fact: Order
as: o
where:
status: { eq: "PENDING" }
then:
- action: setField
target: o
field: total
value: { $expr: "double(o.subtotal) + double(o.tax)" }It covers the two things operator maps cannot express: arithmetic across fields, and nested
OR/NOT. It gives up the indexed fast path, which is why reaching it takes an extra module and an
explicit registration. Constraints that can be indexed belong in where.
The double(...) calls are required: comparisons work across integers and decimals, but CEL has no
int + double overload, so adding a whole number to a decimal throws without them. Money arithmetic
is better done before the fact reaches the engine.
docs/dsl-guide.md has the other pitfalls of
expressions, including how CEL treats an absent field and why money arithmetic belongs at
ingestion.
Facts are JSON. A fact is a type name plus a JsonNode payload, not an object that happens to
serialise. Field paths are dotted and map to RFC 6901 JSON Pointers, so customer.tier reads
/customer/tier. Facts come from wherever the host gets them (an event, a request body, reference
data), and the host inserts them; the engine persists nothing. Fact identity is the handle the
engine hands back, not anything in the payload, so inserting the same customer twice makes two
facts.
A rule is when (patterns) and then (actions). Patterns are AND-ed, and so is everything inside a
pattern. There is no or; two rules, or an in, express the alternatives. Any constraint can
compare two facts by naming the other's field with { $ref: alias.field }, and both ends of an
equality or ordering join are indexed. Anything else is re-evaluated per fire cycle, and the
compiler report names it.
There are four kinds of pattern. An ordinary one binds a fact; notExists asserts an absence;
forAll asserts that everything the join selects meets a requirement; accumulate folds a scope
into a number. The last three bind no fact, and accumulate binds a value, so nothing may join to
it. after and before relate two facts in time within a required bound. The engine reads no
clock, so every time it uses comes from an inserted fact.
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. insertFact with logical: true makes a conclusion, withdrawn when the match that made
it stops holding. Derived facts feed back in and other rules match them, so a rule set is a small
program rather than a flat list of filters.
The engine has two tiers, and the split between them is the whole design. A CompiledRuleSet is
immutable, thread-safe, and shared by everything. A RuleSession is single-writer, cheap to
allocate (248ns), and never shared across threads; it holds all the state. The pattern is one
compile, one session per unit of work, and one virtual thread per session. halt() is the only
method legal to call from another thread.
A fire cycle matches, resolves the conflict set (the activations currently eligible), and fires one, repeating until nothing is eligible. Order is salience, then recency, then a total tiebreak, and refraction stops the same match firing twice on the same facts. Right-hand sides are staged and then committed as a unit, so no action sees a half-applied world.
rule-engine-example is a complete small application, with one rule file,
one feed of ten events, and four deployment shapes side by side. It runs with:
./gradlew :rule-engine-example:runIt is the fastest way to see what a reference page cannot show: what belongs in the ingestion path rather than in a rule, how session scope decides what a rule can possibly see, what a long-lived session has to do to stay bounded, and what to assert about a rule set in CI. Its README is a companion to the DSL guide.
All three are intended behaviour, and all three shape the data model from the first day.
- Absent and null are different values.
{ eq: null }matches an explicit JSON null and never an absent field.hasField: falseis the test for "the field isn't there". neis true for an absent field, becauseneis defined as!eq.status != "CLOSED"matches an order with nostatusat all. Pairing it withhasField: trueexpresses "present and not closed". The same applies tonotIn.- Collections are flattened at ingestion. JSON Pointer has no wildcard, so
items.*.qtydoes not exist and is not planned. AnOrderwith anitems[]array becomes oneOrderfact plus NLineItemfacts carryingorderId, joined normally. Flattening is the only way to get indexing and incremental matching over collection elements, and retrofitting it means rewriting every rule that touches a collection.
docs/dsl-guide.md opens with the fuller version of these, and the DSL
reference has the rest: vacuous forAll over an empty scope, what min of nothing means, and why
nothing may name a quantified pattern's alias.
The sharpest hazard in the engine appears at operations time rather than authoring time. A fact
type that the rules negate, quantify over, fold, or conclude 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.
docs/embedding.md works through all four
shapes.
A firing leaves a record; a non-firing leaves nothing to look up, because the fast path is optimised
not to record what it eliminated. MatchExplainer re-evaluates the constraints one at a time and
names the one that emptied the set:
Explanation why = new MatchExplainer(rules, session).explain("high-value-order-review");
System.out.println(why.describe());
// rule high-value-order-review: matched, but refracted — already fired at recency 4
// o: Order — 1 considered, 1 matched
// c: Customer — 1 considered, 1 matchedFour answers cover nearly every real case, and the last two are the least obvious:
- No fact of some type exists. The usual cause is a fact type spelled differently from how the host
inserts it.
declaredFactTypessurfaces it asreport().unreachableRules(), which a CI build can assert is empty. - N facts were considered and all failed a named constraint. The report gives the value that failed it.
- The session could not see the facts. A rule spanning two orders cannot fire in a session holding one, however it is written. Scope is the thing to check before the rule.
- The rule already fired on those exact facts. That is refraction, and it is what stops rules firing forever.
The three modelling traps above come before any of these. A rule that "matches nothing" is very
often an eq: null that meant hasField: false, and a rule that "matches everything" is very often
a bare ne.
At 3am the session is closed and the facts are gone, so the postmortem path (capture with a listener
or exportFacts(), replay, then explain) has to be built before it is needed.
docs/embedding.md has it.
CompiledRuleSet.report() is data rather than a printed string, so a build can assert on it:
CompilerReport report = rules.report();
// rule set sha256:4073bf55c15edf78
// 2 rules, 3 distinct alpha nodes from 4 tests (sharing 1.33x), 2 patterns, 1 join edges
// unindexed: fraud-check: o.region (NOT_IN)Two options turn a runtime problem into a build-time one, and both belong in a build from the first day:
CompilerOptions.builder()
.declaredFunctions(Set.of("notifySlack")) // a typo becomes a compile error
.declaredFactTypes(Set.of("Order", "Customer")) // fills report().unreachableRules() -- assert it
.build();An alpha node is one single-fact test, shared across every rule that expresses it; the sharing figure is how much of that work the rule set has in common. A join edge is a constraint relating two facts. Neither is configured; they are how the report describes what it built.
docs/dsl-guide.md has the whole gate, including how
to handle unindexed constraints and what registering fact schemas provides.
ExampleRulesTest
is a copyable version.
A host compiles once and shares the CompiledRuleSet, and creates a cheap single-writer
RuleSession per unit of work, one virtual thread each.
docs/embedding.md is the host-side manual and covers the operational
surface. Four of its answers in brief:
- Rules swap under load.
RuleSetHolderis one volatile field and no locks, andpublishtakes an already-compiled rule set, so a rule file with a typo in it fails at compile and the engine stays in service on the rules it has. A swap affects new sessions only. - Running many at once is the default shape.
RuleBatchesgives one virtual thread and one session per batch, returning a per-batch outcome that carries either a result or a failure. - Choosing a matcher is not required. The default is the indexed network, which is the right choice for per-request work; the streaming matcher matters only for long-lived sessions.
- A decision is fast enough for a request path. Allocating a session, inserting twenty facts, and
firing to completion measures about 15µs on the default matcher; two hundred facts is about 455µs.
Neither is a latency guarantee (work is bounded, wall time is not), so a host with a hard budget
supplies a watchdog.
docs/benchmarks.mdhas the method and the error bars.
Registering a HostFunction, every SessionOptions setting, the work limits, eviction, tracing and
Flight Recorder, and reconstructing a decision after the session is closed are all in the manual.
Every badge reads the live version from Maven Central. All eight move together (a release tags one version and publishes all of them in a single deployment), so a badge showing a different number from its neighbours means a deployment went wrong rather than that the modules drifted.
| Document | Contents |
|---|---|
rule-engine-example/README.md |
the starting point: a complete, runnable application |
docs/dsl-guide.md |
writing a rule, from a blank file |
docs/dsl-reference.md |
every operator, every action, every diagnostic code |
docs/embedding.md |
the host side: sessions, options, limits, concurrency, operations, and diagnosing production |
docs/choosing-this-engine.md |
where it fits, where it does not, the comparisons, and getting out |
docs/rule-engine-spec.md |
the specification, and the source of truth |
docs/benchmarks.md |
what is measured, on what, and what the numbers do not show |
docs/style.md |
the register, punctuation, and terminology the documentation is written to |
Building requires a JDK 25 toolchain; Gradle resolves one via the foojay plugin if none is installed.
./gradlew build # compile, test, and the strict-mode test run
./gradlew test # the suite
./gradlew strictTest # the same suite with -Drules.strict=true, which turns on the contract
# checks too expensive for production. CI runs both; never run it in prod
./gradlew javadoc # warnings fail the build; several contracts live only in Javadoc
./gradlew testCodeCoverageReport # aggregated across modules, currently 93.5% line
./gradlew :rule-engine-example:run # the worked exampleRELEASING.md covers releasing: a vX.Y.Z tag starts the workflow, which does the
rest.
Copyright 2026 Ned Wolpert.
Licensed under the Apache License, Version 2.0; see LICENSE.