How to build Rule Engines with Golang

Golang Tutorial

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 a 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 business logic and conditions that help in growing your business over time. In very layman’s 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 rules that check a condition and execute an 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 your business provides. (Eg. Zomato, Swiggy, Uber Eats)

Condition: When a user meets all of the following conditions:

  • User has made at least 10 orders
  • Average order values is greater than Rs. 150
  • User age is between 20-30

Action: Offer the 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 results in some sort of decision using a 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 logic. Since these conditions come from product/business, they have much more accessible 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 web interface where anyone can easily perform CRUD operations. Another addition would be the support of multiple versions of rules for a different set of users.

In the next section, let’s learn how the rule engine works.


Working of a Rule Engine

As you must have understood, the rule engine works like multiple if-else conditons. So the system runs input (aka fact) through a defined set of rules, based on the result of the condition it decides whether to run the 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 pattern matching phase where the system matches the facts and data against the set of defined conditions (rules). Some commonly used algorithms 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 the match phase, the engine handles the order of conflicting rules. Think of this like a priority that allows the engine to give more weightage to some conditions 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 system’s state in such a way that it alters the value of the condition part of other rules.


Implementing a Rule Engine with Golang

Let’s try to implement a rule engine for hands-on experience. We’ll use the Grule library and implement 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 the 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 the following code.

package main

import (
	"fmt"
)

func main() {
  fmt.Println("TODO: implementing rule engine")
}

Now that our 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.