» A Simple & Intuitive Rules Abstraction for Java
100% Java · Lambda Enabled · Simple, Intuitive DSL · Lightweight
RuleBook rules are built in the way that Java developers think: Java code. And they are executed in the way that programmers expect: In order. RuleBook also allows you to specify rules using an easy to use Lambda enabled Domain Specific Language or using POJOs that you define!
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 Java]? RuleBook just might be the rules abstraction you've been waiting for!
Got questions? Here are answers to Frequently Asked Questions!
Still not finding what you are looking for? Try the Wiki!
- 1 Getting RuleBook
- 2 Using RuleBook
- 3 The RuleBook Domain Specific Language
- 4 POJO Rules
- 5 Using RuleBook with Spring
- 6 How to Contribute
git clone https://github.com/Clayton7510/RuleBook.git
cd RuleBook
./gradlew build
Add the code below to your pom.xml
<dependency>
<groupId>com.deliveredtechnologies</groupId>
<artifactId>rulebook-core</artifactId>
<version>0.9</version>
</dependency>
Add the code below to your build.gradle
compile 'com.deliveredtechnologies:rulebook-core:0.9'
[Top]
RuleBook ruleBook = RuleBookBuilder.create()
.addRule(rule -> rule.withNoSpecifiedFactType()
.then(f -> System.out.print("Hello "))
.then(f -> System.out.println("World")))
.build();
...or use 2 rules
RuleBook ruleBook = RuleBookBuilder.create()
.addRule(rule -> rule.withNoSpecifiedFactType().then(f -> System.out.print("Hello ")))
.addRule(rule -> rule.withNoSpecifiedFactType().then(f -> System.out.println("World")))
.build();
now, run it!
ruleBook.run(new FactMap());
RuleBook ruleBook = RuleBookBuilder.create()
.addRule(rule -> rule.withFactType(String.class)
.when(f -> f.containsKey("hello"))
.using("hello")
.then(System.out::print))
.addRule(rule -> rule.withFactType(String.class)
.when(f -> f.containsKey("world"))
.using("world")
.then(System.out::println))
.build();
..or it could be a single rule
RuleBook ruleBook = RuleBookBuilder.create()
.addRule(rule -> rule.withFactType(String.class)
.when(f -> f.containsKey("hello") && f.containsKey("world"))
.using("hello").then(System.out::print)
.using("world").then(System.out::println))
.build();
now, run it!
NameValueReferableMap factMap = new FactMap();
factMap.setValue("hello", "Hello ");
factMap.setValue("world", " World");
ruleBook.run(factMap);
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).
public class ApplicantBean {
private int creditScore;
private double cashOnHand;
private boolean firstTimeHomeBuyer;
public ApplicantBean(int creditScore, double cashOnHand, boolean firstTimeHomeBuyer) {
this.creditScore = creditScore;
this.cashOnHand = cashOnHand;
this.firstTimeHomeBuyer = firstTimeHomeBuyer;
}
public int getCreditScore() {
return creditScore;
}
public void setCreditScore(int creditScore) {
this.creditScore = creditScore;
}
public double getCashOnHand() {
return cashOnHand;
}
public void setCashOnHand(double cashOnHand) {
this.cashOnHand = cashOnHand;
}
public boolean isFirstTimeHomeBuyer() {
return firstTimeHomeBuyer;
}
public void setFirstTimeHomeBuyer(boolean firstTimeHomeBuyer) {
this.firstTimeHomeBuyer = firstTimeHomeBuyer;
}
}
public class HomeLoanRateRuleBook extends CoRRuleBook<Double> {
@Override
public void defineRules() {
//credit score under 600 gets a 4x rate increase
addRule(RuleBuilder.create().withFactType(ApplicantBean.class).withResultType(Double.class)
.when(facts -> facts.getOne().getCreditScore() < 600)
.then((facts, result) -> result.setValue(result.getValue() * 4))
.stop()
.build());
//credit score between 600 and 700 pays a 1 point increase
addRule(RuleBuilder.create().withFactType(ApplicantBean.class).withResultType(Double.class)
.when(facts -> facts.getOne().getCreditScore() < 700)
.then((facts, result) -> result.setValue(result.getValue() + 1))
.build());
//credit score is 700 and they have at least $25,000 cash on hand
addRule(RuleBuilder.create().withFactType(ApplicantBean.class).withResultType(Double.class)
.when(facts ->
facts.getOne().getCreditScore() >= 700 &&
facts.getOne().getCashOnHand() >= 25000)
.then((facts, result) -> result.setValue(result.getValue() - 0.25))
.build());
//first time homebuyers get 20% off their rate (except if they have a creditScore < 600)
addRule(RuleBuilder.create().withFactType(ApplicantBean.class).withResultType(Double.class)
.when(facts -> facts.getOne().isFirstTimeHomeBuyer())
.then((facts, result) -> result.setValue(result.getValue() * 0.80))
.build());
}
}
public class ExampleSolution {
public static void main(String[] args) {
RuleBook homeLoanRateRuleBook = RuleBookBuilder.create(HomeLoanRateRuleBook.class).withResultType(Double.class)
.withDefaultResult(4.5)
.build();
NameValueReferableMap facts = new FactMap();
facts.setValue("applicant", new ApplicantBean(650, 20000.0, true));
homeLoanRateRuleBook.run(facts);
homeLoanRateRuleBook.getResult().ifPresent(result -> System.out.println("Applicant qualified for the following rate: " + result));
}
}
...or nix the ApplicantBean and just use independent Facts
public class HomeLoanRateRuleBook extends CoRRuleBook<Double> {
@Override
public void defineRules() {
//credit score under 600 gets a 4x rate increase
addRule(RuleBuilder.create().withResultType(Double.class)
.when(facts -> facts.getIntVal("Credit Score") < 600)
.then((facts, result) -> result.setValue(result.getValue() * 4))
.stop()
.build());
//credit score between 600 and 700 pays a 1 point increase
addRule(RuleBuilder.create().withResultType(Double.class)
.when(facts -> facts.getIntVal("Credit Score") < 700)
.then((facts, result) -> result.setValue(result.getValue() + 1))
.build());
//credit score is 700 and they have at least $25,000 cash on hand
addRule(RuleBuilder.create().withResultType(Double.class)
.when(facts ->
facts.getIntVal("Credit Score") >= 700 &&
facts.getDblVal("Cash on Hand") >= 25000)
.then((facts, result) -> result.setValue(result.getValue() - 0.25))
.build());
//first time homebuyers get 20% off their rate (except if they have a creditScore < 600)
addRule(RuleBuilder.create().withFactType(Boolean.class).withResultType(Double.class)
.when(facts -> facts.getOne())
.then((facts, result) -> result.setValue(result.getValue() * 0.80))
.build());
}
}
public class ExampleSolution {
public static void main(String[] args) {
RuleBook homeLoanRateRuleBook = RuleBookBuilder.create(HomeLoanRateRuleBook.class).withResultType(Double.class)
.withDefaultResult(4.5)
.build();
NameValueReferableMap facts = new FactMap();
facts.setValue("Credit Score", 650);
facts.setValue("Cash on Hand", 20000);
facts.setValue("First Time Homebuyer", true);
homeLoanRateRuleBook.run(facts);
homeLoanRateRuleBook.getResult().ifPresent(result -> System.out.println("Applicant qualified for the following rate: " + result));
}
}
RuleBooks are threadsafe. However, FactMaps and other implementations of NameValueReferableMap are not. This means that a single instance of a RuleBook can be run in different threads with different Facts without unexpected results. However, using the same exact FactMap across different threads may cause unexpected results. Facts represent data for individual invocations of a RuleBook, whereas RuleBooks represent reusable sets of Rules.
[Top]
The RuleBook Java Domain Specific Language (DSL) uses the Given-When-Then format, popularized by Behavior Driven Development (BDD) and associated testing frameworks (e.g. Cucumber and Spock). Many of the ideas that went into creating the RuleBook Java 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.
Much like the Given-When-Then language for defining tests that was popularized by BDD, RuleBook uses a Given-When-Then language for defining rules. The RuleBook Given-When-Then methods have the following meanings.
- Given some Fact(s)
- When a condition evaluates to true
- Then an action is triggered
Given 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.
When methods accept a Predicate that evaluates a condition based on the Facts provided. Only one when() method can be specified per Rule.
Then methods accept a Consumer (or BiConsumer for Rules that have a Result) 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.
Using methods reduce the set of facts available to a then() method. Mutiple 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. An example of how using() works is shown above.
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.
Facts can provided to Rules using the given() method. In RuleBooks, facts are provided to Rules when the RuleBook is run. The facts available to Rules and RuleBooks are contained in a NameValueReferableMap (the base implementation being FactMap), which is a special kind of Map that allows for easy access to the underlying objects contained in facts. 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.
Facts really only have a single convenience method. Since the NameValueReferableMap (e.g. FactMap) is what is passed into when() and then() methods, most of the convenience methods around facts are made available in the Map. However, there is one convenience method included in the Fact class... the constructor. Facts consist of a name value pair. But in some cases, the name of the Fact should just be the string value of the object it contains. In those cases, a constructor with a single argument of the type of the object contained in the fact can be used.
Although the reason for NameValueReferableMaps (commonly referred to as FactMaps) is important, that doesn't mean anyone wants to chain a bunch of boiler plate calls to get to the value object contained in an underlying Fact. So, some convenience methods are there to make life easier when working with when() and then() methods.
getOne() gets the value of the Fact when only one Fact exists in the FactMap
getValue(String name) gets the value of the Fact by the name of the Fact
setValue(String name, T value) sets the Fact with the name specified to the new value
put(Fact fact) adds a Fact to the FactMap, using the Fact's name as the key for the Map
toString() toString gets the toString() method of the Fact's value when only one Fact exists
The following methods are part of the NameValueReferrableTypeConvertible interface, which is implemented by the TypeConvertibleFactMap class as a NameValueReferrable decorator. You can think of it as a decorator for FactMaps (because it's also that too!) and it's what's used to inject facts into when() and then() methods.
getStrVal(String name) gets the value of the Fact by name as a String
getDblVal(String) gets the value of the Fact by name as a Double
getIntVal(String) gets the value of the Fact by name as an Integer
getBigDeciVal(String) gets the value of the Fact by name as a BigDecimal
getBoolVal(String) gets the value of the Fact by name as a Boolean
Rules auditing can be enabled when constructing a RuleBook by specifying asAuditor() as follows.
RuleBook rulebook = RuleBookBuilder.create().asAuditor()
.addRule(rule -> rule.withName("Rule1").withNoSpecifiedFactType()
.when(facts -> true)
.then(facts -> { } ))
.addRule(rule -> rule.withName("Rule2").withNoSpecifiedFactType()
.when(facts -> false)
.then(facts -> { } )).build();
rulebook.run(new FactMap());
By using asAuditor() each rule in the RuleBook can register itself as an Auditable Rule if its name is sepcified. Each Auditable Rule added to a RuleBook Auditor has its state is recorded in the RuleBook. At the time when rules are registered as auditable in the RuleBook, their RuleStatus is NONE. After the RuleBook is run, their RuleStatus is changed to SKIPPED for all rules that fail or whose conditions do not evaluate to true. For rules whose conditions do evaluate to true and whose then() action completes successfully, their RuleStatus is changed to EXECUTED.
Retrieving the status of a rule can be done as follows.
Auditor auditor = (Auditor)rulebook;
System.out.println(auditor.getRuleStatus("Rule1")); //prints EXECUTED
System.out.println(auditor.getRuleStatus("Rule2")); //prints SKIPPED
A map of all rule names and their corresponding status can be retrieved as follows.
Map<String, RuleStatus> auditMap = auditor.getRuleStatusMap();
As of RuleBook v0.2, POJO rules are supported. Simply define your rules as annotated POJOs in a package and then use RuleBookRunner to scan the package for rules and create a RuleBook out of them. It's that simple!
package com.example.rulebook.helloworld;
import com.deliveredtechnologies.rulebook.annotations.*;
import com.deliveredtechnologies.rulebook.RuleState;
@Rule
public class HelloWorld {
@Given("hello")
private String hello;
@Given("world")
private String world;
@Result
private String helloworld;
@When
public boolean when() {
return true;
}
@Then
public RuleState then() {
helloworld = hello + " " + world;
return RuleState.BREAK;
}
}
public static void main(String args[]) {
RuleBookRunner ruleBook = new RuleBookRunner("com.example.rulebook.helloworld");
NameValueReferableMap facts = new FactMap();
facts.setValue("hello", "Hello");
facts.setValue("world", "World");
ruleBook.run(facts);
ruleBook.getResult().ifPresent(System.out::println); //prints "Hello World"
}
MegaBank changed their rate adjustment policy. They also now accept loan applications that include up to 3 applicants. If all of the applicants' credit scores are below 600, then they must pay 4x the current rate. However, if all of the applicants have a credit score of less than 700, but at least one applicant has a credit score greater than 600, then they must pay an additional point on top the rate. Also, if any of the applicants have a credit score of 700 or more and the sum of the cash on hand available from all applicants is greater than or equal to $50,000, then they get a quarter point reduction in their rate. And if at least one applicant is a first time home buyer and at least one applicant has a credit score of over 600, then they get a 20% reduction in their calculated rate after all other adjustments are made.
...using the ApplicantBean defined above
@Rule(order = 1) //order specifies the order the rule should execute in; if not specified, any order may be used
public class ApplicantNumberRule {
@Given
private List<ApplicantBean> applicants; //Annotated Lists get injected with all Facts of the declared generic type
@When
public boolean when() {
return applicants.size() > 3;
}
@Then
public RuleState then() {
return RuleState.BREAK;
}
}
@Rule(order = 2)
public class LowCreditScoreRule {
@Given
private List<ApplicantBean> applicants;
@Result
private double rate;
@When
public boolean when() {
return applicants.stream()
.allMatch(applicant -> applicant.getCreditScore() < 600);
}
@Then
public RuleState then() {
rate *= 4;
return BREAK;
}
}
@Rule(order = 3)
public class QuarterPointReductionRule {
@Given
List<ApplicantBean> applicants;
@Result
private double rate;
@When
public boolean when() {
return
applicants.stream().anyMatch(applicant -> applicant.getCreditScore() >= 700) &&
applicants.stream().map(applicant -> applicant.getCashOnHand()).reduce(0.0, Double::sum) >= 50000;
}
@Then
public void then() {
rate = rate - (rate * 0.25);
}
}
@Rule(order = 3)
public class ExtraPointRule {
@Given
List<ApplicantBean> applicants;
@Result
private double rate;
@When
public boolean when() {
return
applicants.stream().anyMatch(applicant -> applicant.getCreditScore() < 700 && applicant.getCreditScore() >= 600);
}
@Then
public void then() {
rate += 1;
}
}
@Rule(order = 4)
public class FirstTimeHomeBuyerRule {
@Given
List<ApplicantBean> applicants;
@Result
private double rate;
@When
public boolean when() {
return
applicants.stream().anyMatch(applicant -> applicant.isFirstTimeHomeBuyer());
}
@Then
public void then() {
rate = rate - (rate * 0.20);
}
}
public class ExampleSolution {
public static void main(String[] args) {
RuleBookRunner ruleBook = new RuleBookRunner("com.example.rulebook.megabank");
NameValueReferableMap<ApplicantBean> facts = new FactMap<>();
ApplicantBean applicant1 = new ApplicantBean(650, 20000, true);
ApplicantBean applicant2 = new ApplicantBean(620, 30000, true);
facts.put(new Fact<>(applicant1));
facts.put(new Fact<>(applicant2));
ruleBook.setDefaultResult(4.5);
ruleBook.run(facts);
ruleBook.getResult().ifPresent(result -> System.out.println("Applicant qualified for the following rate: " + result));
}
}
POJO Rules are annotated with @Rule at the class level. This lets the RuleBookRunner know that the class you defined is really a Rule. Facts are injected into POJO Rules using @Given annotations. The value passed into the @Given annotation is the name of the Fact given to the RuleBookRunner. The types annotated by @Given can either be the generic type of the matching Fact or the Fact type as seen above. The big difference between the two is that changes applied to immutable objects are not propigated down the rule chain if the Fact’s generic object is changed (because it would then be a new object). However, if you set the value on a Fact object, those changes will be persisted down the rule chain.
The @When annotation denotes the method that is used as the condition for executing the ‘then’ action. The method annotated with @When should accept no arguments and it should return a boolean result.
The @Then annotation denotes the action(s) of the rule that is executed if the ‘when’ condition evaluates to true. The method(s) annotated with @Then should accept no arugments and it can optionally return a RuleState result. If more than one method in a POJO rule is annotated with @Then, then all rules annotated with @Then are executed if the 'when' condition evaluates to true.
The @Result annotation denotes the result of the Rule. Of course, some Rules may not have a result. In that case, just don’t use the @Result annotation. It’s that simple.
The ‘order’ property can [optionally] be used with the @Rule annoataion to specify the orner in which POJO Rules will execute as seen above. If the order property is not specified, then Rules may execute in any order. Similarly, more than one Rule may have the same order, which would mean that the Rules with a matching order can fire in any order - order would then denote a group of rules, where the execution of the group is ordered among other rules, but the execution of the rules within that group doesn’t matter.
If the following conditions are met then the objects contained in all Facts of generic type specified are injected into a collection:
- A List, Set, Map or FactMap is annotated with a @Given annotation
- The @Given annotation on the collection has no value specified
- The generic type of the List, Set, Map (the first generic type in a Map is String - representing the name of the Fact injected) or FactMap is the same type of at least one Fact supplied to the RuleBookRunner
As of v.0.3.2, RuleBook supports annotation inheritance on POJO Rules. That means if you have a subclass, whose parent is annotated with RuleBook annotations (i.e. @Given, @When, @Then, @Result) then the subclass will inherit the parent’s annotations. @Given and @Result attributes injected in the parent, will be available to the subclass. @Then and @When methods defined in the parent will be visible in the subclass.
Auditing is built into POJO Rules via the RuleBookRunner and each POJO Rule is automatically audited. If a name is specified in the @Rule attribute, then that name is used for auditing. Otherwise, the class name of the POJO rule is used. For example, assuming that there is a POJO rule named "My Rule" that was run by the RuleBookRunner, rulebookRunner, the status of that rule's execution can be retrieved as follows.
Auditor auditor = (Auditor)rulebookRunner;
RuleStatus myRuleStatus = auditor.getRuleStatus("My Rule");
[Top]
RuleBook can be integrated with Spring to inject instances of RuleBooks that are created from POJOs in a package. RuleBooks can be specified using either the Java DSL or POJO Rules. And since RuleBook's are threadsafe, they can be used as Singeltons, Spring's default for injecting beans.
No additional configuration is needed for RuleBook to work with Spring. If you are using a current version of RuleBook then it works with Spring.
POJO Rules can be created just like they were created above without Spring.
package com.example.rulebook.helloworld;
@Rule(order = 1)
public class HelloSpringRule {
@Given("hello")
private String hello;
@Result
private String result;
@When
public boolean when() {
return hello != null;
}
@Then
public void then() {
result = hello + " ";
}
}
package com.example.rulebook.helloworld;
@Rule(order = 2)
public class WorldSpringRule {
@Given("world")
private String world;
@Result
private String result;
@When
public boolean when() {
return world != null;
}
@Then
public void then() {
result += world;
}
}
@Configuration
public class SpringConfig {
@Bean
public RuleBook ruleBook() {
RuleBook ruleBook = new RuleBookRunner("com.example.rulebook.helloworld");
return ruleBook;
}
}
@Autowired
private RuleBook ruleBook;
public void printResult() {
NameValueReferableMap<String> facts = new FactMap<>();
facts.setValue("hello", "Hello ");
facts.setValue("world", "World");
ruleBook.run(facts);
ruleBook.getResult().ifPresent(System.out::println); //prints Hello World
}
If you were using the RuleBean annotation to create Spring enabled Rules, all of that stuff still works. And there Spring enabled POJO Rules can still be configured in RuleBooks in Spring [using SpringRuleBook]. But RuleBean doesn't have an order property. So, if you need to order beans scanned using a RuleBookFactoryBean, just use the @Rule annotation like you would with regular non-Spring enabled POJO Rules. It works exactly the same way!
[Top]
Suggestions and code contributions are welcome! Please see the Developer Guidelines below.
Contributions must adhere to the following criteria:
- The forked repository must be publicly visible.
- The issues addressed in the request must be associated to an accepted issue.
- The build (i.e. ./gradlew build) must pass with no errors or warnings.
- All new and existing tests must pass.
- The code must adhere to the style guidleines icluded in the checkstyle configuration (i.e. no checkstyle errors).
- Newly introduced code must have at least 85% test coverage.
- Pull requests must be for the develop branch.
- The version number in gradle.properties should match the milestone of the issue its associated with appended with -SNAPSHOT (ex. 0.2-SNAPSHOT)
Anyone may submit an issue, which can be either an enhancement/feature request or a bug to be remediated. If a feature request or a bug is approved (most reasonable ones will be), completed and an associated pull request is submitted that adheres to the above criteria, then the pull request will be merged and the contributor will be added to the list of contributors in the following release.
[Top]