Guide to Rule Engines
Get latest articles directly in your inbox
If you’ve been working on a product or business, a recurring scenario that happens is the changing business requirements. Developers build a solution based on set of conditions. Over time these logical conditions might change due to changing business needs or other external market factors. Rule engines are a powerful way to solve such problems.
In this article, you’ll learn about the rule engines and how can this system be leveraged to solve complicated business problems in a scalable and maintainable way.
What is a Rule Engine?
You can think of rule engines as a way of business logic and conditions that helps in growing your business over time. In very layman terms, these could be a bunch of if-else conditions closely associated with business attributes that can vary and grow over time. So these are set of rules that check a condition and execute a action based on the result.
Each rule follows a basic structure
When
<Condition is true>
Then
<Take desired Action>
Let’s take an example to understand this better. Assume you’re working on a problem where you want to give relevant offers to users for the food ordering service that your business provides. (Eg. Zomato, Swiggy, Uber Eats)
Condition: When a user meets all of the following conditions:
- User has made atleast 10 orders
- Average order values is greater than Rs. 150
- User age is between 20-30
Action: Offer user a discount of 20%
This logic can be modified easily as well as enhanced further to other attributes that belong to user.
Rule engines are useful in solving business oriented logic that result in some sort of decision using number of business attributes. You could argue that can’t we embed this logic in our code itself. Yes, we could do that but rule engines give flexibility to modify conditions and add more logics. Since these conditions come from product/business, they have much more accessibily and don’t have to reach out to developers each time.
Also you have the flexibility where you want to define the rules. It could be in a JSON, text file or a web interface where anyone can easily perform CRUD operations. Another addition would be support of multiple version of rules for different set of users.
In the next section, let’s learn how the rule engine actually works.
Working of a Rule Engine
As you must have understood the rule engine typically works as like multiple if-else
conditons. So the system runs input (aka fact) through defined set of rules, based on the result of the condition it decides whether to run corresponding action or not. To define it a bit formally, there are 3 phases in one execution.
3 phases in rule engine
Match
This is the phase of pattern matching where the system matches the facts and data against the set of defined conditions (rules). Some of the commonly used algorithm for pattern matching like Rete (used in Drools), Treat, Leaps, etc. Various versions of Rete are used in modern business rule management solutions (BRMS) today. Going in-depth of Rete is out of scope for this blog (maybe another time).
Resolve
There can be scenarios of conflicts from match phase, the engine handles the order of conflicting rules. Think of this like a priority that allows engine to give more weightage to some conditons over others. Few of the algorithms used for resolving conflicts are Recency based, priority wise, refactor, etc.
Execute
In this phase, the engine executes the action corresponding to the selected rule and returns the final result.
An important property of rule engines is chaining - where the action part of one rule changes the state of the system in such a way that it alters the value of the condition part of other rules.
Implementing a Rule Engine
Let’s try to implement a rule engine for hands-on experience. We’ll be using the Grule library and implementing a fairly simple rule engine in Golang. Grule has its own Domain Specific Language and is inspired from the popular Drools library.
We’ll be implementing the offer example defined in previous section. Let’s get started by setting up a go project.
mkdir test_rule_engine
cd test_rule_engine
go mod init test_rule_engine
touch main.go
Open main.go
in your editor and add following code.
package main
import (
"fmt"
)
func main() {
fmt.Println("TODO: implementing rule engine")
}
Now that are project is ready, let’s create a rule engine service.
mkdir rule_engine
touch rule_engine/service.go
touch rule_engine/offer.go
go get -u github.com/hyperjumptech/grule-rule-engine
Let’s define our core rule engine service. Paste the following code in service.go
// rule_engine/service.go
package rule_engine
import (
"github.com/hyperjumptech/grule-rule-engine/ast"
"github.com/hyperjumptech/grule-rule-engine/builder"
"github.com/hyperjumptech/grule-rule-engine/engine"
"github.com/hyperjumptech/grule-rule-engine/pkg"
)
var knowledgeLibrary = *ast.NewKnowledgeLibrary()
// Rule input object
type RuleInput interface {
DataKey() string
}
// Rule output object
type RuleOutput interface {
DataKey() string
}
// configs associated with each rule
type RuleConfig interface {
RuleName() string
RuleInput() RuleInput
RuleOutput() RuleOutput
}
type RuleEngineSvc struct {
}
func NewRuleEngineSvc() *RuleEngineSvc {
// you could add your cloud provider here instead of keeping rule file in your code.
buildRuleEngine()
return &RuleEngineSvc{}
}
func buildRuleEngine() {
ruleBuilder := builder.NewRuleBuilder(&knowledgeLibrary)
// Read rule from file and build rules
ruleFile := pkg.NewFileResource("rules.grl")
err := ruleBuilder.BuildRuleFromResource("Rules", "0.0.1", ruleFile)
if err != nil {
panic(err)
}
}
func (svc *RuleEngineSvc) Execute(ruleConf RuleConfig) error {
// get KnowledgeBase instance to execute particular rule
knowledgeBase := knowledgeLibrary.NewKnowledgeBaseInstance("Rules", "0.0.1")
dataCtx := ast.NewDataContext()
// add input data context
err := dataCtx.Add(ruleConf.RuleInput().DataKey(), ruleConf.RuleInput())
if err != nil {
return err
}
// add output data context
err = dataCtx.Add(ruleConf.RuleOutput().DataKey(), ruleConf.RuleOutput())
if err != nil {
return err
}
// create rule engine and execute on provided data and knowledge base
ruleEngine := engine.NewGruleEngine()
err = ruleEngine.Execute(dataCtx, knowledgeBase)
if err != nil {
return err
}
return nil
}
I’ve tried to document the code in a way that helps you understand the flow. Here we define a rule engine service. The rule engine execute as explained above in theory works in three parts.
- Defining knowledge library (loading rules)
- Defining the data attributes on which the rules will evaluate
- Executing the rule engine and getting the result.
Let’s create our offer rule now that uses the interface we’ve defined in our core rule engine service.
// rule_engine/offer.go
package rule_engine
type UserOfferContext struct {
UserOfferInput *UserOfferInput
UserOfferOutput *UserOfferOutput
}
func (uoc *UserOfferContext) RuleName() string {
return "user_offers"
}
func (uoc *UserOfferContext) RuleInput() RuleInput {
return uoc.UserOfferInput
}
func (uoc *UserOfferContext) RuleOutput() RuleOutput {
return uoc.UserOfferOutput
}
// User data attributes
type UserOfferInput struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Age int `json:"age"`
Gender string `json:"gender"`
TotalOrders int `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
}
func (u *UserOfferInput) DataKey() string {
return "InputData"
}
// Offer output object
type UserOfferOutput struct {
IsOfferApplicable bool `json:"is_offer_applicable"`
}
func (u *UserOfferOutput) DataKey() string {
return "OutputData"
}
func NewUserOfferContext() *UserOfferContext {
return &UserOfferContext{
UserOfferInput: &UserOfferInput{},
UserOfferOutput: &UserOfferOutput{},
}
}
Cool, so we’ve defined our offer rule construct. But we’re missing one step, we haven’t added any rule yet. Remember rules.grl
file that we read rules from. Let’s add that.
# go to root level in project
touch rules.grl
Paste the following code in rules.grl
rule CheckOffer "Check if offer can be applied for user" salience 10 {
when
InputData.TotalOrders >= 10 && InputData.AverageOrderValue > 150 && InputData.Age >= 20 && InputData.Age <= 30
then
OutputData.IsOfferApplicable = true;
Retract("CheckOffer");
}
There are couple of things that could be refactored here further. But i’ll leave that as an exercise. Now that we have our offer rule engine ready to be used, let’s see it in action.
Go to main.go
and update it with following code.
// main.go
package main
import (
"fmt"
"testgo/rule_engine"
"github.com/hyperjumptech/grule-rule-engine/logger"
)
// can be part of user serice and a separate directory
type User struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Age int `json:"age"`
Gender string `json:"gender"`
TotalOrders int `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
}
// can be moved to offer directory
type OfferService interface {
CheckOfferForUser(user User) bool
}
type OfferServiceClient struct {
ruleEngineSvc *rule_engine.RuleEngineSvc
}
func NewOfferService(ruleEngineSvc *rule_engine.RuleEngineSvc) OfferService {
return &OfferServiceClient{
ruleEngineSvc: ruleEngineSvc,
}
}
func (svc OfferServiceClient) CheckOfferForUser(user User) bool {
offerCard := rule_engine.NewUserOfferContext()
offerCard.UserOfferInput = &rule_engine.UserOfferInput{
Name: user.Name,
Username: user.Username,
Email: user.Email,
Gender: user.Gender,
Age: user.Age,
TotalOrders: user.TotalOrders,
AverageOrderValue: user.AverageOrderValue,
}
err := svc.ruleEngineSvc.Execute(offerCard)
if err != nil {
logger.Log.Error("get user offer rule engine failed", err)
}
return offerCard.UserOfferOutput.IsOfferApplicable
}
func main() {
ruleEngineSvc := rule_engine.NewRuleEngineSvc()
offerSvc := NewOfferService(ruleEngineSvc)
userA := User{
Name: "Mohit Khare",
Username: "mkfeuhrer",
Email: "me@mohitkhare.com",
Gender: "Male",
Age: 25,
TotalOrders: 50,
AverageOrderValue: 225,
}
fmt.Println("offer validity for user A: ", offerSvc.CheckOfferForUser(userA))
userB := User{
Name: "Pranjal Sharma",
Username: "pj",
Email: "pj@abc.com",
Gender: "Male",
Age: 25,
TotalOrders: 10,
AverageOrderValue: 80,
}
fmt.Println("offer validity for user B: ", offerSvc.CheckOfferForUser(userB))
}
Just run the main file and you should see the output.
go run main.go
offer validity for user A: true
offer validity for user B: false
Tada 🎉 Congrats, you’ve just implemented your first rule engine. Test your knowledge by adding more rules. You’ll need to add a new file similar as offer.go
with your own data attributes. Don’t forget to update the rules.grl
file.
Benefits of Rule engine
- Easy understanding for non-tech folks since rules are defined in normal logical statements.
- Logic is independent from the code. This allows the same logic to be used at multiple places. (Reusability FTW)
- No changes in code are required if the business needs changes on same attributes. Frequent changes are feasible, you won’t need deployments each time.
- Central place for all your business rules instead of being distributed at multiple codebases.
When to avoid Rule Engine
Rule engines are not an alternate to your developers. In most situations building services, using AI models, proper system design is the way to go for scalable and efficient solutions. Rule engines are extra supplies that can help you in solving business problems.
Resources
Popular Libraries
Do explore articles on Golang and System Design. You’ll learn something new 💡
I hope you learned something new. Feel free to suggest improvements ✅
I share regular updates and resources on Twitter. Let’s connect!
Keep exploring 🔎 Keep learning 🚀
Liked the content? Do support :)