RuleBox 1.0.0
Archived documentation for RuleBox 1.0.0, frozen as it shipped in 2018.
On this page
RuleBox 1.0.0
Archived version. This page preserves the RuleBox 1.0.0 docs as originally written. For the current release, see the Latest docs.
RuleBox is a modern intuitive and natural language rule engine based upon the great work of RuleBook: https://github.com/rulebook-rules/rulebook ported over to BoxLang.
Tired of classes filled with if/then/else statements? Need a nice abstraction that allows rules to be easily specified in a way that decouples them from each other? Want to write rules the same way that you write the rest of your code in BoxLang? RuleBox is right for you!
RuleBox allows you to write rules in an expressive and dynamic Domain Specific Language modeled closely after the Given-When-Then methodology.
Requirements
- BoxLang 1.14+
Installation
Just leverage CommandBox: box install rulebox and it will install as a
module in your ColdBox application.
Usage
The module will register the following objects in WireBox:
Rule@rulebox- A transient ruleRuleBook@rulebox- A transient rule book objectBuilder@rulebox- A static class that can be used to build a-la-carte rules and rule books.Result@rulebox- RuleBooks produce results and this is the object that models such results.
Defining Rules
The preferred approach is for you to create your own RuleBook that
extends: rulebox.models.RuleBook with a method called defineRules().
In this method you will define all the rules that apply to that specific
RuleBook using our DSL. There is nothing stopping you from creating
rulebooks on the fly, which can allow you to create dynamic or a-la-carte
rules if needed.
RuleBooks should be transient objects as they are reused when binded with facts.
A HelloWorld Example
class extends="rulebox.models.RuleBook"{
function defineRules(){
// Add a new rule to this rulebook
addRule(
newRule( "MyRule" )
.then( ( facts ) => println( "Hello " ) )
.then( ( facts ) => println( "World" ) )
)
}
}
As you can see from above, new rules are created by calling the
newRule() method with an optional name that you can use to identify
the rule you register. You can also define rules as a closure/lambda
with slightly different syntax:
class extends="rulebox.models.RuleBook"{
// Lambdas
function defineRules(){
addRule( ( rule ) => {
rule
.setName( "MyRule" )
.then( ( facts ) => println( "Hello " ) )
.then( ( facts ) => println( "World" ) )
} )
}
}
The RuleBook also has a
nameproperty, so you can attach a human readable name to the RuleBook viasetName( name )method.
...or use 2 rules
class extends="rulebox.models.RuleBook"{
function defineRules(){
.addRule(
newRule()
.then( () => println( "Hello " ) )
)
.addRule(
newRule()
.then( () => println( "World " ) )
)
}
}
now, run it!
getInstance("HelloWorld").run()
Like mentioned before, I can also create a-la-carte rules and a rulebook
by leveraging the Builder:
builder = getInstance( "Builder@rulebox" )
builder
.create( "My RuleBook" )
.addRule(
builder.rule()
.then( ( facts ) => println( "Hello " ) )
)
.addRule(
builder.rule()
.then( ( facts ) => println( "World " ) )
)
.run()
The Above Example Using Facts
class extends="rulebox.models.RuleBook"{
function defineRules(){
addRule(
newRule()
.when( ( facts ) => facts.keyExists( "hello" ) )
.then( ( facts ) => println( facts.hello ) )
)
.addRule(
newRule()
.when( ( facts ) => facts.keyExists( "world" ) )
.then( ( facts ) => println( facts.world ) )
)
}
}
..or it could be a single rule
class extends="rulebox.models.RuleBook"{
function defineRules(){
addRule(
newRule()
.when( ( facts ) => facts.keyExists( "hello" ) && facts.keyExists( "world" ) )
using( "hello" ).then( ( facts ) => println( facts.hello ) )
using( "world" ).then( ( facts ) => println( facts.world ) )
)
}
}
now, run it!
getInstance( "MyRuleBook" )
.run( {
"hello" : "Hello ",
"world" : " World"
} )
# or using the givenAll() method
getInstance( "MyRuleBook" )
.givenAll( {
"hello" : "Hello ",
"world" : " World"
} )
.run()
A More Complex Scenario
The Requirements:
MegaBank issues home loans. If an applicant's credit score is less than 600 then they must pay 4x the current rate. If an applicant's credit score is between 600, but less than 700, then they must pay a an additional point on top of their rate. If an applicant's credit score is at least 700 and they have at least $25,000 cash on hand, then they get a quarter point reduction on their rate. If an applicant is a first time home buyer then they get a 20% reduction on their calculated rate after adjustments are made based on credit score (note: first time home buyer discount is only available for applicants with a 600 credit score or greater).
Given those set of requirements we will create the rules, but this time
we will also track results using a RuleBox Result object. The Result
object is passed to the then() methods and it has a very simple API for
dealing with results. Please note that the same instance of that Result
object is passed from rule to rule, so you can work on the result. Much
how map, reduce functions work. The Result object can also be pre-set
with a default value by leveraging the withDefaultValue() method in the
RuleBook object. If not, the default value would be null.
Basic Result methods are:
setValue()- Set the value in the resultgetValue()- Get the valueisPresent()- Has the value been set or is itnull
Applicant.bx
class{
property creditScore
property cashOnHand
property firstTimeHomeBuyer
function init( creditScore, cashOnHand, firstTimeHomeBuyer ){
variables.creditScore = arguments.creditScore
variables.cashOnHand = arguments.cashOnHand
variables.firstTimeHomeBuyer = arguments.firstTimeHomeBuyer
return this
}
}
This Applicant.bx tracks our home loan applicants, now let's build the
rules for this home loan.
HomeLoanRateRuleBook.bx
/**
* This rule book determines rules for a home loan rate
*/
class extends="rulebox.models.RuleBook"{
function defineRules(){
//credit score under 600 gets a 4x rate increase
addRule(
newRule()
.when( ( facts ) => facts.applicant.getCreditScore() < 600 )
.then( ( facts, result ) => result.setValue( result.getValue() * 4 ) )
.stop()
)
//credit score between 600 and 700 pays a 1 point increase
addRule(
newRule()
.when( ( facts ) => facts.applicant.getCreditScore() < 700 )
.then( ( facts, result ) => result.setValue( result.getValue() + 1 ) )
)
//credit score is 700 and they have at least $25,000 cash on hand
addRule(
newRule()
.when( ( facts ) => facts.applicant.getCreditScore() >= 700 && facts.applicant.getCashOnHand() >= 25000 )
.then( ( facts, result ) => result.setValue( result.getValue() - 0.25 ) )
)
// first time homebuyers get 20% off their rate (except if they have a creditScore < 600)
addRule(
newRule()
.when( ( facts ) => facts.applicant.getFirstTimeHomeBuyer() )
.then( ( facts, result ) => result.setValue( result.getValue() * 0.80 ) )
)
}
}
Now that we have built the rules and applicant, let's run them with a few example applicants. Remember, you would run these from a handler or another service method. Below I am running them from a BDD test:
describe("Home Loan Rate Rules", () => {
it("Can calculate a first time home buyer with 20,000 down and 650 credit score", () => {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given(
"applicant",
new tests.resources.Applicant(650, 20000, true)
)
homeLoans.run()
expect(homeLoans.getResult().isPresent()).toBeTrue()
expect(homeLoans.getResult().getValue()).toBe(4.4)
})
it("Can calculate a non first home buyer with 20,000 down and 650 credit score", () => {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given(
"applicant",
new tests.resources.Applicant(650, 20000, false)
)
homeLoans.run()
expect(homeLoans.getResult().isPresent()).toBeTrue()
expect(homeLoans.getResult().getValue()).toBe(5.5)
})
})
Let's even take this further and just use facts instead of the
Applicant.bx
/**
* This rule book determines rules for a home loan rate using facts
*/
class extends="rulebox.models.RuleBook"{
function defineRules(){
//credit score under 600 gets a 4x rate increase
addRule(
newRule()
.when( ( facts ) => facts[ "creditScore" ] < 600 )
.then( ( facts, result ) => result.setValue( result.getValue() * 4 ) )
.stop()
)
//credit score between 600 and 700 pays a 1 point increase
addRule(
newRule()
.when( ( facts ) => facts[ "creditScore" ] < 700 )
.then( ( facts, result ) => result.setValue( result.getValue() + 1 ) )
)
//credit score is 700 and they have at least $25,000 cash on hand
addRule(
newRule()
.when( ( facts ) => facts[ "creditScore" ] >= 700 && facts[ "cashOnHand" ] >= 25000 )
.then( ( facts, result ) => result.setValue( result.getValue() - 0.25 ) )
)
// first time homebuyers get 20% off their rate (except if they have a creditScore < 600)
addRule(
newRule()
.when( ( facts ) => facts[ "firstTimeHomeBuyer" ] )
.then( ( facts, result ) => result.setValue( result.getValue() * 0.80 ) )
)
}
}
describe("Home Loan Rate Rules", () => {
it("Can calculate a first time home buyer with 20,000 down and 650 credit score", () => {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given("creditScore", 650)
.given("cashOnHand", 20000)
.given("firstTimeHomeBuyer", true)
homeLoans.run()
expect(homeLoans.getResult().isPresent()).toBeTrue()
expect(homeLoans.getResult().getValue()).toBe(4.4)
})
it("Can calculate a non first home buyer with 20,000 down and 650 credit score", () => {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given("creditScore", 650)
.given("cashOnHand", 20000)
.given("firstTimeHomeBuyer", false)
homeLoans.run()
expect(homeLoans.getResult().isPresent()).toBeTrue()
expect(homeLoans.getResult().getValue()).toBe(5.5)
})
})
Result Object
From the code above you might have noticed some nice convenience methods
in the Result object. Here are some more:
ifPresent( closure )- You pass a closure that receives the value and it is only called if the value is NOT nullorElse( value )- You can get a value or a default value if the value is not set.orEleseGet( closure )- If the value is not set, then we will call your closure which should return a value back.
if (rulebook.getResult().isPresent()) {
// do something.
}
rulebook.getResult().ifPresent( ( value ) => println("The vaue produced is #value#") )
Thread Safety
RuleBook and Rule are transient (non-singleton) objects, and
given()/givenAll() write facts directly onto the instance's own
state. That means a single RuleBook instance is not safe to run()
concurrently from multiple threads, or with different facts across
separate calls - the facts and rule status audit trail accumulate on
that instance across every run() call. Always request a fresh
transient instance (e.g. getInstance( "MyRuleBook" )) per invocation,
whether that invocation happens on a separate thread or simply at a
separate point in time with different facts. A RuleBook instance is
only safe to run() again with the exact same facts it already holds.
The RuleBook Domain Specific Language Explained
The RuleBox BoxLang Domain Specific Language (DSL) uses the
Given-When-Then format, popularized by Behavior Driven Development
(BDD) and associated testing frameworks (e.g. TestBox, Cucumber and
Spock) and highly inspired by our Java Counterpart: RuleBook
(https://github.com/rulebook-rules/rulebook). Many of the ideas that went
into creating the RuleBox BoxLang DSL are also borrowed from BDD,
including: Sentences should be used to describe rules and Rules should
be defined using a ubiquitous language that translates into the
codebase.
Given-When-Then: The Basis of the RuleBook Language
Much like the Given-When-Then language for defining tests that was popularized by BDD, RuleBox uses a Given-When-Then language for defining rules. The RuleBox Given-When-Then methods have the following meanings:
- Given - some Fact(s)
- When - a condition evaluates to true
- Except - a condition that evaluates to false
- Then - an action is triggered
This is great, but we have determined that the when() operations can
also get out of hand, so we introduced another rule to the language:
except(). So you can say: when().except().then(). This can be a handy
exception function that even though the when condition evaulates to
true, if you chain an except() to it that must evaluate to false.
given, givenAll methods can accept one or more facts in various
different forms and are used as a collection of information provided to
a single Rule. When grouping Rules into a RuleBook, facts are supplied to
the Rules when the RuleBook is run, so the Given can be inferred.
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given("creditScore", 650)
.given("cashOnHand", 20000)
.given("firstTimeHomeBuyer", false)
.run()
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.givenAll({
creditScore: 650,
cashOnHand: 20000,
firstTimeHomeBuyer: false,
})
.run()
When methods accept a Predicate closure/lambda that evaluates a
condition based on the Facts provided. Only one when() method can be
specified per Rule and it must return boolean.
.when( ( facts ) => {
// determine if we continue or not
return boolean
} )
Except methods negate the when() operation if it passes. Thus you can
say, when the balance is greater than 100, except when your account is
disabled, then dispense some money.
except( ( facts ) => facts.accountDisabled )
Then methods accept a Consumer closure/lambda that describe the action
to be invoked if the condition in the when() method evaluates to
true. There can be multiple then() methods specified in a Rule
that will all be invoked in the order they are specified if the when()
condition evaluates to true. If a then() returns a true then no
more consumers left in the execution will execute, thus breaking the
consumer chain. If you return void or false the chain continues.
.then( ( facts, result ) => {
// do stuff
// break the next then()
return true
} )
.then( ( facts, result ) => {
// This never fires
} )
The Using Method
Using methods reduce the set of facts available to a then() method.
Multiple using() methods can also be chained together if so desired.
The aggregate of the facts with the names specified in all using()
methods immediately preceeding a then() method will be made available
to that then() method. Please look above for the using() examples.
The Stop Method
Stop methods break the rule chain. If a stop() method is specified when
defining a rule, it means that if the when() condition evaluates to
true, following the completion of the then() action(s), the rule
chain should be broken and no more rules in that chain should be
evaluated.
Working With Facts
Facts can be provided to Rules using the given() and givenAll()
methods. In RuleBooks, facts are provided to Rules when the RuleBook is
run. The facts available to Rules and RuleBooks are contained in a
struct, so this means that the facts are passed by referece. The reason
why facts exist is so that there is always a reference to the objects
that Rules work with - even if say, an immutable object is replaced, the
perception is that the Fact still exists and provides a named reference
to a representative object.
Auditing Rules
Rule auditing is also very handy in knowing which rules fired and which
ones did not. The RuleBook is in charge of tracking its rules in a
special struture called RuleStatusMap. It is imperative that you give
rules a name in order for the auditing to present you meaningful data,
if not you will see the intern ID of the rule. You can name rules in many
ways:
// Using the Builder
builder.rule("ruleName")
// Using the new Rule method
addRule(newRule("ruleName"))
// Or using it's setter
addRule(newRule().setName("ruleName"))
Each Auditable Rule added to a RuleBook has its state recorded in the
RuleBook. At the time when rules are registered in the RuleBook, their
Rule Status is REGISTERED. After the RuleBook is run, their Rule Status
is changed to SKIPPED for all rules whose conditions do not evaluate to
true. For rules whose conditions do evaluate to true and whose then()
action completes successfully, their Rule Status is changed to
EXECUTED. If a then() action throws an exception, the Rule Status is
changed to FAILED and the exception is re-thrown to the caller.
Retrieving the status of a rule can be done as follows:
status = ruleBook.getRuleStatus("rule1")
status = ruleBook.getRuleStatus("rule2")
Or you can retrieve the entire struct of statuses:
writeDump(ruleBook.getRuleStatusMap())