Build your own rules engine 4: Condition composition
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.
So far, we have defined rules by using concrete examples. We turned them around to express them in terms of the goal. And we coded one interface per outcome, one class per condition.
With this code, we could easily write a system that applies one rule.
public class PointOfSaleService { private IDiscountRule _discountRule; public PointOfSaleService(IDiscountRule discountRule) { _discountRule = discountRule; } public CheckWithDiscounts AddDiscounts(Check check) { return CheckWithDiscounts.Create(check, _discountRule.GetDiscounts(check)); } }
Notice that the rule does not modify the original check. If it did, we could not safely execute the rule again. Instead, it returns a new object: a check with discounts. The check is independent. The check with discounts is dependent – upon the check and upon the rules.
Sorry, make that rule (singular). We inject the rule into the service. There is probably some code elsewhere that loads the rule from the database, so that the user can modify it.
But the user probably wants to write several different rules. They don’t want just one combo; they want a whole menu of them. Furthermore, they also want discounts for prior visits. We need to apply all of these discounts.
Union of collections
The IDiscountRule is coded to return a collection of Discount objects. We did this because one rule could apply multiple discounts. We can take advantage of that fact to compose these rules. All we need to do is take the union of their outcomes.
public class PointOfSaleService { private List<IDiscountRule> _discountRules; public PointOfSaleService(List<IDiscountRule> discountRules) { _discountRules = discountRules; } public CheckWithDiscounts AddDiscounts(Check check) { return CheckWithDiscounts.Create( check, _discountRules.SelectMany( rule => rule.GetDiscounts(check))); } }
The SelectMany extension method takes the union of several different sets. Each set is generated by one element of the source collection. You can think of it as flattening a tree. In this case, the parent of the tree is the rule, and the child is the list of discounts that the rule generates.
Isolation
We’ve taken care to remove cycles from our rules before we started. Remember the Big Spender rule? Give a 5% discount on checks totaling more than $500. To eliminate cycles, we need to consider the total of the original check, not the total after discounts have been applied.
Consider another way that we could have written the method:
public class PointOfSaleService { private List<IDiscountRule> _discountRules; public PointOfSaleService(List<IDiscountRule> discountRules) { _discountRules = discountRules; } public void AddDiscounts(Check check) { // WARNING!! // This is the wrong way to apply multiple rules. foreach (IDiscountRule rule in _discountRules) check.AddDiscounts(rule.GetDiscounts(check)); } }
In this version, we are modifying the original check after each rule. The next rule sees the modifications. So what would happen if our Big Spender purchased a combo? If the combo brought the total of the check down below $500, he would no longer get his 5% discount. That is, unless we moved the Big Spender discount to the front of the list.
This is the mistake that so many rules engines have made. Each rule has the opportunity to change the state of the system. Each rule can see the effects of the rules that came before. Now, all of a sudden, order matters. The user has to prioritize rules. The rules engine has to run complex solutions like the Rete algorithm in order to iterate to a solution. We can no longer develop rules in isolation without running the risk of affecting the other rules.
Preserve original state
A point-of-sale system is interactive. The user can add items to a check and see what discounts are applied. They can then add or subtract other items and see how the discount is affected.
Imagine how we would accomplish that interactivity if we modified the check to apply discounts. Add a combo, and the rules engine adds a discount. Then remove one of the items. The rules engine would have to recognize what just happened and remove the discount in response. Can you imagine the code it would take to make this work? Can you imagine all of the corner cases? Can you imagine how much testing you would need to do to be sure it was right?
It is much easier to instead preserve the original state of the system. Our correct rules engine (second listing) does just that. Our incorrect rules engine (third listing) destroys the original check. It is nearly impossible to get back to the original.
In the correct rules engine, any changes that the user makes are applied to the original Check. The object that appears on the user’s screen, however, is the resulting CheckWithDiscounts. It is a very simple matter of re-running the rules after every change to update the discounts. If the user removes a trigger item, the rule will no longer fire, and the discount will be “removed”.
The only way to safely apply multiple rules is to allow each rule to see only the original data. Rules should not modify data. Instead, the engine should gather their outcomes and build a new state from that union. Even then, that new state does not replace the original state. When the original state changes, you can simply re-run the rules to evaluate their new effect.
To finish it all off, we provide all of the context that the rules need in the Rule Model.