Externalized Rule Definitions
Load rules from JSON, YAML, or a database instead of hand-writing DSL code.
On this page
Externalized Rule Definitions
Every rule you've seen so far is written in BoxLang, using the RuleBox
DSL. Sometimes that's the wrong place for a rule to live - business users
want to tweak a threshold without a deploy, or the rules genuinely belong
in a database table next to the data they govern. RuleBook.loadRules()
loads rule definitions from an external source - JSON, YAML, or a
database - and turns each one into a real Rule, wired into the same
priority chain, audit trail, and dryRun() support as any other rule.
The rule-definition schema
A rule source is any object with a load() method that returns an array
of rule-definition structs (or an envelope
holding that array). Each struct can contain:
| Key | Required | Meaning |
|---|---|---|
name | No | The rule's name, used for auditing. Defaults like any other rule if omitted |
description | No | What the rule does, for the Rule Visualizer. See withDescription() |
priority | No | See Rule Priority. Defaults to 0 |
activeFrom | No | See active(). Left open-ended if omitted |
activeUntil | No | See active(). Left open-ended if omitted |
when | No | A condition node or a predicate reference. Defaults to always-true |
except | No | Same shape as when, negated |
then | No | An array of action references. Defaults to none |
using | No | An array of fact names to restrict every then action to, applied the same way to each one - see The DSL |
stop | No | true to stop the chain after this rule fires |
A JSON example, mirroring the "credit approval" walkthrough used throughout these guides:
[
{
"name": "highRisk",
"priority": 10,
"when": { "lt": [ "creditScore", 600 ] },
"then": [ { "action": "flagHighRisk" } ],
"stop": true
},
{
"name": "approve",
"when": { "gte": [ "creditScore", 600 ] },
"then": [ { "action": "approveApplicant", "params": { "reason": "good credit" } } ]
}
]
Declaring facts in a rule file
A rule source can also return an envelope struct instead of a bare array, so a rule file can declare the facts its rules take:
{
"description": "Approves applicants with a credit score of 600 or more",
"facts": {
"creditScore": { "type": "numeric", "required": true, "description": "FICO score", "example": 680 },
"loanType": { "type": "string", "values": [ "fixed", "variable" ], "default": "fixed" }
},
"enforceFacts": true,
"rules": [
{ "name": "approve", "when": { "gte": [ "creditScore", 600 ] } }
]
}
| Key | Meaning |
|---|---|
rules | The array of rule definitions. Required |
description | What the rulebook decides. See Describing a RuleBook |
facts | The facts, keyed by name, each with type, required, default, description, example and values: the same struct withFacts() takes |
enforceFacts | true to check the facts on every run. See Enforcing facts |
strictFacts | true to also reject undeclared facts |
The same envelope works in YAML:
facts:
creditScore:
type: numeric
required: true
strictFacts: true
rules:
- name: approve
when:
gte: [ creditScore, 600 ]
Any other key, a description that isn't a string, a facts that isn't a
struct, or a flag that isn't a boolean throws
RuleBox.InvalidRuleDefinitionException. The facts are
declared only after every rule builds, so a file that fails to load adds
neither rules nor facts.
The condition tree grammar
when/except accept a small, safe, declarative grammar instead of
arbitrary code - there's no eval, so a rule definition loaded from a
file or a database table can never execute code you didn't write
yourself. A condition node is a struct with exactly one operator key:
| Operator | Shape | Meaning |
|---|---|---|
eq / neq | [ "factPath", value ] | Equals / not equals |
lt / lte / gt / gte | [ "factPath", value ] | Numeric/date comparison |
in | [ "factPath", [ values ] ] | Fact value is one of the given values |
and / or | [ node, node, ... ] | All / any of the child nodes |
not | node | Negates the child node |
factPath supports dot-notation into nested facts ("applicant.address.state").
A missing path resolves to null rather than throwing. Nodes nest freely:
{
"and": [
{ "eq": [ "state", "CA" ] },
{ "or": [
{ "lt": [ "creditScore", 600 ] },
{ "not": { "eq": [ "flagged", true ] } }
] }
]
}
Validation at load time
loadRules() validates every definition as it builds it, so a malformed
condition tree fails immediately instead of inside run() - possibly only
on the day a short-circuited and/or branch is finally reached. It
checks for exactly one operator key per node, a known operator, the operand
shapes in the table above (a string fact path first, and an array second
for in; a non-empty array for and/or) and that activeFrom/activeUntil
parse as dates. Any problem throws RuleBox.InvalidRuleDefinitionException
whose message names the rule (or its 1-based position in the source if it
has no name), the offending path such as when.and[2].lt, and what is
wrong. Definitions are all built before any is added, so a source that
fails to load adds no rules to the book.
A { "predicate": ... } reference is only supported at the top level of
when/except; nesting one inside and/or/not is rejected with a
clear message. Wrap the logic in a single registered predicate instead.
The predicate and action registries
The condition grammar covers comparisons, but not arbitrary logic, and a
rule always needs to do something. For both cases, a rule definition
references code by name, and that name must be registered on the
RuleBook first via registerPredicate()/registerAction() - a rule
definition can never carry inline code, so untrusted rule sources stay
safe to load.
ruleBook
.registerPredicate( "isEligible", ( facts, params ) => facts.creditScore >= params.threshold )
.registerAction( "approveApplicant", ( facts, result, params ) => result.setValue( params.reason ) )
Referenced from a rule definition as:
{
"when": { "predicate": "isEligible", "params": { "threshold": 600 } },
"then": [ { "action": "approveApplicant", "params": { "reason": "good credit" } } ]
}
Both registerAction() and registerPredicate() accept three forms:
- A closure/lambda -
( facts, result, params ) => { ... }for actions,( facts, params ) => booleanfor predicates - An object instance - duck-typed on an
execute( facts, result, params )ortest( facts, params )method.RuleAction/RulePredicatedocument the optional contract - A WireBox mapping ID string - e.g.
"CreditService@myModule", resolved viagetInstance()immediately when you callregisterAction()/registerPredicate(), not deferred to rule execution
ruleBook.registerAction( "approveApplicant", "ApprovalService@myModule" )
An action/predicate name referenced by a rule definition but never
registered throws RuleBox.UnregisteredActionException /
RuleBox.UnregisteredPredicateException from loadRules(), rather than
failing silently or at run time.
JSONRuleSource
ruleBook.loadRules( new rulebox.models.JSONRuleSource( "/path/to/rules.json" ) )
The file is a JSON array of rule-definition structs, as shown above.
YAMLRuleSource
ruleBook.loadRules( new rulebox.models.YAMLRuleSource( "/path/to/rules.yaml" ) )
Same schema, as YAML:
- name: highRisk
priority: 10
when:
lt: [ creditScore, 600 ]
then:
- action: flagHighRisk
stop: true
YAML support depends on the official BoxLang YAML module, which RuleBox
does not install for you - install it yourself if you use
YAMLRuleSource:
box install bx-yaml
DBRuleSource
Point it at a datasource and a SQL statement:
ruleBook.loadRules( new rulebox.models.DBRuleSource(
datasource = "myApp",
sql = "SELECT * FROM rules WHERE ruleset = 'credit'"
) )
...or hand it a query you already have, which is also the easiest way to
test code that uses DBRuleSource:
ruleBook.loadRules( new rulebox.models.DBRuleSource( query = myQuery ) )
Expected columns: name, priority, active_from (optional),
active_until (optional), when_json, except_json (optional),
then_json, stop, using_facts (optional, a comma-delimited list of
fact names). when_json/except_json/then_json hold the same
condition-tree/action JSON used by JSONRuleSource, stored as text:
| Column | Maps to |
|---|---|
name | name |
priority | priority |
active_from | activeFrom (optional) |
active_until | activeUntil (optional) |
when_json | when (deserialized) |
except_json | except (deserialized, optional) |
then_json | then (deserialized) |
stop | stop |
using_facts | using (comma-delimited list) |
stop is parsed leniently: true/false, 1/0, yes/no, y/n
(case-insensitive); empty or NULL means false. Any other stop value, invalid
JSON in a JSON column, a non-numeric priority or an unparseable date throws a
RuleBox.InvalidRuleRowException whose message names the rule (or its 1-based row
number when it has no name) and the offending column; the original parser error
is available in the exception detail.
Writing your own source
Any object with a load() method returning an array of rule-definition
structs, or an envelope, works with loadRules() - a REST call, a config service, a cache,
whatever fits. There's no interface to implement.
Reloading rules manually
loadRules() is a one-shot call you make explicitly - RuleBox never
watches a file or table for changes on its own. To pick up edits, call
reloadRules() whenever you decide it's time (a scheduled task, an admin
action, whatever fits your app):
ruleBook.reloadRules( new rulebox.models.JSONRuleSource( "/path/to/rules.json" ) )
reloadRules() is clearRules() (wipe the current rule chain and audit
trail) followed by loadRules( source ). Registries
(registerAction()/registerPredicate()) and rule metrics
are untouched - clearRules() only touches rules.
Declaring rulebooks in config
For apps with several named rulebooks, RuleBookRegistry builds them from
config instead of hand-writing registerAction()/loadRules() calls for
each one. Declare them under moduleSettings.rulebox.rulebooks in your
app's ColdBox config:
moduleSettings = {
rulebox = {
rulebooks = {
// A string is a rule-source file path - JSON/YAML inferred from the extension.
// Relative paths resolve against your app root, no expandPath() needed.
"credit" : "config/rules/creditscore.yaml",
// An array is inline rule definitions - no file at all.
"promo" : [
{ "name": "blackFriday", "then": [ { "action": "applyDiscount" } ] }
],
// A struct is the full descriptor: source (any of the above, or a DB descriptor),
// plus the actions/predicates this rulebook's definitions reference.
"shipping" : {
"source" : "config/rules/shipping.json",
"actions" : { "applyDiscount" : "PromoActions@myModule" },
"predicates" : { "isEligible" : "PromoPredicates@myModule" }
},
// A DB source needs the struct form, since it can't be expressed as a path or array
"fraud" : {
"source" : { "type" : "db", "datasource" : "myApp", "sql" : "SELECT * FROM rules WHERE ruleset = 'fraud'" }
},
// The struct form can also describe the rulebook, declare the facts it takes, and enforce them
"loans" : {
"source" : "config/rules/loans.json",
"description" : "Decides home loan applications",
"facts" : { "creditScore" : { "type" : "numeric", "required" : true } },
"enforceFacts" : true
}
}
}
}
description, facts, enforceFacts and strictFacts work as in a
rule-file envelope. A config
description wins over the rule file's. Config facts are
applied after the source's, key by key, so config can add a fact or change
one part of a declaration (say, its description) and keep the rest. The
flags only turn checking on: a rule file that enforces its facts keeps
enforcing them.
actions/predicates values here can only be WireBox mapping ID strings
(a closure can't be written in config) - resolved eagerly, same as calling
registerAction()/registerPredicate() yourself. If you need a closure
for a config-declared rulebook, grab it via the registry or DSL below and
register it yourself before use.
Any *.json, *.yaml or *.yml file (extension matched case-insensitively)
dropped in the convention folder (default config/rulebox, override via
conventionPath) is auto-discovered too - the declared name is the filename
without its extension. Directories and dotfiles are ignored, and if two files
map to the same name (e.g. credit.json and credit.yaml) the registry throws
a RuleBox.DuplicateRuleBookException naming both rather than picking one
silently. An explicit config entry of the same name layers its
actions/predicates on top of that discovered file.
Retrieving a declared rulebook
// From a model, handler, or anywhere with WireBox access
getInstance( "RuleBookRegistry@rulebox" ).getRuleBook( "credit" )
// The ruleBook() application helper mixin - available in handlers/views/layouts
ruleBook( "credit" )
// WireBox injection DSL
property name="creditRules" inject="rulebook:credit";
Every one of these builds and returns a fresh RuleBook instance -
RuleBookRegistry never caches a built instance, only the recipe to
build one, so it stays safe to use from a singleton or across concurrent
requests. The inject="rulebook:credit" form actually injects a small
provider (.get() returns a fresh instance) rather than a live RuleBook
directly - so it's safe to inject even into a singleton, since nothing is
built until you call .get() at the point of use. inject="rulebook"
(no name) injects the RuleBookRegistry singleton itself.
Call getInstance( "RuleBookRegistry@rulebox" ).reload() to re-scan your
config and convention folder without restarting.