Build your own rules engine 5: The rule model
Download the source code and follow along.
This post is part of a series:
- The customer is not you.
- If-then is backwards.
- Condition and outcome templates.
- Condition composition.
- The rule model.
We’ve defined interfaces for the various types of rules in our point-of-sale system.
public interface IFreeItemsRule { IEnumerable<Item> GetFreeItems(Check check); } public interface IDiscountRule { IEnumerable<Discount> GetDiscounts(Check check); } public interface ICouponRule { IEnumerable<Coupon> GetCoupons(Check check); }
These interfaces assume that we can execute rules based entirely on the check. While this is true for the majority of our example rules, this is not true for the frequent diner discount.
- m dollars for every n prior visits of minimum dollars or more that have not already been rewarded.
This rule needs access to the history of visits. So instead of providing a simple Check, we’ll provide a richer object. Inspired by the Model-View-ViewModel pattern, I’ve decided to call this rich object a Rule Model.
The RuleModel class
The Rule Model has access not only to the Check that the user is currently editing, but also to a repository. It can query the repository for any historical information that a rule may require.
public class RuleModel { private Check _check; private IRepository _repository; public RuleModel(Check check, IRepository repository) { _check = check; _repository = repository; } public Check Check { get { return _check; } } }
For example, our frequent diner rule needs access to the history of visits.
public int GetPriorVisitCount( FrequentDiner frequentDiner, decimal minimumAmountPerVisit) { int totalPriorVisitCount = _repository .GetChecks(check => check.FrequentDiner == frequentDiner && check.TotalBeforeDiscounts > minimumAmountPerVisit) .Count(); int discountedPriorVisitCount = _repository .GetChecks(check => check.FrequentDiner == frequentDiner) .SelectMany(check => check.Discounts) .OfType<PriorVisitDiscount>() .Sum(discount => discount.VisitsCounted); return totalPriorVisitCount - discountedPriorVisitCount; }
The RuleModel can satisfy the demands of the frequent diner discount rule. It can count the number of prior visits by this frequent diner, and it can count the number of prior visits that have already been rewarded. The difference is needed to determine whether the frequent diner has earned a new discount.
Let history decide
When the frequent diner earns a discount, we record that reward on the new check. We use a derivative of the Discount class that captures the number of visits counted.
public class PriorVisitDiscount : Discount { private int _visitsCounted; public PriorVisitDiscount( string description, decimal amount, int visitsCounted) : base(description, amount) { _visitsCounted = visitsCounted; } public int VisitsCounted { get { return _visitsCounted; } } }
By capturing this information in the discount itself, we ensure that our action is atomic. The very act of awarding the discount deducts the visits required to earn it. Refer back to the GetPriorVisitCount method to see how this historical information is used.
By capturing data historically rather than keeping a separate tally, we can greatly reduce the likelihood of a defect. And if there ever is a question about discounts awarded (or not awarded), the entire history is there to be audited.
Rules are independent
Download the source code and examine the tests. You’ll find that each of the example rules that we originally promised the customer are working as expected. You will also find that they are working independently of one another. As we discussed at the beginning of this series, independence is important for understanding the behavior of the rule engine. If the rules depend upon one another, it becomes difficult to explain why they behave the way that they do.
An example of independence can be found if we combine a free item rule with a coupon rule.
- Buy 5 burgers, get 2 ice creams free.
- Buy an ice cream and get a coupon for half off your next visit.
Take a look at the IndependentRulesTest to see how we express these rules:
[TestInitialize] public void Initialize() { InitializeService( new List<IDiscountRule>(), new List<IFreeItemsRule>() { new BuyOneGetOneRule(5, BurgerId, 2, IceCreamId) }, new List<ICouponRule>() { new TriggerItemCouponRule(1, IceCreamId, "Half off") } ); }
You might worry that the customer would be awarded free coupons for the two free ice creams. But fear not:
[TestMethod] public void DontAwardACouponForFreeIceCream() { // Buy five burgers. Check check = new Check(); check.AddItem(BurgerId).Quantity = 5; Check outputCheck = _service.ExecuteRules(check); // No coupons awarded. Pred.Assert(outputCheck.Coupons, Is.Empty<Coupon>()); }
As you can see, no coupons are awarded when the customer buys 5 burgers. This is because the rules are independent of one another. Each rule is evaluated against the original check. Rules are never permitted to modify the check. No rule depends upon the results of another.
Conclusion
The next time your customer asks for customizable business logic, consider what we’ve gone over here. You could use a general purpose off the shelf rules engine, but that is probably asking too much of your customer. They would be forced to think like a programmer. Instead, you can provide specific goal-directed rules that they can parameterize. Use set unions to compose rules, and be sure that each rule runs independently of the others. The result will be a simple, powerful, and extensible custom rules engine that will empower, rather than confuse, your customer.
January 5th, 2010 at 6:51 pm
Great series. Please can you publish your code?
Thanks
January 6th, 2010 at 4:46 pm
The link to the source code is buried in the article. I've added it to the top. You can find the source at http://adventuresinsoftware.com/blog/wp-content/uploads/2009/12/byorulesengine.zip.