Build your own rules engine 3: Condition and outcome templates
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 have rewritten our rules in terms of goals rather than conditions:
- Free items = 1 sandwich for every 4 on the original check.
- Discount =
- 75 cents for every combination of sandwich, chips, and drink on the original check, plus
- 5 dollars for every 10 prior visits of 5 dollars or more that have not already been rewarded.
- Coupons = 1 half-off coupon for every ice cream.
Now we need to give the customer a way to express rules in those terms. One way to do this is to identify all of the properties mentioned in the rules and provide them to the user:
- Check
- Item
- Quantity
- Total
- Visit
- Coupon
- etc.
This approach works for programmers, but it does not work for business users. The customer is not you. programmer can build a rule out of tiny parts. But the business user does not think this way. If you force them to think this way, they will get it wrong.
For example, while working on an eCommerce system, I asked a business user what properties they want in their rules engine. I explained that we could use an operator to compare a property against a number. One of the properties they asked for was “Any Item”. “How would you use that?”, I asked. “Like this: if Any Item > $50.00, then print a coupon.” This makes perfect sense to a business user. But this property is nonsense to a programmer.
Templates
Rather than attacking the problem from a programmer’s perspective, attack it from the business user’s perspective. Business users can’t build a rule out of small pieces. So we have to build rules for them. We can do so by creating templates.
This is why we want specific examples of rules. For each example of a rule, start by turning every constant into a parameter:
- Free items = m promotionalItemId for every n triggerItemId on the original check.
- Discount =
- m dollars for every combination of {itemIds} on the original check, plus
- m dollars for every n prior visits of minimum dollars or more that have not already been rewarded.
- Coupons = 1 couponType coupon for every n triggerItemId.
There may be some trivial parameters that you need to add. The example rule only dealt with one trigger item for a coupon, but you can imagine that they might want to specify the number. A coefficient of 1 tends to get forgotten.
We now have a set of outcome templates and condition templates. The outcomes are goal-oriented, and the conditions satisfy the outcomes. Conditions in this context are not just true or false; they govern the behavior of the outcome. That is why conditions must be expressed within the scope of an outcome, not the other way around.
Interface per outcome template
We can finally start putting rules into code. The first thing we will want to do is express each of our outcome templates as an interface. In a condition-oriented rules engine, an outcome would be an action: add a free item, print a coupon, etc. But in a goal-oriented rules engine, an outcome is a query. Given a check, what are my free items. What are my coupons. These are the interfaces that represent our three outcome templates:
public interface IFreeItemsRule { IEnumerable<Item> GetFreeItems(Check check); } public interface IDiscountRule { IEnumerable<Discount> GetDiscounts(Check check); } public interface ICouponRule { IEnumerable<Coupon> GetCoupons(Check check); }
Each interface is implemented by a rule. We don’t know at this point what kinds of rules might implement these interfaces, but we do know precisely what the outcome of those rules can be. A rule can yield free items, or it can yield coupons. But it can’t do both. The rules are goal-oriented.
Class per condition template
Once we have outcome templates expressed as interfaces, we can write our condition templates as classes that implement those interfaces. Remember that these conditions are not simply true or false. They influence the outcome.
public class CombinationDiscountRule : IDiscountRule { private string _description; private decimal _amount; private List<ItemId> _triggerItems; public CombinationDiscountRule(string description, decimal amount, List<ItemId> triggerItems) { _description = description; _amount = amount; _triggerItems = triggerItems; } public IEnumerable<Discount> GetDiscounts(Check check) { // Get the minimum quantity of all trigger items. int comboCount = _triggerItems .Select(triggerItemId => check .Items .Where(item => item.ItemId == triggerItemId) .Sum(item => item.Quantity)) .Min(); for (int i = 0; i < comboCount; i++) yield return new Discount(_description, _amount); } }
To define a rule, all the business user has to do is fill in the parameters.
To our programmer sensibilities, it seems that we are putting too much behavior into one class. Not only is it looking for combinations, but it is also creating discounts. But to a business user, it’s one rule. They don’t build things from pieces. They need a template.
Still, there is some refactoring that we can do. We’ll tackle that next in Condition composition.