This is the tenth and sadly the last post in the “Design Patterns” series. The previous one was about the memento pattern and can be found here.
With this post, we will say our final goodbyes to the Design Pattern Series. However, I'm not saying adieu, only see you soon. No worries, programming content won’t stop coming, we will simply focus on different c#/.net topics.
In this instalment, I want to talk about the rules engine pattern. This is another pattern that makes our code more organised by splitting if blocks into more civilised chunks.
You can find the presented code in this post. The projects in the solution correspond with pattern names.
The Goal
As always, we are going to create a short console application. The goal of the application is to present how the rules engine pattern helps us organise our code. Again, we are going to use two examples: the old, if-based approach, and a new one, where we will split code into well-organised pieces.
The program is to decide what kind of discount customers should get. In both the new and old way, the customer is going to be represented by the simple class.
1 2 3 4 5 6 7
public class Customer { public string Name { get; set; } public DateTime? ClientSince { get; set; } public DateTime BirthDate { get; set; } public bool IsBloodDonor { get; set; } }
The final discount value is to be decided based on several factors. Let’s check out the old version.
The Old Way
The old way is made by one class. OldDiscountCalculator is a class that holds all the logic, and yes, it looks quite simple. However, believe it or not, such a simple approach has its downsides. Before I get all grumpy, let’s look at the code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
public class OldDiscountCalculator { public decimal GetDiscount(Customer customer) { decimal result = 0; if (customer.ClientSince == null) result = .2m; else if (customer.IsBloodDonor) result = .15m; else if (customer.ClientSince.Value < DateTime.Now.AddYears(-10)) result = .1m; else if (customer.ClientSince.Value < DateTime.Now.AddYears(-5)) result = .05m; if (customer.BirthDate.Day == DateTime.Now.Day && customer.BirthDate.Month == DateTime.Now.Month) result += .10m; return result; } }
Code has one method – GetDiscount. No surprise there, the goal of this method is to get a discount. To do so, this method goes over a set of ifs. And that bothers me. Mainly because it is one big piece of… code. Plus, there is no indication describing what the ifs are responsible for. We don’t know if the order is important and why. In my opinion, this code can be written much better. Let’s do that now.
The New Way
First, let’s consider how we want to modify the code. The pattern we are working on is the caller rules engine. So, we are going to need to create some rules, I guess.
What do we know about the discount rule so far? It determines the discount value based on customer data. Sometimes it sets the value of the discount, and sometimes it modifies it. This is all we need to create an interface and a set of rules. Let's start with an interface.
1 2 3 4
public interface IDiscountRule { public decimal GetDiscount(Customer customer, decimal discount); }
As we can see, the interface matches the description of the rules criteria. It returns the value of a discount and needs customer and discount data to do so. Easy-peasy.
Now that interface is out of the way, we need to focus on the rules. Based on the “old” code, we can extract four of them:
- the new customer rule giving us a 20% discount,
- the blood donor rule with a 15% discount,
- the loyal customer rules, which, depending on time will give us a 10% or 5% discount,
- and finally, the birthday rule, adding 10% to the discount a customer is entitled to.
Now we will try to split it into code based on the interface.
1 2 3 4 5 6 7 8 9 10 11
public class FirstTransactionDiscountRule: IDiscountRule { public decimal GetDiscount(Customer customer, decimal discount) { decimal result = 0; if (customer.ClientSince == null) result = .2m; return result; } }
1 2 3 4 5 6 7 8 9 10 11
public class BloodDonorDiscountRule: IDiscountRule { public decimal GetDiscount(Customer customer, decimal discount) { decimal result = 0; if (customer.IsBloodDonor) result = .15m; return result; } }
1 2 3 4 5 6 7 8 9 10 11 12 13
public class LoyalCustomerDiscountRule: IDiscountRule { public decimal GetDiscount(Customer customer, decimal discount) { decimal result = 0; if (customer.ClientSince == null) result = 0; else if (customer.ClientSince.Value < DateTime.Now.AddYears(-10)) result = .1m; else if (customer.ClientSince.Value < DateTime.Now.AddYears(-5)) result = .05m; return result; } }
1 2 3 4 5 6 7 8 9 10 11
public class BirthdayDiscountRule: IDiscountRule { public decimal GetDiscount(Customer customer, decimal discount) { var result = discount; if (customer.BirthDate.Day == DateTime.Now.Day && customer.BirthDate.Month == DateTime.Now.Month) result += .10m; return result; } }
As we can see, the code from the “old” example got split into four classes. Each of them focuses on one rule, and each rule concentrates on specific situations. Thanks to this organisation, it is easy to read. Plus, adding a new one is much more straightforward.
But how to use them? What about rules order? Valid questions. The engine part of the pattern name is the answer. To make it work, we need to create an engine class to manage it all.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public class DiscountRuleEngine { List<IDiscountRule> _rules = new(); public DiscountRuleEngine(IEnumerable<IDiscountRule?> rules) { _rules.AddRange(rules); } public decimal GetDiscountPercentage(Customer customer) { return _rules.Aggregate(0m, (current, rule) => Math.Max(current, rule.GetDiscount(customer, current))); } }
The engine is not as difficult as it seems. It has two functionalities: the constructor that takes a set of rules and stores it in a class property, and the second one that gives us the final discount based on stored rules. Here is the place that takes part in ordering the rules. It does it by aggregating the max discounts. Easy and clean. Now, let's skip to the part where we use the engine.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public class DiscountCalculator { public decimal CalculateDiscountPercentage(Customer customer) { var ruleType = typeof(IDiscountRule); var rules = GetType().Assembly.GetTypes() .Where(p => ruleType.IsAssignableFrom(p) && !p.IsInterface) .Select(r => Activator.CreateInstance(r) as IDiscountRule); var engine = new DiscountRuleEngine(rules); return engine.GetDiscountPercentage(customer); } }
The calculating method uses a reflection to get the classes implementing the IDiscountRule interface. This approach loads classes during the run time. So, all we need to do if we need a new rule is to create a class implementing the right interface. After these classes are extracted, we need to create an engine with that class set. Once it's done, all that is left is to calculate the discount.
1 2 3 4 5 6 7 8 9
var discountCalculator = new DiscountCalculator(); var result =discountCalculator.CalculateDiscountPercentage(new Customer() { Name = "Test Client", IsBloodDonor = true, BirthDate = new DateTime(1985,12,08) }); Console.WriteLine(result);
Summary
The rules pattern helps us organise our code in a more useful way. Thanks to it, we can drop a block of ifs and change it into something more modular and way easier to read. This modular approach is also made for extension and closed for modification, keeping tightly to one of the SOLID rules.
As every single pattern, it should be used in cases where a value can be gained. Have you ever used it? Let me know at karol.rogowski@softwarehut.com.
Till next time. Keep coding.