Skip to content

Commit e8a2971

Browse files
committed
Create sample showing how to implement short circuiting priority rules.
1 parent 8343601 commit e8a2971

File tree

6 files changed

+367
-1
lines changed

6 files changed

+367
-1
lines changed

DesignPatternsInCSharp/DesignPatternsInCSharp.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
<ItemGroup>
1010
<None Remove="Adapter\People.json" />
11+
<None Remove="RulesEngine\DiscountsShortCircuit\README.md" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<Content Include="RulesEngine\DiscountsShortCircuit\README.md" />
1116
</ItemGroup>
1217

1318
<ItemGroup>

DesignPatternsInCSharp/RulesEngine/Discounts/DiscountCalculate_CalculateDiscountPercentage.cs renamed to DesignPatternsInCSharp/RulesEngine/Discounts/DiscountCalculator_CalculateDiscountPercentage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace DesignPatternsInCSharp.RulesEngine.Discounts
66
{
7-
public class DiscountCalculate_CalculateDiscountPercentage
7+
public class DiscountCalculator_CalculateDiscountPercentage
88
{
99
private DiscountCalculator _calculator = new DiscountCalculator();
1010
const int DEFAULT_AGE = 30;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
3+
namespace DesignPatternsInCSharp.RulesEngine.DiscountsShortCircuit
4+
{
5+
public class Customer
6+
{
7+
public DateTime? DateOfFirstPurchase { get; set; }
8+
public DateTime? DateOfBirth { get; set; }
9+
public bool IsVeteran { get; set; }
10+
public ReferredBy Referrer { get; set; }
11+
}
12+
13+
// Customers who are referred by others get a one time 30% discount on their first purchase.
14+
// We can determine if they are eligible by seeing if they have a referrer and if DateOfFirstPurchase is null.
15+
public class ReferredBy
16+
{
17+
public ReferredBy(Customer customer)
18+
{
19+
Customer = customer;
20+
}
21+
public Customer Customer { get; private set; }
22+
public DateTime DateReferred { get; set; } = DateTime.Today;
23+
}
24+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace DesignPatternsInCSharp.RulesEngine.DiscountsShortCircuit
6+
{
7+
// View History
8+
// https://github.githistory.xyz/ardalis/DesignPatternsInCSharp/blob/master/DesignPatternsInCSharp/RulesEngine/Discounts/DiscountCalculator.cs
9+
public interface IDiscountRule
10+
{
11+
decimal CalculateDiscount(Customer customer, decimal currentDiscount);
12+
}
13+
14+
// Priority rules short-circuit all other rules if they match.
15+
public interface IExclusivePriorityRule : IDiscountRule
16+
{
17+
int PriorityLowestExecutesFirst { get; }
18+
}
19+
20+
public class ReferredCustomerRule : IExclusivePriorityRule
21+
{
22+
public int PriorityLowestExecutesFirst => 0;
23+
24+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
25+
{
26+
if(customer.Referrer != null)
27+
{
28+
// here we can either duplicate the logic in FirstTimeCustomerRule or use that rule
29+
bool firstTimeCustomer = new FirstTimeCustomerRule().CalculateDiscount(customer, 0) > 0;
30+
31+
if (firstTimeCustomer) return .30m;
32+
}
33+
return 0;
34+
}
35+
}
36+
37+
public class FirstTimeCustomerRule : IDiscountRule
38+
{
39+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
40+
{
41+
if (!customer.DateOfFirstPurchase.HasValue)
42+
{
43+
return .15m;
44+
}
45+
return 0;
46+
}
47+
}
48+
49+
public class LoyalCustomerRule : IDiscountRule
50+
{
51+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
52+
{
53+
if (customer.DateOfFirstPurchase.HasValue)
54+
{
55+
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-15))
56+
{
57+
return .15m;
58+
}
59+
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-10))
60+
{
61+
return .12m;
62+
}
63+
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-5))
64+
{
65+
return .10m;
66+
}
67+
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-2))
68+
{
69+
return .08m;
70+
}
71+
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-1))
72+
{
73+
return .05m;
74+
}
75+
}
76+
return 0;
77+
}
78+
}
79+
80+
public class VeteranRule : IDiscountRule
81+
{
82+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
83+
{
84+
if (customer.IsVeteran)
85+
{
86+
return .10m;
87+
}
88+
return 0;
89+
}
90+
}
91+
public class SeniorRule : IDiscountRule
92+
{
93+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
94+
{
95+
if (customer.DateOfBirth < DateTime.Now.AddYears(-65))
96+
{
97+
return .05m;
98+
}
99+
return 0;
100+
}
101+
}
102+
103+
public class BirthdayRule : IDiscountRule
104+
{
105+
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
106+
{
107+
bool isBirthday = customer.DateOfBirth.HasValue && customer.DateOfBirth.Value.Month == DateTime.Today.Month && customer.DateOfBirth.Value.Day == DateTime.Today.Day;
108+
109+
if (isBirthday) return currentDiscount + 0.10m;
110+
return currentDiscount;
111+
}
112+
}
113+
114+
/// <summary>
115+
/// Adding exclusive priority rules we could just use a single list
116+
/// but then we would have LSP violations and we would need to sort it
117+
/// and treat the priority rules separately. Using two lists from the start
118+
/// yields a simpler overall design.
119+
/// </summary>
120+
public class DiscountRuleEngine
121+
{
122+
List<IDiscountRule> _rules = new List<IDiscountRule>();
123+
List<IExclusivePriorityRule> _priorityRules = new List<IExclusivePriorityRule>();
124+
125+
public DiscountRuleEngine(IEnumerable<IDiscountRule> rules, IEnumerable<IExclusivePriorityRule> priorityRules)
126+
{
127+
_rules.AddRange(rules);
128+
_priorityRules.AddRange(priorityRules);
129+
}
130+
131+
public decimal CalculateDiscountPercentage(Customer customer)
132+
{
133+
decimal discount = 0m;
134+
foreach(var priorityRule in _priorityRules.OrderBy(pr => pr.PriorityLowestExecutesFirst))
135+
{
136+
var result = priorityRule.CalculateDiscount(customer, 0);
137+
if (result > 0) return result;
138+
}
139+
foreach(var rule in _rules)
140+
{
141+
discount = Math.Max(discount, rule.CalculateDiscount(customer, discount));
142+
}
143+
return discount;
144+
}
145+
}
146+
147+
public class DiscountCalculator
148+
{
149+
public decimal CalculateDiscountPercentage(Customer customer)
150+
{
151+
// We need to produce separate list of priority rules now
152+
var ruleType = typeof(IDiscountRule);
153+
IEnumerable<IDiscountRule> rules = this.GetType().Assembly.GetTypes()
154+
.Where(p => ruleType.IsAssignableFrom(p) && !p.IsInterface)
155+
.Select(r => Activator.CreateInstance(r) as IDiscountRule);
156+
157+
// create a strongly-typed list of just the priority rules
158+
var priorityRules = rules.Where(r => r is IExclusivePriorityRule)
159+
.Select(r => (IExclusivePriorityRule)r)
160+
.ToList();
161+
162+
// create a list of all the other rules
163+
var baseRules = rules.Where(r => !(r is IExclusivePriorityRule)).ToList();
164+
165+
var engine = new DiscountRuleEngine(baseRules, priorityRules);
166+
167+
return engine.CalculateDiscountPercentage(customer);
168+
}
169+
}
170+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using FluentAssertions;
2+
using System;
3+
using Xunit;
4+
5+
namespace DesignPatternsInCSharp.RulesEngine.DiscountsShortCircuit
6+
{
7+
public class DiscountCalculator_CalculateDiscountPercentage
8+
{
9+
private DiscountCalculator _calculator = new DiscountCalculator();
10+
const int DEFAULT_AGE = 30;
11+
12+
[Fact]
13+
public void Returns0PctForBasicCustomer()
14+
{
15+
var customer = CreateCustomer(DEFAULT_AGE, DateTime.Today.AddDays(-1));
16+
17+
var result = _calculator.CalculateDiscountPercentage(customer);
18+
19+
result.Should().Be(0m);
20+
}
21+
22+
[Fact]
23+
public void Returns5PctForCustomersOver65()
24+
{
25+
var customer = CreateCustomer(65, DateTime.Today.AddDays(-1));
26+
27+
var result = _calculator.CalculateDiscountPercentage(customer);
28+
29+
result.Should().Be(.05m);
30+
}
31+
32+
[Theory]
33+
[InlineData(20)]
34+
[InlineData(70)]
35+
public void Returns15PctForCustomerFirstPurchase(int customerAge)
36+
{
37+
var customer = CreateCustomer(customerAge);
38+
39+
var result = _calculator.CalculateDiscountPercentage(customer);
40+
41+
result.Should().Be(.15m);
42+
}
43+
44+
[Theory]
45+
[InlineData(20)]
46+
[InlineData(70)]
47+
public void Returns10PctForCustomersWhoAreVeterans(int customerAge)
48+
{
49+
var customer = CreateCustomer(customerAge, DateTime.Today.AddDays(-1));
50+
customer.IsVeteran = true;
51+
52+
var result = _calculator.CalculateDiscountPercentage(customer);
53+
54+
result.Should().Be(.10m);
55+
}
56+
57+
[Theory]
58+
[InlineData(1, .05)]
59+
[InlineData(2, .08)]
60+
[InlineData(5, .10)]
61+
[InlineData(10, .12)]
62+
[InlineData(15, .15)]
63+
public void ReturnsCorrectLoyaltyDiscountForLongtimeCustomers(int yearsAsCustomer, decimal expectedDiscount)
64+
{
65+
var customer = CreateCustomer(DEFAULT_AGE,
66+
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
67+
68+
var result = _calculator.CalculateDiscountPercentage(customer);
69+
70+
result.Should().Be(expectedDiscount);
71+
}
72+
73+
[Theory]
74+
[InlineData(1, .15)]
75+
[InlineData(2, .18)]
76+
[InlineData(5, .20)]
77+
[InlineData(10, .22)]
78+
[InlineData(15, .25)]
79+
public void ReturnsCorrectLoyaltyDiscountForLongtimeCustomersOnTheirBirthday(int yearsAsCustomer, decimal expectedDiscount)
80+
{
81+
var customer = CreateBirthdayCustomer(DEFAULT_AGE, DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
82+
83+
var result = _calculator.CalculateDiscountPercentage(customer);
84+
85+
result.Should().Be(expectedDiscount);
86+
}
87+
88+
[Theory]
89+
[InlineData(1)]
90+
[InlineData(2)]
91+
public void ReturnsVeteransDiscountForLoyal1And2YearCustomers(int yearsAsCustomer)
92+
{
93+
var customer = CreateCustomer(DEFAULT_AGE,
94+
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
95+
customer.IsVeteran = true;
96+
97+
var result = _calculator.CalculateDiscountPercentage(customer);
98+
99+
result.Should().Be(.10m);
100+
}
101+
102+
[Theory]
103+
[InlineData(1)]
104+
[InlineData(2)]
105+
public void ReturnsVeteransDiscountForLoyal1And2YearCustomersOnBirthday(int yearsAsCustomer)
106+
{
107+
var customer = CreateBirthdayCustomer(DEFAULT_AGE,
108+
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
109+
customer.IsVeteran = true;
110+
111+
var result = _calculator.CalculateDiscountPercentage(customer);
112+
113+
result.Should().Be(.20m);
114+
}
115+
116+
[Fact]
117+
public void Returns10PctForCustomerSecondPurchaseOnBirthday()
118+
{
119+
var customer = CreateBirthdayCustomer(20, DateTime.Today.AddDays(-1));
120+
121+
var result = _calculator.CalculateDiscountPercentage(customer);
122+
123+
result.Should().Be(.10m);
124+
}
125+
126+
[Fact]
127+
public void Returns30PctForCustomerFirstPurchaseWithReferrer()
128+
{
129+
var customer = CreateCustomer();
130+
customer.Referrer = new ReferredBy(CreateCustomer());
131+
132+
var result = _calculator.CalculateDiscountPercentage(customer);
133+
134+
result.Should().Be(.30m);
135+
}
136+
137+
private Customer CreateCustomer(int age = DEFAULT_AGE, DateTime? firstPurchaseDate = null)
138+
{
139+
return new Customer
140+
{
141+
DateOfBirth = DateTime.Today.AddYears(-age).AddDays(-1),
142+
DateOfFirstPurchase = firstPurchaseDate
143+
};
144+
}
145+
146+
private Customer CreateBirthdayCustomer(int age = DEFAULT_AGE, DateTime? firstPurchaseDate = null)
147+
{
148+
return new Customer
149+
{
150+
DateOfBirth = DateTime.Today.AddYears(-age),
151+
DateOfFirstPurchase = firstPurchaseDate
152+
};
153+
}
154+
}
155+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Discount Rules with Short-Circuiting
2+
3+
In response to a reader question found here:
4+
5+
https://github.com/ardalis/DesignPatternsInCSharp/issues/15
6+
7+
This folder shows how to add a new ReferralDiscount that yields a 30% discount and immediately stops checking all other rules. There are two parts to this:
8+
9+
- This rule needs to run first
10+
- If it matches, no other rules should run
11+
12+
Our design will allow for multiple short-circuiting rules which can be run in a priority order.

0 commit comments

Comments
 (0)