One small application, one rule file, ten events. Run it:
./gradlew :rule-engine-example:runEverything below is in this module, compiled by CI and executed by its tests. Every YAML block that
begins with apiVersion: is a fixture in ReadmeExamplesTest; if this page and the engine disagree,
this page is wrong.
Read docs/dsl-guide.md first if you have never written a rule. This page
assumes you have, and is about the shape of the application around the rules.
- The domain
- The three decisions you make before writing a rule
- The rules, and what each one is for
- Four ways to run it
- What to assert in CI
- Two defects this example found in itself
- File map
A small merchant's order back office. Four fact types arrive from outside:
| Fact type | Where it comes from |
|---|---|
Customer |
reference data, upserted |
Order |
one per order.placed event |
LineItem |
one per element of that event's items array |
Payment |
one per payment.received event |
and two are derived by the rules themselves: OrderUnpaid, which is a withdrawable conclusion, and
Discount, which is an ordinary fact recording a decision.
The feed is feed/orders.jsonl — ten lines telling one
story. Two customers, four orders, three payments, one restock. Change it and re-run; that is what
it is for.
All three live in Ingest.java, and
two of them are expensive to change once rules exist.
Flatten collections. An order.placed event carries its line items nested inside it. That is
not what goes into working memory: one Order without them, plus one LineItem per element,
carrying the order id. There is no wildcard in a JSON Pointer — items.*.qty does not exist — so a
nested array is a value the engine can store and can never match inside. Flattening is also the only
way to get an index over elements at all: LineItem./orderId is indexable, "somewhere inside this
array" is not.
Upsert on the handle. A second customer.upserted for the same id is an update on the existing
handle, not a second insert. Fact identity is the handle, not anything in the payload, so two
Customer facts with the same id are two facts and every join matching one matches both.
Normalise absent fields you plan to read from an expression. priority is defaulted to false
at ingestion. Every operator map in the engine treats an absent field as a value; CEL treats it as an
error. Normalising once at the edge beats has(o.priority) && o.priority in every expression.
The whole file is rules/orders.yaml, and each rule is
there to be the shortest honest use of one feature.
| # | Rule | Teaches |
|---|---|---|
| 1 | high-value-order-review |
a join, salience, setField, and a rule that terminates itself |
| 2 | unpaid-order |
notExists, and logical: true — a conclusion that is withdrawn |
| 3 | ready-to-ship |
forAll, and the companion that closes its vacuity trap |
| 4 | bulk-order-discount |
accumulate — an alias that binds a number, not a fact |
| 5 | paid-within-the-hour |
after / within — ordering two facts with no clock |
| 6 | repeat-unpaid-customer |
forward chaining, over the facts rule 2 concluded |
| 7 | expedite-eligible |
§6.4's expression escape hatch, and why it needs noLoop |
Two are worth reading in full here.
apiVersion: rules.v1
rules:
- id: unpaid-order
salience: 10
when:
- fact: Order
as: o
where:
status: { hasField: true, ne: "CANCELLED" }
- fact: Payment
as: p
quantifier: notExists
where:
orderId: { eq: { $ref: o.id } }
then:
- action: insertFact
fact: OrderUnpaid
logical: true
payload:
orderId: { $ref: o.id }
customerId: { $ref: o.customerId }hasField: true beside the ne is load-bearing: ne is defined as !eq, so an order with no
status at all satisfies ne: "CANCELLED".
logical: true is what makes OrderUnpaid a belief rather than a record. Watch it in the
streaming output: the conclusion is drawn when the order arrives and withdrawn at the next cycle
boundary when its payment does. Nothing in the application deletes it — the justification went, so it
went. That is also what cleans up when Ingest.retractOrder lets go of an unpaid order.
apiVersion: rules.v1
rules:
- id: ready-to-ship
when:
- fact: Order
as: o
where:
status: { hasField: true, ne: "CANCELLED" }
- fact: Payment
as: pay
where:
orderId: { eq: { $ref: o.id } }
- fact: LineItem
as: some
quantifier: accumulate
accumulate:
count: true
having: { gte: 1 }
where:
orderId: { eq: { $ref: o.id } }
- fact: LineItem
as: all
quantifier: forAll
where:
orderId: { eq: { $ref: o.id } }
inStock: { eq: true }
then:
- action: emit
event: order.readyToShip
payload:
orderId: { $ref: o.id }The forAll's join picks the scope — this order's line items — and its other constraints are the
requirement. On its own that is vacuously true for an order with no line items at all, so some is
there to mean "and there is at least one".
some is an accumulate rather than the plain positive pattern the DSL guide suggests, and the
difference is cardinality rather than meaning. A plain pattern binds a line item, so a two-item
order produces two matches and fires this rule twice, emitting the same event each time. An
accumulate binds a number, so there is one match per order however many items it has. See
below.
The rules never change. What changes is session scope, and that decides what a rule can possibly see.
PerOrderDemo. Create a
session, insert the facts this decision is about, fire once, read the result, close it.
O-2: 3 rule(s) fired, stopped because DRAINED
emit order.flagged {"orderId":"O-2","customerId":"C-2","riskTier":"HIGH"}
emit order.expedited {"orderId":"O-2"}
emit order.readyToShip {"orderId":"O-2"}
Compile the rules once at startup; the CompiledRuleSet is immutable and shared. Sessions are the
cheap per-unit-of-work thing.
Rule 6 never fires here, and that is the lesson. It counts a customer's unpaid orders, and a session holding one order can only count to one. Nothing is wrong with the rule — the session cannot see the facts it needs. Check session scope before you reach for the explainer.
BatchDemo. RuleBatches.run
gives one virtual thread and one session per input, and hands back a BatchOutcome per input holding
either a value or a throwable — a batch that fails does not stop the others, and what a partial
result means is the caller's decision to make.
There is no lock and no pool to size. One SessionOptions fans out across every session, though, so
anything mutable it holds becomes shared state: here that is OpsPager, which is written for it.
StreamingDemo. Three things
change, and each is a decision rather than a setting.
The matcher becomes RETE, which maintains joins as facts arrive instead of recomputing them per
cycle. Right when tuples survive thousands of cycles; wrong for a session that fires once.
The owner becomes a SessionActor. A session is single-writer and "fire until told to stop" is a
blocking loop, so inserting from a producer thread while it runs is a data race. One worker owns the
session; producers hand it work through a bounded inbox.
The bound has to come from somewhere, because every structure a long-lived session grows is keyed on handles. Now the whole story is visible in one trace:
order.placed <- O-2 arrives
emit order.flagged {"orderId":"O-2","customerId":"C-2","riskTier":"HIGH"}
emit order.expedited {"orderId":"O-2"}
order.placed <- O-3 arrives; C-2 now has two unpaid orders
emit order.expedited {"orderId":"O-3"}
emit customer.atRisk {"customerId":"C-2","unpaidOrders":2}
after the feed: 18 facts, 1 conclusions held, 18 matches materialised
retract everything about O-1
let go of 4 facts -> 14 facts, 1 conclusions held
retract everything about O-3
let go of 2 facts -> 11 facts, 0 conclusions held
On eviction. §4.4 gives the engine a fact-eviction policy, and of this rule set's six types
exactly one can use it. The demo prints the analysis, and it is worth doing on paper before writing
the config: an evicted fact and an absent fact are indistinguishable, so a cap on Payment — which
rule 2 negates — does not cost a firing, it asserts that a paid order is unpaid. A cap on LineItem
does not weaken rule 3's requirement, it deletes it, and changes rule 4's total without changing
anything a reader would look at.
The exception is Discount: rule 4 inserts it, and nothing patterns, negates, folds or concludes it.
It is also the one type nothing here can let go of, because a rule created it and the application
holds no handle — so in a session that runs for a week it is the type that actually grows, and
SessionOptions.eviction(EvictionPolicy.perType(Map.of("Discount", n))) is the right answer for it.
Four of the remaining five are bounded by the application retracting what it knows is finished, which
is knowledge the engine does not have. The sixth, Customer, is bounded by how many customers the
business has — nothing in this session ever lets one go. That is usually fine, and it is worth
knowing rather than assuming.
DiagnosticsDemo. A rule
that did not fire leaves nothing in a log, because the fast path is optimised precisely not to record
what it eliminated. MatchExplainer re-asks the question one constraint at a time:
rule ready-to-ship: 1 combination(s) matched every pattern and join, but the rule asserts that
every LineItem in scope for 'all' matches it and fact #4 does not (§2.5's FOR_ALL)
o: Order — 1 considered, 1 matched
pay: Payment — 1 considered, 1 matched
all all: LineItem — 2 present, suppressed 1 match(es) — e.g. fact #4 is in scope and fails it
fold some: LineItem — 2 present, suppressed nothing
The same class also shows a dry run — match, resolve conflicts, execute nothing, so you can diff
"what would fire" against the current rule set before shipping a change — and RuleSetHolder,
which swaps a compiled rule set in under load. publish takes a compiled rule set on purpose: a
rule file with a typo in it fails at compile and the engine stays in service on the rules it has.
ExampleRulesTest is meant
to be copied. It needs no facts and runs in milliseconds:
- it compiles — that is your syntax check, and every diagnostic names a file, line and column
unreachableRules()is empty — whatdeclaredFactTypesbuys. A rule patterningOrdrinstead ofOrdercompiles perfectly and fires never, and nothing at runtime can tell you that apart from facts that have not arrived yet- the unindexed set is exactly the one somebody accepted — an allowlist, not
isEmpty(), becausene, a CEL condition and a temporal join can never be indexed. The value is in the diff: a new join that falls back to a linear scan gets named on the build
OrderPipelineTest is the
other half — fire the rules and assert on the emitted events. Nothing performs I/O by default, and
FireResult.emitted() comes back from the firing records, so this needs no mocking at all.
And MatcherAgreementTest
points the testkit's own harnesses at these rules: MatcherEquivalence checks this rule set against
the naive oracle, and ShuffleHarness checks that rule declaration order does not reach the firing
sequence. Both are available to any consumer, for their own rules.
Written down because both are the kind that produce a plausible wrong answer.
ready-to-ship fired twice per order. The positive companion pattern that closes the forAll
vacuity trap also binds a fact, so a two-item order produced two matches and emitted the same event
twice. The fix is the accumulate count above. Nothing about this is visible in a rule review; it
showed up the first time the demo ran against an order with more than one line item.
Rule 1 re-fired rule 2. Rule 1 originally wrote status, which rule 2 tests. That cleared rule
2's refraction, re-fired it, and made it draw its conclusion a second time — correct in the end,
because a re-firing replaces its conclusion, and confusing every time somebody read the trace. Rule 1
writes riskState now. A field written by one rule and tested by another is a coupling; give the
write its own field.
| Path | What it is |
|---|---|
src/main/resources/rules/orders.yaml |
the rule set |
src/main/resources/feed/orders.jsonl |
the ten events |
OrderRules.java |
compile once, with the CompilerOptions that turn runtime surprises into compile errors |
Ingest.java |
events to facts — the three modelling decisions |
OrderEvent.java, EventFeed.java |
the application's own envelope and feed reader |
OpsPager.java |
the callFunction handler, and why it is the wrong default |
PerOrderDemo, BatchDemo, StreamingDemo, DiagnosticsDemo |
the four shapes |
Main.java |
runs all four |