Tuesday, January 25, 2011

Business Rules Implementation Using Windows Workflow 4.0

Recently I have been working on a project for a back-end service providing product information for a telecom company. The pricing of a product or products is relying on a number of business rules. e.g. a cell-phone's price and its monthly charge can vary based on the data/voice plan as well as those value-added features. A discount or multiple discounts could be applied if certain criteria are matched. In addition, complicated validation rules are applied for those products.

Windows Workflow Rules Engine, a free out-of-box feature from Windows Workflow Foundation, plays a center role in our business rules implementation. We construct our own business entities and relationship. On top of those business objects we build our customized rule authoring UI. Those rule-related entities are converted to CodeDom expression at run-time, and then a set of Workflow RuleSets are created based on those CodeDom expression. For each service call, products will be passed to the Workflow Rule Engine as an input, and rules execution would update their metadata or properties if a rule or some rules are satisfied.

Following code sample demos how all this works:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.CodeDom;
using System.Workflow.Activities.Rules;
using System.Workflow.ComponentModel.Compiler;

#region Business Object Models
class Product
{
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
}

class ShoppingCart
{
public List<Product> SelectedProducts { get; set; }
public string CouponCode { get; set; }
public decimal TotalAmount { get { return SelectedProducts.Sum(prod => prod.Price); } }
internal decimal Discount { get; set; }

public ShoppingCart()
{
SelectedProducts = new List<Product>();
Discount = 0;
}

// Check total amount for a given category
public bool TotalCategoryAmountGreaterThan(decimal price, string category)
{
decimal totalPriceForCategory = 0;
foreach (var prod in SelectedProducts)
{
if (prod.Category == category)
totalPriceForCategory += prod.Price;
}

return totalPriceForCategory >= price;
}
}
#endregion

#region Rule-related Object Models
/// <summary>
/// Apply a discount if having the promotion code
/// </summary>
class CouponPromotion
{
public string Name { get; set; }
public string PromotionCode { get; set; }
public decimal Discount { get; set; }
}

/// <summary>
/// Apply a discount when total amount for a given category greater than a value
/// </summary>
class BundlePromotion
{
public string Name { get; set; }
public string ProductCategory { get; set; }
public decimal TotalAmountThreshold { get; set; }
public decimal Discount { get; set; }
}
#endregion

#region Helper class to create rules based on Rule-related objects
class WFRuleBuilder
{
// Create a Rule for a CouponPromotion
public static Rule GetCouponPromotionRule(CouponPromotion couponPromo)
{
CodePrimitiveExpression couponCode = new CodePrimitiveExpression(couponPromo.PromotionCode);
CodePrimitiveExpression couponDiscount = new CodePrimitiveExpression(couponPromo.Discount);

CodePropertyReferenceExpression couponPropertyRef =
new CodePropertyReferenceExpression(new CodeThisReferenceExpression(), "CouponCode");
CodePropertyReferenceExpression discountPropertyRef =
new CodePropertyReferenceExpression(new CodeThisReferenceExpression(), "Discount");

// Create a binary equality expression to compare the property value
CodeBinaryOperatorExpression couponConditionExpression =
new CodeBinaryOperatorExpression(couponCode, CodeBinaryOperatorType.ValueEquality, couponPropertyRef);

// Create a binary addition expression to increase the discount
CodeBinaryOperatorExpression discountExpression =
new CodeBinaryOperatorExpression(discountPropertyRef, CodeBinaryOperatorType.Add, couponDiscount);
CodeAssignStatement discountActionExpression =
new CodeAssignStatement(discountPropertyRef, discountExpression);

Rule couponRule = new Rule(couponPromo.Name);
couponRule.Condition = new RuleExpressionCondition(couponConditionExpression);
couponRule.ThenActions.Add(new RuleStatementAction(discountActionExpression));

return couponRule;
}

// Create a Rule for a BundlePromotion
public static Rule BuildBundlePromotionRule(BundlePromotion bundlePromo)
{
CodePrimitiveExpression priceThreshold = new CodePrimitiveExpression(bundlePromo.TotalAmountThreshold);
CodePrimitiveExpression productCategory = new CodePrimitiveExpression(bundlePromo.ProductCategory);
CodePrimitiveExpression discount = new CodePrimitiveExpression(bundlePromo.Discount);
CodePrimitiveExpression[] parameters = new CodePrimitiveExpression[] { priceThreshold, productCategory };

CodePropertyReferenceExpression discountPropertyRef =
new CodePropertyReferenceExpression(new CodeThisReferenceExpression(), "Discount");

// Create a method invocation expression to check the totalValue at runtime
CodeMethodInvokeExpression methodEvaluationExpression =
new CodeMethodInvokeExpression(new CodeThisReferenceExpression(), "TotalCategoryAmountGreaterThan", parameters);

// Create a binary addition expression to increase the discount
CodeBinaryOperatorExpression discountExpression =
new CodeBinaryOperatorExpression(discountPropertyRef, CodeBinaryOperatorType.Add, discount);
CodeAssignStatement discountActionExpression = new CodeAssignStatement(discountPropertyRef, discountExpression);

Rule priceThresholdRule = new Rule(bundlePromo.Name);
priceThresholdRule.Condition = new RuleExpressionCondition(methodEvaluationExpression);
priceThresholdRule.ThenActions.Add(new RuleStatementAction(discountActionExpression));

return priceThresholdRule;
}
}
#endregion

class Program
{
static void Main(string[] args)
{
// Mock 5 products in shopping cart:
// [{"Product1":{Price:$100, Category:"Category1"}}, {"Product2":{Price: $200, Category:"Category1"}}, ...]
ShoppingCart mockShoppingCart = new ShoppingCart();
Enumerable.Range(1, 5).ToList().ForEach(i => mockShoppingCart.SelectedProducts.Add(
new Product() { Name = "Product" + i.ToString(), Price = 100 * i, Category = "Category1" }));
mockShoppingCart.CouponCode = "123456789";
Console.Write("\nShopping Cart before rules execution -- ");
Console.WriteLine("Total : ${0}\tDiscount : ${1}", mockShoppingCart.TotalAmount, mockShoppingCart.Discount);

// Mock some rules
RuleSet rules = MockRules();

// Execute Rules
RuleValidation validation = new RuleValidation(typeof(ShoppingCart), null);
if (rules.Validate(validation))
{
RuleEngine engine = new RuleEngine(rules, validation);
engine.Execute(mockShoppingCart);
}
else // Validation failed, print errors
{
StringBuilder errors = new StringBuilder();
foreach (ValidationError validationError in validation.Errors)
{
errors.Append(validationError.ErrorText);
}
Console.WriteLine("Validation errors:{0}", errors);
}

Console.Write("\nShopping Cart after rules execution -- ");
Console.WriteLine("Total : ${0}\tDiscount : ${1}", mockShoppingCart.TotalAmount, mockShoppingCart.Discount);

Console.Read();
}

// Mock one CouponPromotion rule and PackageTotalPricePromotion rule for testing
private static RuleSet MockRules()
{
// Mock coupon rule: $10 discount if the user has a coupon code of "123456789"
CouponPromotion couponPromo = new CouponPromotion()
{
Name = "2011 new year coupon",
PromotionCode = "123456789",
Discount = 10
};

// Mock total price rule: $100 discount if purchased more than $1000 for ProductCategory of "Category1"
BundlePromotion bundlePromo1 = new BundlePromotion()
{
Name = "Bundle Promotion for Category1",
ProductCategory = "Category1",
TotalAmountThreshold = 1000,
Discount = 100
};

// Mock total price rule: $200 discount if purchased more than $1000 for ProductCategory of "Category2"
BundlePromotion bundlePromo2 = new BundlePromotion()
{
Name = "Bundle Promotion for Category2",
ProductCategory = "Category2",
TotalAmountThreshold = 1000,
Discount = 200
};

// Create a RuleSet to contain above two rules
RuleSet promotionRules = new RuleSet("Promotion Rules");
promotionRules.Rules.Add(WFRuleBuilder.GetCouponPromotionRule(couponPromo));
promotionRules.Rules.Add(WFRuleBuilder.BuildBundlePromotionRule(bundlePromo1));
promotionRules.Rules.Add(WFRuleBuilder.BuildBundlePromotionRule(bundlePromo2));

// Print Rule information
Console.WriteLine("\nRules defined in the RuleSet:");
promotionRules.Rules.ToList().ForEach(rule =>
{
Console.WriteLine("\"{0}\" RuleCondition: \n{1}", rule.Name, rule.Condition);
Console.WriteLine("\"{0}\" RuleAction: \n{1}", rule.Name, rule.ThenActions[0]);
});

return promotionRules;
}

}
The console app shows that a $10 coupon discount and a $100 bundle discount has been applied:



We have a simple Product class with Name, Category and Price properties. A ShoppingCart sent to a rule engine contains a list of products and an optional coupon code. Rule engine will update ShoppingCart's discount property if applicable.

Two promotion types have been abstracted in the demo code. CouponPromotion simply applies a discount if the promotion code is matched. BundlePromotion is setup the way that a discount is applied if the total amount for certain category is greater than a number. For such rules we are easily build a nice authoring UI without using the out-of-box RuleSetDialog. RuleSetDialog is powerful but not that user friendly.

GetCouponPromotionRule and BuildBundlePromotionRule are two helper methods to build the Windows Workflow Rule objects based on the defined CouponPromotion and BundlePromotion. CodeDom expression trees are constructed to setup the WF condition and actions. CodeDom expression is an other topic and I am not going to expand it in detail here. Basically you need to build an expression for each statement but join them as an expression tree.

For CouponPromotion the logical condition is comparing the ShoppingCart's CouponCode property with the PromotionCode property defined in CouponPromotion by using CodeBinaryOperatorType.ValueEquality. The CodeBinaryOperatorExpression returns true if the values are the same. The action for a satisfied CouponPromotion condition is a property assignment using CodeAssignStatement to increase the ShoppingCart's Discount value. For BundlePromotion, the condition expression is a little different. The TotalCategoryAmountGreaterThan method defined in ShoppingCart class is used to determine the Workflow Rule's condition. This is achieved by CodeMethodInvokeExpression.

In our test code we create a shoppoing cart with five mock products and three promotions:
ShoppingCart1:
Product1: Name: Product1, Price: $100, Category: "Category1"
Product2: Name: Product2, Price: $200, Category: "Category1"
Product3: Name: Product3, Price: $300, Category: "Category1"
Product4: Name: Product4, Price: $400, Category: "Category1"
Product5: Name: Product5, Price: $500, Category: "Category1"
CouponCode: "123456789"
Promotion1: CouponPromotion $10 discount for coupon code "123456789"
Promotion2: BundlePromotion $100 discount if total amount for "Category1" is more than $1000
Promotion3: BundlePromotion $200 discount if total amount for "Category2" is more than $1000

We can see that both Promotion1 and Promotion2's conditions are matched, but Promotion3's condition is not. So the result shows that total $110 discount has been applied.

How about the performance? Let's do a simple test by increasing the ShoppoingCart's product number to 200, faking 200 CouponPromotion rules and 200 BundlePromotion rules:
    static void Main(string[] args)
{
ShoppingCart mockShoppingCart = new ShoppingCart();
Enumerable.Range(1, 200).ToList().ForEach(i => mockShoppingCart.SelectedProducts.Add(
new Product() { Name = "Product" + i.ToString(), Price = 100 * (i%10),
Category = "Category" + (i%10).ToString() }));
mockShoppingCart.CouponCode = "100";
Console.Write("\nShopping Cart before rules execution -- ");
Console.WriteLine("Total : ${0}\tDiscount : ${1}", mockShoppingCart.TotalAmount, mockShoppingCart.Discount);

RuleSet promotionRules = new RuleSet("Promotion Rules");
Enumerable.Range(1, 200).ToList().ForEach(i =>
{
string sequence = i.ToString(), category = "Category" + (i % 10).ToString();
CouponPromotion couponPromo = new CouponPromotion()
{ Name = "Coupon" + sequence, PromotionCode = sequence, Discount = 10 };
BundlePromotion bundlePromo = new BundlePromotion()
{ Name = "Bundle" + sequence, ProductCategory = category, TotalAmountThreshold = 1000, Discount = 100 };
promotionRules.Rules.Add(WFRuleBuilder.GetCouponPromotionRule(couponPromo));
promotionRules.Rules.Add(WFRuleBuilder.BuildBundlePromotionRule(bundlePromo));
});
Console.WriteLine("\nRules number: {0}", promotionRules.Rules.Count);

Stopwatch watch = new Stopwatch();
RuleValidation validation = new RuleValidation(typeof(ShoppingCart), null);
if (promotionRules.Validate(validation))
{
RuleEngine engine = new RuleEngine(promotionRules, validation);
watch.Start();
engine.Execute(mockShoppingCart);
watch.Stop();
}
else // Validation failed, print errors
{
StringBuilder errors = new StringBuilder();
foreach (ValidationError validationError in validation.Errors)
{
errors.Append(validationError.ErrorText);
}
Console.WriteLine("Validation errors:{0}", errors);
}

Console.WriteLine("Execution time in milliseconds: {0}", watch.ElapsedMilliseconds);

Console.Write("\nShopping Cart after rules execution -- ");
Console.WriteLine("Total : ${0}\tDiscount : ${1}", mockShoppingCart.TotalAmount, mockShoppingCart.Discount);
Console.Read();
}
The test result in an i5-CPU 8G memory Windows 2008 machine:



It shows the execution time for 400 rules is around 20-millisecond. In reality the rules would be more complex than our simple test case. But in general we can see that the speed of Windows Workflow Rules Engine is okay.