A complete tutorial on the Drools business rule engine
As always we share the code presented in the tutorial in a companion repository: EmailSchedulingRules.
Business rules work very well to represent the logic for certain domains. They work well because they result intuitive and close to the way of thinking of many types of domain experts. The reason for that it is that they permit to decompose a large problem in single components. In this way the user has not to deal with the orchestration of all the single rules: this is the added value provided by the business rule engine.
In this article we will discuss one specific example of application written by using business rules. We will write the rules to decide which email to send to the subscribers to a newsletter. We will see different types of rules and how we could express them using the Drools Rule Language. We will also see how to configure Drools (spoiler: it will be easy) and have the system elaborate the rules to produce a result we can use.
I think that business rules are quite interesting because they permit to look at problems in a different way. As developers we are very used to the imperative paradigm or functional paradigms. However there are other paradigms, like state machines and business rules, which are not so commonly used and which can be a much better fit in some contexts.
As always we share the code presented in the tutorial in a companion repository: EmailSchedulingRules.
What problem we are trying to solve
Let’s consider the domain of email marketing. As marketers we have an email list of persons interested in our content. Each of them may have demonstrate interest in a specific topic, read some of our articles and bought certain products. Considering all their history and preferences we want to send to them at each time the most appropriate content. This content may be either educative or proposing some deal. The problem is that there are constraints we want to consider (i.e., not sending emails on sunday or not sending emails promoting a product to someone who already bought it).
All these rules are simple per se, but the complexity derives by how they are combined and how they interact. The business rule engine will deal with that complexity for us, all we have to do is to express clearly the single rules. Rules will be expressed in the terms of our domain data so let’s focus on our domain model first.
The model of our domain
In our domain model we have:
- Emails: the single emails we want to send, described by their title and content
- Email Sequences: groups of emails that have to be sent in a specific order, for example a set of emails representing a tutorial or describing different features of a product
- Subscribers: the single subscriber to the mailing list. We will need to know which emails we sent to him, what things he is interested in, and which products he bought
- Products: the products we sell
- Purchases: the purchases subscribers have made
- Email Sending: the fact we sent or are about to send a certain email, on a certain date to a certain subscriber
- Email Scheduling: the plan for sending an email, with some additional information
The latter two elements of our domain model could seem less obvious compared to the others, but we will see in the implementation for which reasons we need them.
What our system should do
Our system should execute all the rules, using the Drools engine, and to determine for each user which email we should send on a specific day. The result could be the decision to not send any email, or to send an email, selecting one among many possible emails.
An important thing to consider is that these rules may evolve over time. The people in charge of marketing may want to try new rules and see how they affect the system. Using Drools it should be easy for them to add or remove rules or tweak the existing rules.
Let’s stress this out:
these domain experts should be able to experiment with the system and try things out quickly, without always needing help from developers.
The rules
Ok, now that we know which data do we have, we can express rules based on that model.
Let’s see some examples of rules we may want to write:
- We may have sequences of emails, for example the content of a course. They have to be sent in order
- We may have time sensitive emails that should either be sent in a specific time window or not sent at all
- We may want to avoid sending emails on specific days of the week, for example on the public holidays in the country where the subscriber is based
- We may want to send certain type of emails (for example proposing a deal) only to persons who received certain other emails (for example at least 3 informative emails on the same subject)
- We do not want to propose a deal on a certain product to a subscriber who has already bought that product
- We may want to limit the frequency we send emails to users. For example, we may decide to not send an email to a user if we have sent already one in the last 5 days
Setting up drools
Setting up drools can be very simple. We are looking into running drools in a standalone application. Depending on your context this may or may not be an acceptable solution and in some cases you will have to look into JBoss, the application server supporting Drools. However if you want to get started you can forget all of this and just configure your dependencies using Gradle (or Maven). You can figure out the boring configuration bits later, if you really have to.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | buildscript { ext.droolsVersion = "7.20.0.Final" repositories { mavenCentral() } } plugins { id "org.jetbrains.kotlin.jvm" version "1.3.21" } apply plugin: 'java' apply plugin: 'idea' group 'com.strumenta' version '0.1.1-SNAPSHOT' repositories { mavenLocal() mavenCentral() maven { } } dependencies { compile "org.kie:kie-api:${droolsVersion}" compile "org.drools:drools-compiler:${droolsVersion}" compile "org.drools:drools-core:${droolsVersion}" compile "ch.qos.logback:logback-classic:1.1.+" compile "org.slf4j:slf4j-api:1.7.+" implementation "org.jetbrains.kotlin:kotlin-stdlib" implementation "org.jetbrains.kotlin:kotlin-reflect" testImplementation "org.jetbrains.kotlin:kotlin-test" testImplementation "org.jetbrains.kotlin:kotlin-test-junit" } |
In our Gradle script we use:
- Kotlin, because Kotlin rocks!
- IDEA, because it is my favorite IDE
- Kotlin StdLib, reflect and test
- Drools
And this is how our program will be structured:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 | fun main(args: Array<String>) { try { val kbase = readKnowledgeBase(listOf( File( "rules/generic.drl" ), File( "rules/book.drl" ))) val ksession = kbase.newKieSession() // typically we want to consider today but we may decide to schedule // emails in the future or we may want to run tests using a different date val dayToConsider = LocalDate.now() loadDataIntoSession(ksession, dayToConsider) ksession.fireAllRules() showSending(ksession) } catch (t: Throwable) { t.printStackTrace() } } |
Pretty simple, pretty neat.
What we do in, details is:
- We load the rules from file. For now we just load the file
rules/generic.drl
- We setup a new session. Think of the session as the universe as seen by the rules: all data they can access is there
- We load our data model into the session
- We fire all the rules. They could change stuff in the session
- We read the modified data model (a.k.a. the session) to figure out which emails we should send today
Writing the classes for the data model
We have previously seen how our data model looks like, let’s now see the code for it.
Given we are using Kotlin it will be pretty concise and obvious.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | package com.strumenta.funnel import java. time .DayOfWeek import java. time .LocalDate import java.util.* enum class Priority { TRIVIAL, NORMAL, IMPORTANT, VITAL } data class Product(val name: String, val price: Float) data class Purchase(val product: Product, val price: Float, val date : LocalDate) data class Subscriber(val name: String, val subscriptionDate: LocalDate, val country: String, val email: String = "$name@foo.com" , val tags: List<String> = emptyList(), val purchases: List<Purchase> = emptyList(), val emailsReceived: MutableList<EmailSending> = LinkedList()) { val actualEmailsReceived get() = emailsReceived.map { it.email } fun isInSequence(emailSequence: EmailSequence) = hasReceived(emailSequence.first) && !hasReceived(emailSequence.last) fun hasReceived(email: Email) = emailsReceived.any { it.email == email } fun hasReceivedEmailsInLastDays(nDays: Long, day: LocalDate) : Boolean { return emailsReceived.any { it. date .isAfter(day.minusDays(nDays)) } } fun isOnHolidays( date : LocalDate) : Boolean { return date .dayOfWeek == DayOfWeek.SATURDAY || date .dayOfWeek == DayOfWeek.SUNDAY } fun emailReceivedWithTag(tag: String) = emailsReceived.count { tag in it.email.tags } } data class Email(val title: String, val content: String, val tags: List<String> = emptyList()) data class EmailSequence(val title: String, val emails: List<Email>, val tags: List<String> = emptyList()) { val first = emails.first() val last = emails.last() init { require(emails.isNotEmpty()) } fun next(emailsReceived: List<Email>) = emails.first { it ! in emailsReceived } } data class EmailSending(val email: Email, val subscriber: Subscriber, val date : LocalDate) { override fun equals(other: Any?): Boolean { return if (other is EmailSending) { this.email === other.email && this.subscriber === other.subscriber && this. date == other. date } else { false } } override fun hashCode(): Int { return this.email.title.hashCode() * 7 + this.subscriber.name.hashCode() * 3 + this. date .hashCode() } } data class EmailScheduling @JvmOverloads constructor(val sending: EmailSending, val priority: Priority, val timeSensitive: Boolean = false , var blocked: Boolean = false ) { val id = ++nextId companion object { private var nextId = 0 } } |
Nothing surprising here: we have the seven classes we were expecting. We have a few utility methods here and there but nothing that you cannot figure out by yourself.
Writing a rule to schedule an email
It is now time to write our first business rule. This rule will state that, given a sequence and given a person, we will schedule the first email of the sequence to be sent to a person if that person is not already receiving an email from that sequence.
01 02 03 04 05 06 07 08 09 10 11 | dialect "java" rule "Start sequence" when sequence : EmailSequence () subscriber : Subscriber ( !isInSequence(sequence) ) then EmailSending $sending = new EmailSending(sequence.getFirst(), subscriber, day); EmailScheduling $scheduling = new EmailScheduling($sending, Priority.NORMAL); insert($scheduling); end |
In the header of the rule we specify the language we are using for writing the clauses. In this tutorial we will consider only Java. There is another possible value: mvel. We will not look into that. Also, while in this example we specify the dialect on the rule it can be instead specified once for the whole file. There is even a better option: not specifing the dialect at all, as Java is the default anyway and the usage of mvel is discouraged.
The when
section determines on which elements our rule will operate. In this case we state that it will operate on an EmailSequence and a Subscriber. It will not work just on any person but only on a person for which the condition !isInSequence(sequence)
is satisfied. This condition is based on a call to the method isInsequence
that we will show below:
1 2 3 4 5 6 7 8 9 | data class Subscriber(...) { fun isInSequence(emailSequence: EmailSequence) = hasReceived(emailSequence.first) && !hasReceived(emailSequence.last) fun hasReceived(email: Email) = emailReceived.any { it.email == email } } |
Let’s now look at the then
section of our rule. In such section we specify what happens when the rule is fired. The rule will be fired when elements satisfying the when
section can be found.
In this case we will create an EmailScheduling
and add it to the session. In particular we want to send to the considered person the first email of the sequence, on the day considered. We also specify the priority of this email (NORMAL
in this case). This is necessary to decide which email effectively to send when we have more than one. Indeed we will have another rule looking at these values to decide which emails to prioritize (hint: it will be the email with the highest priority).
In general you may want to typically add things into the session in the then
clause. Alternatively you may want to modify objects which are part of the session. You could also call methods on objects which have side-effects. While the recommended approach is to limit yourself to manipulate the session you may want to add side effects for logging, for example. This is especially useful when learning Drools and trying to wrap your head around your first rules.
Writing a rule to block an email from being sent
We will see that we have two possible types of rules: rules to schedule new emails and rules to prevent scheduled emails to be sent. We have seen before how to write a rule to send an email and we will now see how to write an email to prevent an email from being sent.
In this rule we want to check if an email is scheduled to be sent to a person who has received already emails in the last three days. If this is the case we want to block that email from being sent.
1 2 3 4 5 6 7 8 9 | rule "Prevent overloading" when scheduling : EmailScheduling( sending.subscriber.hasReceivedEmailsInLastDays(3, day), !blocked ) then scheduling.setBlocked( true ); end |
In the when
section we specify that this rule will operate on an EmailScheduling
. So, every time another rule will add an EmailScheduling
this rule could be triggered to decide if we have to block it from being sent.
This rule will apply to all scheduling which are directed to subscribers who have received emails in the last 3 days. In addition to that we will check if the EmailScheduling
was not already blocked. If that is the case we will not need to apply this rule.
We use the setBlocked
method of the scheduling object to modify an element which is part of the session.
At this point we have seen the pattern we will use:
- We will create
EmailScheduling
when we think it makes sense to send an email to the user - We will check if we have reasons to block those emails. If that is the case we will set the
blocked
flag to true, effectively removing theEmailScheduling
Using a flag to mark elements to remove/invalidate/block is a common pattern used in business rules. It can sound a bit unfamiliar at the beginning but it is actually quite useful. You may think that you could just delete elements from the session, however doing so it becomes easy to create infinite loops in which you create new elements with some rules, remove them with others and keep recreating them again. The block-flag pattern avoids all of that.
The session
Rules operate on data which is part of the session. Data is typically inserted into the session during the initialization phase. Later we could have rules inserting more data into the session, potentially triggering other rules.
This is how we could populate the session with some example data:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | fun loadDataIntoSession(ksession: KieSession, dayToConsider: LocalDate) { val products = listOf( Product( "My book" , 20.0f), Product( "Video course" , 100.0f), Product( "Consulting package" , 500.0f) ) val persons = listOf( Subscriber( "Mario" , LocalDate.of(2019, Month.JANUARY, 1), "Italy" ), Subscriber( "Amelie" , LocalDate.of(2019, Month.FEBRUARY, 1), "France" ), Subscriber( "Bernd" , LocalDate.of(2019, Month.APRIL, 18), "Germany" ), Subscriber( "Eric" , LocalDate.of(2018, Month.OCTOBER, 1), "USA" ), Subscriber( "Albert" , LocalDate.of(2016, Month.OCTOBER, 12), "USA" ) ) val sequences = listOf( EmailSequence( "Present book" , listOf( Email( "Present book 1" , "Here is the book..." , tags= listOf( "book_explanation" )), Email( "Present book 2" , "Here is the book..." , tags= listOf( "book_explanation" )), Email( "Present book 3" , "Here is the book..." , tags= listOf( "book_explanation" )) )), EmailSequence( "Present course" , listOf( Email( "Present course 1" , "Here is the course..." , tags= listOf( "course_explanation" )), Email( "Present course 2" , "Here is the course..." , tags= listOf( "course_explanation" )), Email( "Present course 3" , "Here is the course..." , tags= listOf( "course_explanation" )) )) ) ksession.insert(Email( "Question to user" , "Do you..." )) ksession.insert(Email( "Interesting topic A" , "Do you..." )) ksession.insert(Email( "Interesting topic B" , "Do you..." )) ksession.insert(Email( "Suggest book" , "I wrote a book..." , tags= listOf( "book_offer" ))) ksession.insert(Email( "Suggest course" , "I wrote a course..." , tags= listOf( "course_offer" ))) ksession.insert(Email( "Suggest consulting" , "I offer consulting..." , tags= listOf( "consulting_offer" ))) ksession.setGlobal( "day" , dayToConsider) ksession.insert(products) persons.forEach { ksession.insert(it) } sequences.forEach { ksession.insert(it) } } |
Of course in a real application we would access some database or some form of storage to retrieve the data to be used to populate the session.
Global objects
In rules we will not only access elements which are part of the session but also global objects.
Global objects are inserted in the session using setGlobal
. We have seen an example in loadDataIntoSession
:
1 2 3 4 5 | fun loadDataIntoSession(ksession: StatefulKnowledgeSession, dayToConsider: LocalDate) : EmailScheduler { ... ksession.setGlobal( "day" , dayToConsider) ... } |
In the rules we declare the globals:
01 02 03 04 05 06 07 08 09 10 | package com.strumenta.funnellang import com.strumenta.funnel.Email; import com.strumenta.funnel.EmailSequence; import com.strumenta.funnel.EmailScheduling import com.strumenta.funnel.EmailScheduler; import com.strumenta.funnel.Person import java. time .LocalDate; global LocalDate day; |
At this point we can refer to these globals in all rules. In our example we use day
value to know which day we are considering for the scheduling. Typically it would be tomorrow, as we would like to do the scheduling one day in advance. However for testing reasons we could use any day we want. Or we may want to use days in the future for simulation purposes.
Global should not be abused. Personally I like to use them to specify configuration parameters. Others prefer to insert this data into the session and this is the recommended approach. The reason why I use globals (carefully and rarely) is because I like to distinguish between the data I am working on (stored in the session) and the configuration (for that I use globals).
Writing the generic rules
Let’s now see the whole set of generic rules that we have written. By generic rules we mean rules that could be applied to all email schedulings we want to do. To complement these rules we may have others for specific products or topics we are promoting.
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 | package com.strumenta.funnellang import com.strumenta.funnel.Email; import com.strumenta.funnel.EmailSequence; import com.strumenta.funnel.EmailScheduling import com.strumenta.funnel.EmailSending; import com.strumenta.funnel.Subscriber import java. time .LocalDate; import com.strumenta.funnel.Priority global LocalDate day; rule "Continue sequence" when sequence : EmailSequence () subscriber : Subscriber ( isInSequence(sequence) ) then EmailSending $sending = new EmailSending(sequence.next(subscriber.getActualEmailsReceived()), subscriber, day); EmailScheduling $scheduling = new EmailScheduling($sending, Priority.IMPORTANT, true ); insert($scheduling); end rule "Start sequence" when sequence : EmailSequence () subscriber : Subscriber ( !isInSequence(sequence) ) then EmailSending $sending = new EmailSending(sequence.getFirst(), subscriber, day); EmailScheduling $scheduling = new EmailScheduling($sending, Priority.NORMAL); insert($scheduling); end rule "Prevent overloading" when scheduling : EmailScheduling( sending.subscriber.hasReceivedEmailsInLastDays(3, day), !blocked ) then scheduling.setBlocked( true ); end rule "Block on holidays" when scheduling : EmailScheduling( sending.subscriber.isOnHolidays(scheduling.sending. date ), !blocked ) then scheduling.setBlocked( true ); end rule "Precedence to time sensitive emails" when scheduling1 : EmailScheduling( timeSensitive == true , !blocked ) scheduling2 : EmailScheduling( this != scheduling1, !blocked, sending.subscriber == scheduling1.sending.subscriber, sending. date == scheduling1.sending. date , timeSensitive == false ) then scheduling2.setBlocked( true ); end rule "Precedence to higher priority emails" when scheduling1 : EmailScheduling( !blocked ) scheduling2 : EmailScheduling( this != scheduling1, !blocked, sending.subscriber == scheduling1.sending.subscriber, sending. date == scheduling1.sending. date , timeSensitive == scheduling1.timeSensitive, priority < scheduling1.priority) then scheduling2.setBlocked( true ); end rule "Limit to one email per day" when scheduling1 : EmailScheduling( blocked == false ) scheduling2 : EmailScheduling( this != scheduling1, blocked == false , sending.subscriber == scheduling1.sending.subscriber, sending. date == scheduling1.sending. date , timeSensitive == scheduling1.timeSensitive, priority == scheduling1.priority, id > scheduling1. id ) then scheduling2.setBlocked( true ); end rule "Never resend same email" when scheduling : EmailScheduling( !blocked ) subscriber : Subscriber( this == scheduling.sending.subscriber, hasReceived(scheduling.sending.email) ) then scheduling.setBlocked( true ); end |
Let’s examine all these rules, one by one:
- Continue sequence: if someone started receiving an email sequence and he did not receive the last email yet, then he should get the next email in the sequence
- Start sequence: if someone did not yet receive the first email of a sequence he should. Note that technically speaking this rule alone would cause everyone who has finished a sequence to immediately restart it. This does not happen because of the Never resend same email rule. However you could decide to rewrite this rule to explicitly forbidding someone who has already received a certain sequence to be re-inserted in it.
- Prevent overloading: if someone has received an email in the last three days then we should block any email scheduling directed to that person
- Block on holidays: if someone is on holidays we should not send emails to them
- Precedence to time sensitive emails: given a pair of email schedulings directed to the same person on the same date, if only one of the two is time sensitive we should block the other
- Precedence to higher priority emails: given a pair of email schedulings directed to the same person on the same date being both time sensitive or both not time sensitive, we should block the one with lower importance
- Limit to one email per day: we should not schedule to send more than one email per day to the same person. If this happens we have to pick one somehow. We use the internal ID to discriminate between the two
- Never resend same email: if someone has already received a certain email he should not receive it again in the future
Writing the rules specific to the book emails
Our marketing experts may want to write specific rules for specific products or topics. Let’s assume they want to create a set of emails to promote and sell a book. We could write these rules in a separate file, perhaps maintained by the marketing expert in charge of selling that book.
To write rules regarding a specific topic we will take advantage of tags, a mechanism that will give us a certain amount of flexibility. Let’s see the rules we can write:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | package com.strumenta.funnellang import com.strumenta.funnel.Subscriber; import com.strumenta.funnel.EmailScheduling; import java. time .DayOfWeek; rule "Send book offer only after at least 3 book presentation emails" when subscriber : Subscriber ( emailReceivedWithTag( "book_explanation" ) < 3 ) scheduling : EmailScheduling( !blocked, sending.subscriber == subscriber, sending.email.tags contains "book_offer" ) then scheduling.setBlocked( true ); end rule "Block book offers on monday" when scheduling : EmailScheduling( !blocked, sending. date .dayOfWeek == DayOfWeek.MONDAY, sending.email.tags contains "book_offer" ) then scheduling.setBlocked( true ); end rule "Block book offers for people who bought" when subscriber : Subscriber ( tags contains "book_bought" ) scheduling : EmailScheduling( !blocked, sending.subscriber == subscriber, sending.email.tags contains "book_offer" ) then scheduling.setBlocked( true ); end |
Let’s examine our rules:
- Send book offer only after at least 3 book presentation emails: we want to block any email selling the book if the subscriber did not receive at least three emails explaining the content of the book
- Block book offers on monday: we want to block book offers to be sent on monday, for example because we have seen that subscribers are less inclined to buy on that day of the week
- Block book offers for people who bought: we do not want to propose a deal on the book to subscribers who already bought it
Testing the business rules
There are different types of tests we may want to write to verify that our rules behave as expected. On one side of the spectrum we may want to have tests that verify complex scenarios and check for unexpected interactions between rules. These tests will run considering complex data sets and the whole set of business rules. On the other side of the spectrum we may want to write simple unit tests to verify single rules. We will see an example of these unit tests, but most of what we will see could be adapted to test the whole set of rules instead of single rules.
What do we want to do in our unit tests?
- We setup the knowledge base
- We want to load some data into the session
- We want to run the rule business engine, enabling just the one business rule we want to test
- We want to verify that the resulting email schedulings are the one expected
To satisfy point 1 we load all the files containing our rules and we verify there are no issues.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | private fun prepareKnowledgeBase(files: List<File>): InternalKnowledgeBase { val kbuilder = KnowledgeBuilderFactory.newKnowledgeBuilder() files.forEach { kbuilder.add(ResourceFactory.newFileResource(it), ResourceType.DRL) } val errors = kbuilder.errors if (errors.size > 0) { for (error in errors) { System.err.println(error) } throw IllegalArgumentException( "Could not parse knowledge." ) } val kbase = KnowledgeBaseFactory.newKnowledgeBase() kbase.addPackages(kbuilder.knowledgePackages) return kbase } |
How do we load data into the session? We do that by loading some default data and then giving the possibility to change this data a little bit in each test. In the following piece of code you will see that we can pass a function as the dataTransformer parameter. Such function can operate on the data before we load them into the session. This is our hook to tweak the data in each test.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | fun loadDataIntoSession(ksession: KieSession, dayToConsider: LocalDate, dataTransformer: ((Subscriber, Email) -> Unit)? = null) { val amelie = Subscriber( "Amelie" , LocalDate.of(2019, Month.FEBRUARY, 1), "France" ) val bookSeqEmail1 = Email( "Present book 1" , "Here is the book..." , tags= listOf( "book_explanation" )) val products = listOf( Product( "My book" , 20.0f), Product( "Video course" , 100.0f), Product( "Consulting package" , 500.0f) ) val persons = listOf(amelie) val sequences = listOf( EmailSequence( "Present book" , listOf( bookSeqEmail1, Email( "Present book 2" , "Here is the book..." , tags= listOf( "book_explanation" )), Email( "Present book 3" , "Here is the book..." , tags= listOf( "book_explanation" )) )) ) dataTransformer?.invoke(amelie, bookSeqEmail1) ksession.insert(Email( "Question to user" , "Do you..." )) ksession.insert(Email( "Interesting topic A" , "Do you..." )) ksession.insert(Email( "Interesting topic B" , "Do you..." )) ksession.insert(Email( "Suggest book" , "I wrote a book..." , tags= listOf( "book_offer" ))) ksession.insert(Email( "Suggest course" , "I wrote a course..." , tags= listOf( "course_offer" ))) ksession.insert(Email( "Suggest consulting" , "I offer consulting..." , tags= listOf( "consulting_offer" ))) ksession.setGlobal( "day" , dayToConsider) ksession.insert(products) persons.forEach { ksession.insert(it) } sequences.forEach { ksession.insert(it) } } |
We achieve point 3 by specifying a filter on the rules to be executed:
1 | ksession.fireAllRules { match -> match.rule.name in rulesToKeep } |
At this point we can simply check the results.
Once this infrastructure has been put in place the tests we will write will look like this:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 | @ test fun startSequencePositiveCase() { val schedulings = setupSessionAndFireRules( LocalDate.of(2019, Month.MARCH, 17), listOf( "Start sequence" )) assertEquals(1, schedulings.size) assertNotNull(schedulings. find { it.sending.email.title == "Present book 1" && it.sending.subscriber.name == "Amelie" }) } @ test fun startSequenceWhenFirstEmailReceived() { val schedulings = setupSessionAndFireRules( LocalDate.of(2019, Month.MARCH, 17), listOf( "Start sequence" )) { amelie, bookSeqEmail1 -> amelie.emailsReceived.add( EmailSending(bookSeqEmail1, amelie, LocalDate.of(2018, Month.NOVEMBER, 12))) } assertEquals(0, schedulings.size) } |
In the first test we expect Amelie to receive the first email of a sequence, given she did not receive yet. In the second test instead we set in the session athat Amelie already received the first email of the sequence, so we expect it to not receive it again (no email schedulings expected at all).
This is the whole code of the test class:
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | package com.strumenta.funnel import org.drools.core.impl.InternalKnowledgeBase import org.drools.core.impl.KnowledgeBaseFactory import org.kie.api.io.ResourceType import org.kie.api.runtime.KieSession import org.kie.internal.builder.KnowledgeBuilderFactory import org.kie.internal.io.ResourceFactory import java.io.File import java. time .LocalDate import java. time .Month import kotlin. test .assertEquals import kotlin. test .assertNotNull import org.junit.Test as test class GenericRulesTest { private fun prepareKnowledgeBase(files: List<File>): InternalKnowledgeBase { val kbuilder = KnowledgeBuilderFactory.newKnowledgeBuilder() files.forEach { kbuilder.add(ResourceFactory.newFileResource(it), ResourceType.DRL) } val errors = kbuilder.errors if (errors.size > 0) { for (error in errors) { System.err.println(error) } throw IllegalArgumentException( "Could not parse knowledge." ) } val kbase = KnowledgeBaseFactory.newKnowledgeBase() kbase.addPackages(kbuilder.knowledgePackages) return kbase } fun loadDataIntoSession(ksession: KieSession, dayToConsider: LocalDate, dataTransformer: ((Subscriber, Email) -> Unit)? = null) { val amelie = Subscriber( "Amelie" , LocalDate.of(2019, Month.FEBRUARY, 1), "France" ) val bookSeqEmail1 = Email( "Present book 1" , "Here is the book..." , tags= listOf( "book_explanation" )) val products = listOf( Product( "My book" , 20.0f), Product( "Video course" , 100.0f), Product( "Consulting package" , 500.0f) ) val persons = listOf(amelie) val sequences = listOf( EmailSequence( "Present book" , listOf( bookSeqEmail1, Email( "Present book 2" , "Here is the book..." , tags= listOf( "book_explanation" )), Email( "Present book 3" , "Here is the book..." , tags= listOf( "book_explanation" )) )) ) dataTransformer?.invoke(amelie, bookSeqEmail1) ksession.insert(Email( "Question to user" , "Do you..." )) ksession.insert(Email( "Interesting topic A" , "Do you..." )) ksession.insert(Email( "Interesting topic B" , "Do you..." )) ksession.insert(Email( "Suggest book" , "I wrote a book..." , tags= listOf( "book_offer" ))) ksession.insert(Email( "Suggest course" , "I wrote a course..." , tags= listOf( "course_offer" ))) ksession.insert(Email( "Suggest consulting" , "I offer consulting..." , tags= listOf( "consulting_offer" ))) ksession.setGlobal( "day" , dayToConsider) ksession.insert(products) persons.forEach { ksession.insert(it) } sequences.forEach { ksession.insert(it) } } private fun setupSessionAndFireRules(dayToConsider: LocalDate, rulesToKeep: List<String>, dataTransformer: ((Subscriber, Email) -> Unit)? = null) : List<EmailScheduling> { val kbase = prepareKnowledgeBase(listOf(File( "rules/generic.drl" ))) val ksession = kbase.newKieSession() loadDataIntoSession(ksession, dayToConsider, dataTransformer) ksession.fireAllRules { match -> match.rule.name in rulesToKeep } return ksession.selectScheduling(dayToConsider) } @ test fun startSequencePositiveCase() { val schedulings = setupSessionAndFireRules( LocalDate.of(2019, Month.MARCH, 17), listOf( "Start sequence" )) assertEquals(1, schedulings.size) assertNotNull(schedulings. find { it.sending.email.title == "Present book 1" && it.sending.subscriber.name == "Amelie" }) } @ test fun startSequenceWhenFirstEmailReceived() { val schedulings = setupSessionAndFireRules( LocalDate.of(2019, Month.MARCH, 17), listOf( "Start sequence" )) { amelie, bookSeqEmail1 -> amelie.emailsReceived.add( EmailSending(bookSeqEmail1, amelie, LocalDate.of(2018, Month.NOVEMBER, 12))) } assertEquals(0, schedulings.size) } } |
Conclusions
Marketers should be able to experiment and try out their strategies and ideas easily: for example, do they want to create a special offer just to be sent at 20 subscribers per day? Do they want to send special offers to subscribers in a certain country? Do they want to consider the birthday or the national holiday of a subscriber to send him a special message? Our domain experts, marketers in this case, should have a tool to pour these ideas into the system and see them applied. Thanks to business rules they could be able to implement most of them by themselves. Not having to go through developers or other “gate keepers” could mean having the freedom to experiment, to try things and in the end to make the business profit.
There are things to consider: giving the possibility to write business rules could not be enough. To make our domain experts confident in the rules they write we should give them the possibility to play with them and try them out in a safe environment: a testing or simulation mechanism should be put in place. In this way they could try things and see if they translated correctly into code the idea that they had in mind.
Of course business rules are much easier to write compared to typical code. This is the case because they have a predefined format. In this way we can pick an existing rule and tune a little bit. Still, it requires some training for the domain experts to get used to them. They need to develop the ability to formalize their thoughts and this could be easy or hard depending on their background. For example, for marketers it could be doable while for other professionals it could require more exercise. What we could do to simplify their life and make domain experts more productive is to put a Domain Specific Language in front of our business rules.
By creating a simple DSL we could make things easier for our marketers. This DSL would permit to manipulate the domain model we have seen (subscribers, emails, etc) and perform the two actions marketers are interested into: scheduling and blocking emails. We could provide a simple editor, with auto-completion and error checking, and integrate a testing and simulation environment in it. In this scenario marketers would be fully independent and able to design and verify their rules quickly and with very limited supported needed.
Acknowledgments
Mario Fusco (a Java champion) and Luca Molteni, both working on Drools at RedHat, were so very kind to review the article and suggest significant improvements. I am extremely thankful to them.
Thank you!
Published on Java Code Geeks with permission by Federico Tomassetti, partner at our JCG program. See the original article here: A complete tutorial on the Drools business rule engine Opinions expressed by Java Code Geeks contributors are their own. |