Table of contents
Business rules are part of any large enterprise applications. In one of my previous projects, a lot of business rules needed to be evaluated to perform some specific business actions. So I developed a rule engine which is easy to use, can be configured easily, and is scalable. The advantages of this rule engine approach are:
- Once the basic tables and classes are ready, adding new rules require no development/little development effort. Most of the time, adding new rules would be just inserting a few entries in the corresponding tables.
- As introducing/changing rules involves populating tables, new rules can be added or existing rules can be modified without much development effort; a business analyst can do the population of tables to match the business requirement. This can be independent of releases.
- This approach is scalable to handle most complex combinations of business rules.
This rule engine approach is explained using a simple WPF application showing different possible combinations of rules. The sample application (CustomRulesMVVM) is developed using VS2010 and Entity Framework 4. Please download VS2010 Express edition and SQL Server 2005/2008 Express from Microsoft.
Below, I have shown the steps to connect to SQL Server from Visual Studio for those who are not familiar with it. In Visual Studio, click on "Connect to Database" in "Tools". The following window will be shown. Specify the data source and the database file name in the screen.
Now, click on Test Connection, and you should get the following message if SQL Express is installed in your machine.
Open Database Explorer and right click on the "Tables" folder and click "Add Query". A query pane will be opened. Paste "Table Scripts" (from the DB Scripts folder) in the query window and execute it. This will create and populate the tables required for the sample application. Click on the "Stored Procedures" folder and click on "Add New Stored Procedure". Paste the Stored Procedure given in "Stored Procedure" (in the DB Scripts folder) in the sample application. Select the query, and right click and Run Selection. The Stored Procedure will now be executed. After successfully executing all the scripts, the Database Explorer should have the following tables and procedures:
The image below shows the relations between the different tables:
As shown in the entity diagram, each row in Rule table corresponds to a single business rule. Sample values in the table are given below.
Data in Rule table
Data in Source table
Here, RuleID is the Primary Key of the table. ValueRHS (Right Hand Side) is the value against which we are doing the comparison. Operator is the type of comparison we are making. Source comes from the Source table which denotes the object source used to retrieve the value at the LHS (Left Hand Side) of the equation. CodeLHS is used to evaluate the value from Source. RuleDescription gives the description of the rule.
E.g.: For RuleID=1, we are checking if country is USA. So, ValueRHS is 'USA', Operator is '= '. Source identifies which object has the value of country (here, 1 corresponds to Country as given in the Source table), and CountryName is the property in the Country object which has the name of the country entered by the user in the application.
RuleGroup has a combination of two or more rules:
Data in RuleGroup table
Here, you can see RuleGroupID = 2 is a combination of two rules (RuleIDs 2 and 3 in the Rule table). It is checking if City is 'NY' AND Temperature < 20. The relation between Rule and RuleGroup is given in the RuleGroupRelations table. This is the joining table for Rule and RuleGroup.
Data in RuleGroupRelations table
RuleSeqNum gives the sequence in which individual rules are joined to form a RuleGroup.
Similarly, RuleGroupGrouping has a combination of two or more RuleGroups.
Data in RuleGroupGrouping table
Here, RuleGroupGroupingID = 3 is the combination of two RuleGroups. It checks if (City is LA AND Temperature > 30) OR (State is CA AND Temperature is < 15). The relation between the RuleGroup and RuleGroupGrouping table is given in RuleGroupGroupingRelations.
Data in RuleGroupGroupingRelations table
Here, RuleGroups 3 and 4 are joined using the OR operator to form RuleGroupGroupingID = 3, whereas RuleGroup 3 is the combination of RuleIDs 4 and 5, and RuleGroup 4 is the combination of RuleIDs 6 and 7.
For a given RuleGroupGroupingID, the corresponding rules can be found by joining these tables.
In the sample application, we have three tabs to get data from the user to evaluate rules. In the first tab, the user can enter a CountryName and check whether it is USA. If it is USA, the result text will be displayed on the screen as shown below:
From our table design, we know that RuleGroupGroupingID = 1 will check whether CountryName entered is USA. So we have one more table, CountryDetails, joining RuleGroupGroupingID with screenID and ResultText to be displayed.
Data in CountryDetails table
Now all the tables are ready and populated. We have a Stored Procedure which will select the set of rules for a given screenID, as shown below:
CREATE PROCEDURE dbo.SelectRules
@screenID INT
AS
SELECT CD.ScreenID, CD.ResultText, R.CodeLHS, R.Operator,
R.ValueRHS, R.Source, RGR.RuleGroupID,
RGR.RuleJoinOperator, RGR.RuleSeqNum,
RGGR.RuleGroupJoinOperator, RGGR.RuleGroupSeqNum,
R.RuleDescription FROM CountryDetails AS CD INNER JOIN
RulesGroupGroupingRelations AS RGGR ON
CD.RuleGroupGroupingID = RGGR.RuleGroupGroupingID
INNER JOIN RulesGroupRelations AS RGR ON
RGGR.RuleGroupID = RGR.RuleGroupID INNER JOIN
Rules AS R ON R.RuleID = RGR.RuleID WHERE CD.ScreenID = @screenID
ORDER BY RGGR.RuleGroupGroupingID, RGGR.RuleGroupSeqNum,
RGR.RuleGroupID, RGR.RuleSeqNum
For @ScreenID = 3, the result set of the Stored Procedure is shown below:
From this result, we can see that the first two rules to check city = LA and Temperature > 30 (RuleGroupID = 3) are joined using the AND operator, and last two rules to check state = CA and Temperature < 15 (RuleGroupID = 4) are joined using the AND operator. These two RuleGroups are joined using the OR operator. RuleSeqNum and RuleGroupSeqNum give the order in which Rules and RuleGroups are joined. Source = 2 denotes that the value of CityName and Temperature will be checked in the City object, and Source = 3 denotes that the value of StateName and Temperature will be checked in the State object. If this combination of rules are evaluated to True, the ResultText "Hi...City is LA and it is hot OR State is CA and it is cool" will be displayed on the screen as shown below:
If the entered values are wrong, no result text will be displayed. Here, Temperature = 22, but our rule is: state = CA and Temperature < 20. So it is not showing any results.
Similar to the database design, in our C# code, we have CustomRule
, CustomRules
, and CustomRuleGroups
to have mapping entries in the Rule, RuleGroup, and RuleGroupGrouping tables. All these classes implement the IRule
interface.
interface IRule
{
bool IsSelected { get; set; }
string SelectedItem { get; set; }
bool Eval(Dictionary<string, > collection);
}
IsSelected
will have the result of the rules evaluation. SelectedItem
is the result when the rules are evaluated to true
. Eval()
evaluates Rule/Rules or RuleGroups.
The CustomRule
class is given below.
class CustomRule:IRule
{
#region Members
private const string COUNTRY = "COUNTRY";
private const string CITY = "CITY";
private const string STATE = "STATE";
Country country;
City city;
State state;
#endregion
#region Properties
public bool IsSelected { get; set; }
public string SelectedItem { get; set; }
public string CodeLHS { get; set; }
public string Operator { get; set; }
public string ValueRHS { get; set; }
public int Source { get; set; }
public string RuleJoinOperator { get; set; }
public int? RuleSeqNum { get; set; }
public string RuleDescription { get; set; }
#endregion
#region Public methods
public bool Eval(Dictionary<string, > collection)
{
}
}
CustomRules
and CustomRulesGroup
implement CollectionBase
in addition to IRule
.
class CustomRules : CollectionBase, IRule
{
#region Properties
public bool IsSelected { get; set; }
public string SelectedItem { get; set; }
public string RuleGroupJoinOperator { get; set; }
public int? RuleGroupSeqNum { get; set; }
#endregion
#region CollectionBase methods
public void Add(CustomRule item)
{
this.List.Add(item);
}
public void Remove(CustomRule item)
{
this.List.Remove(item);
}
public CustomRule Item(int index)
{
return this.List[index] as CustomRule;
}
#endregion
}
The CustomRules
class has RuleGroupJoinOperator
and RuleGroupSeqNum
, as these are properties of RuleGroup.
class CustomRulesGroups:CollectionBase,IRule
{
#region Properties
public bool IsSelected { get; set; }
public string SelectedItem { get; set; }
#endregion
#region CollectionBase methods
public void Add(CustomRules item)
{
this.List.Add(item);
}
public void Remove(CustomRules item)
{
this.List.Remove(item);
}
public CustomRules Item(int index)
{
return this.List[index] as CustomRules;
}
#endregion
}
In the Converter
class, we have methods to populate CustomRule
, CustomRules
, and CustomRulesGroups
. The BuildCustomRule
extension method is given below:
public static CustomRule BuildCustomRule(this SelectRules_Result entity)
{
CustomRule custRule = new CustomRule();
if (null != entity)
{
custRule.SelectedItem = entity.ResultText;
custRule.CodeLHS = entity.CodeLHS;
custRule.Operator = entity.Operator;
custRule.ValueRHS = entity.ValueRHS;
custRule.Source = entity.Source;
custRule.RuleJoinOperator = entity.RuleJoinOperator;
custRule.RuleSeqNum = entity.RuleSeqNum;
custRule.RuleDescription = entity.RuleDescription;
}
return custRule;
}
CustomRules
will be populated by calling each customrule.BuildCustomRule()
method.
public static CustomRules BuildCustomRules(IList<selectrules_result /> entities)
{
CustomRules custRules = new CustomRules();
if (null != entities)
{
foreach (SelectRules_Result item in entities)
{
custRules.Add(item.BuildCustomRule());
custRules.SelectedItem = item.ResultText;
custRules.RuleGroupJoinOperator = item.RuleGroupJoinOperator;
custRules.RuleGroupSeqNum = item.RuleGroupSeqNum;
}
}
return custRules;
}
Similarly, CustomRulesGroup
will be populated by calling each group's BuildCustomRules()
method.
public static IRule BuildCustomRulesGroups(IList<selectrules_result > entities,
string resultText)
{
CustomRulesGroups customRulesGroups = new CustomRulesGroups();
if (null != entities)
{
var list = (from r in entities
select r.RuleGroupID).Distinct();
foreach (int groupID in list.ToList ())
{
var listRules = from rule in entities
where rule.RuleGroupID == groupID
select rule;
customRulesGroups.Add (Converter.BuildCustomRules(listRules.ToList()));
customRulesGroups.SelectedItem = resultText;
}
}
return customRulesGroups;
}
Evaluation of CustomRulesGroup
is done by calling the Eval()
method of CustomRules
which will in turn call the Eval()
method of CustomRule
.
public bool Eval(Dictionary<string,> collection)
{
return EvaluateCustomRulesGroups(collection);
}
private bool EvaluateCustomRulesGroups(Dictionary<string, > collection)
{
if (null != this.Item(0).RuleGroupJoinOperator)
{
switch (this.Item(0).RuleGroupJoinOperator)
{
case AND:
if (this.Item(0).Eval(collection) && this.Item(1).Eval(collection))
this.IsSelected = true;
break;
case OR:
if (this.Item(0).Eval(collection) || this.Item(1).Eval(collection))
this.IsSelected = true;
break;
default:
this.IsSelected = false;
break;
}
if (this.Count > 2)
{
for (int i = 1; i < this.Count - 2; i++)
{
switch (this.Item(i).RuleGroupJoinOperator)
{
case AND:
if (this.IsSelected && this.Item(i).Eval(collection))
this.IsSelected = true;
else
this.IsSelected = false;
break;
case OR:
if (this.IsSelected || this.Item(i).Eval(collection))
this.IsSelected = true;
else
this.IsSelected = false;
break;
default:
this.IsSelected = false;
break;
}
}
}
}
else
{
this.IsSelected = this.Item(0).Eval(collection);
}
return this.IsSelected;
}
Eval()
for CustomRules
is:
public bool Eval(Dictionary<string, > collection)
{
return EvaluateCustomRules(collection);
}
#endregion
#region Private Methods
private bool EvaluateCustomRules(Dictionary<string, > collection)
{
if (null != this.Item(0).RuleJoinOperator)
{
switch (this.Item(0).RuleJoinOperator)
{
case AND:
if (this.Item(0).Eval(collection) && this.Item(1).Eval(collection))
this.IsSelected = true;
break;
case OR:
if (this.Item(0).Eval(collection) || this.Item(1).Eval(collection))
this.IsSelected = true;
break;
default:
this.IsSelected = false;
break;
}
if (this.Count > 2)
{
for (int i = 1; i < this.Count - 2; i++)
{
switch (this.Item(i).RuleJoinOperator)
{
case AND:
if (this.IsSelected && this.Item(i).Eval(collection))
this.IsSelected = true;
else
this.IsSelected = false;
break;
case OR:
if (this.IsSelected || this.Item(i).Eval(collection))
this.IsSelected = true;
else
this.IsSelected = false;
break;
default:
this.IsSelected = false;
break;
}
}
}
}
else
{
this.IsSelected = this.Item(0).Eval(collection);
}
return this.IsSelected;
}
The evaluation of each Rule happens in the Eval()
method:
public bool Eval(Dictionary<string, > collection)
{
if (collection.ContainsKey(COUNTRY))
country = (Country)collection[COUNTRY];
if (collection.ContainsKey(CITY))
city = (City)collection[CITY];
if (collection.ContainsKey(STATE))
state = (State)collection[STATE];
switch (this.Source)
{
case 1 :
this.IsSelected = RuleHelper.EvaluatePropertyValue(this,country);
break;
case 2:
this.IsSelected = RuleHelper.EvaluatePropertyValue(this, city);
break;
case 3:
this.IsSelected = RuleHelper.EvaluatePropertyValue(this, state);
break;
default:
this.IsSelected = false;
break;
}
return this.IsSelected;
}
EvalutePropertyValue()
in the RuleHelper
class is given below:
public static bool EvaluatePropertyValue(CustomRule customRule,object objSource)
{
object valueLHS = GetValueFromObject(objSource, customRule.CodeLHS);
return ComapareValues(Convert.ToString(valueLHS),
customRule.ValueRHS, customRule.Operator);
}
private static object GetValueFromObject(object objSource, object propertyName)
{
if (null != objSource)
{
PropertyInfo[] properties = objSource.GetType().GetProperties();
foreach (PropertyInfo info in properties)
{
if (info.Name.ToUpper() == propertyName.ToString().ToUpper())
{
return info.GetValue(objSource, null);
}
}
}
return null;
}
The CompareValues
method in RuleHelper
does the actual comparison of ValueLHS
and ValueRHS
. This method will compare any two types of values based on the operator code passed.
private static bool ComapareValues(string valueLHS,string valueRHS,
string operatorCode)
{
bool isBool, isNumeric, isDateTime;
bool boolValue1, boolValue2 = false;
double numericValue1, numericValue2 = 0.0;
DateTime dateValue1, dateValue2 = DateTime.Today;
try
{
isBool = Boolean.TryParse(valueLHS, out boolValue1) &&
Boolean.TryParse(valueRHS, out boolValue2);
isNumeric = Double.TryParse(valueLHS, out numericValue1) &&
Double.TryParse(valueRHS, out numericValue2);
isDateTime = DateTime.TryParse(valueLHS, out dateValue1) &&
DateTime.TryParse(valueRHS, out dateValue2);
if (operatorCode == EQUAL &&
(!isBool && !isNumeric && !isDateTime))
return valueLHS.Equals(valueRHS,
StringComparison.InvariantCultureIgnoreCase);
else if (operatorCode == EQUAL && isNumeric)
return numericValue1 == numericValue2;
else if (operatorCode == GREATER_THAN && isNumeric)
return numericValue1 > numericValue2;
else if (operatorCode == LESSER_THAN && isNumeric)
return numericValue1 < numericValue2;
else if (operatorCode == EQUAL && isBool)
return boolValue1 == boolValue2;
else if (operatorCode == EQUAL && isDateTime)
return dateValue1.Equals(dateValue2);
else
return false;
}
catch (Exception)
{
return false;
}
}
If other evaluation conditions (like >=, <= for numbers/datetime etc.) are required, the corresponding conditions need to be added in this method.
The following steps describe how to add a Stored Procedure in Entity Framework:
On clicking Next, a popup will ask whether the database files need to be added to the project. Select Yes if you want the .mdf files in the solution.
This window lets you select your database objects:
The following window will be displayed with Model Explorer:
Open Model Browser and select Function Imports:
Right click on "Function Imports" and click on "Add Function Import". Give a Function Import Name and select the Stored Procedure name. Inside the Stored Procedure Column Information section, click on "Get Column Information". This will populate the return type of the Stored Procedure in the window. Now, click on "Create New Complex Type". This will create a new complex return type SelectRules_Result.
Now, SelectRules
can be called as follows:
IList<selectrules_result /> rulesCollection = new List<selectrules_result />();
using (CustomRuleEntities context = new CustomRuleEntities())
{
var rules = context.SelectRules(screenID);
foreach (var item in rules)
{
rulesCollection.Add(item);
}
}
The sample application has a Tab control with three tab items. As shown previously, the first tab item checks if the CountryName entered is USA. This is a single Rule evaluation. The Second tab item checks if the entered CityName is NY AND Temperature is < 20, as shown below. This is a RuleGroup evaluation where two rules are joined with an AND condition.
The third tab contains RuleGroups where two RuleGroups are joined with an OR condition.
The MVVM pattern is used where separate ViewModel classes are created for each tab item. The View is a single XAML file, MainWindow.xaml. The structure of the application is shown below. Models have objects to hold data from each screen, and classes to hold custom rules.
Controls in each tab item are bound to properties in the corresponding ViewModels. So MainWindow.xaml.cs contains only the constructor:
using System.Collections.Generic;
using System.Windows;
namespace CustomRulesMVVM
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
}
Instead of the click event of buttons, a Command is used. The Command in CountryViewModel
is as shown below. For Command, RelayCommand explained here is used.
public ICommand SearchCountry
{
get
{
if (null == this._searchCountry)
{
this._searchCountry =
new RelayCommand(param => this.SearchCountryDetails());
}
return this._searchCountry;
}
}
#endregion
#region Private Methods
private void SearchCountryDetails()
{
this.SearchCountryResult = string.Empty;
this.PopulateCustomRules(1);
collection[COUNTRY] = _country;
this.SearchCountryResult = this.EvaluateCustomRules(collection);
}
#endregion
This Rule Engine approach can be used for a different business scenario by replacing the CountryDetails table with a suitable table based on the business requirements. The rest of the tables will remain the same. Similarly, populating and evaluating rules will be the same for any business scenario. So this approach can be used to evaluate any number of business rule combinations. Happy coding...