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:

KeyRequiredMeaning
nameNoThe rule's name, used for auditing. Defaults like any other rule if omitted
descriptionNoWhat the rule does, for the Rule Visualizer. See withDescription()
priorityNoSee Rule Priority. Defaults to 0
activeFromNoSee active(). Left open-ended if omitted
activeUntilNoSee active(). Left open-ended if omitted
whenNoA condition node or a predicate reference. Defaults to always-true
exceptNoSame shape as when, negated
thenNoAn array of action references. Defaults to none
usingNoAn array of fact names to restrict every then action to, applied the same way to each one - see The DSL
stopNotrue 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 ] } }
	]
}
KeyMeaning
rulesThe array of rule definitions. Required
descriptionWhat the rulebook decides. See Describing a RuleBook
factsThe facts, keyed by name, each with type, required, default, description, example and values: the same struct withFacts() takes
enforceFactstrue to check the facts on every run. See Enforcing facts
strictFactstrue 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:

OperatorShapeMeaning
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
notnodeNegates 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 ) => boolean for predicates
  • An object instance - duck-typed on an execute( facts, result, params ) or test( facts, params ) method. RuleAction/RulePredicate document the optional contract
  • A WireBox mapping ID string - e.g. "CreditService@myModule", resolved via getInstance() immediately when you call registerAction()/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:

ColumnMaps to
namename
prioritypriority
active_fromactiveFrom (optional)
active_untilactiveUntil (optional)
when_jsonwhen (deserialized)
except_jsonexcept (deserialized, optional)
then_jsonthen (deserialized)
stopstop
using_factsusing (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.

Edit this page Download Markdown Last updated Oct 7, 2026, 10:59:26 AM