GitHub repository: ardalis/DesignPatternsInCSharp
Table of Contents
- Course Overview
- Introduction to the main module
- What is the Rules Engine Pattern?
- Examples of use
- What problem does the Rules Engine solve?
- Set Rules
- Demonstration: The initial rebate calculator
- Demo: Adding new features
- Structure of the Rules Engine Pattern
- How to apply the Rules Engine to existing code
- Demonstration: Application of the Rules Engine Pattern to discounts
- Alternative structures and advanced considerations
- Similar and Related Patterns
- Key Takeaways
- Full code of demo files
1. Course Overview
About the trainer
Steve Smith, known as ardalis, is an experienced .NET developer, architect and trainer. Its private team workshops and Pluralsight courses have helped thousands of developers write better, faster code. He can be found under the name ardalis on Twitter, GitHub, YouTube and other networks.
Philosophy of Design Patterns
Design patterns are like individual tools that you can add to your toolbox as a software developer. They don’t take much time to discover, but can require a lot of practice to master.
Course objectives
This course explores the Rules Engine design pattern, an excellent pattern for breaking down complex conditional logic into better partitioned and more maintainable code. Major topics covered include:
- What types of problems do rules engines solve?
- What software design principles apply to this pattern?
- How can we apply the Rules Engine Pattern to a real application?
- What other design patterns are similar to this one?
Expected result
At the end of this course, the developer will be able to:
- Recognize situations where adding a simple Rules Engine is relevant
- Apply this pattern with confidence in real projects
2. Introduction to the main module
Module overview
The course focuses on a single main module: Applying the Rules Engine Pattern (duration: 42 minutes 24 seconds). This module answers the following questions:
- What is the Rules Engine Pattern?
- What types of problems does this pattern solve and how to recognize them?
- How is this pattern structured? What are its different parts and how do they interact?
- How to apply this pattern to real code to solve real business problems?
- What are the common variations that we may encounter?
- How to recognize similar patterns and know when to use them instead of or in addition to the Rules Engine?
3. What is the Rules Engine Pattern?
Basic definition
A rules engine processes a set of rules and applies them to produce a result. The pattern is made up of several distinct elements:
| Component | Role |
|---|---|
| The engine | Processes and applies rules to a given context, usually producing a result |
| The Rules Collection | Set of rules grouped for use by the engine |
| Individual rules | Each describes a condition and can calculate a value |
Rule characteristics
- A rule describes a condition and can calculate a value
- Rules are grouped into collections for use by rules engines
- Different algorithms may involve:
- Executing each rule and aggregating their results
- The ordering of rules according to a certain priority or precedence
Classification in Design Patterns
The Rules Engine Pattern is classified as a behavioral pattern because it can be used to model the behavior of part of a system.
Note: Some companies can purchase an off-the-shelf business rules system (ready-to-use), but as part of this design pattern, you learn how to build your own simple rules engine to improve and simplify the design of an application.
Usage approaches
Like most design patterns, you can:
- Start from the pattern: approach a new problem with the implementation of the pattern in mind from the start
- Refactor: modify existing code to use the pattern
The Rules Engine Pattern lends itself particularly well to the second scenario: refactoring existing code to improve its design. Once you identify some code smells indicating a potential problem, this pattern may be the ideal solution.
4. Usage examples
The Rules Engine Pattern is applicable in many practical contexts. Here are the most common use cases:
4.1 Score calculation in games
Many games include a variety of rules that can be used to calculate the optimal score. Rules engines are well suited to this task. Additionally, beyond the score, we can also determine a winner in games that do not necessarily use a traditional scoring system.
4.2 Calculation of customer discounts
Many organizations offer discounts to their customers or partners. These systems often start simple, but grow in complexity over time as new promotions are added. The Rules Engine is perfectly suited to manage this evolution.
4.3 Medical diagnostics or expert systems
Medical or other diagnostics can also be applied as part of an expert system. An expert system typically includes:
- An inference engine (what we call rules engine in this course)
- A body of knowledge in the form of rules drawn from real-world experts (like doctors for a medical system)
Note: Real expert systems exploit artificial intelligence techniques and are outside the scope of this course.
5. What problem does the Rules Engine solve?
The Open/Closed principle (OCP)
The Open/Closed Principle is one of the SOLID principles. It states that the code must be:
- Open for extension
- Closed for modification
This means that code that exists in production and works correctly should not require modification to extend its behavior. Modifying existing code risks breaking already working code and anything that depends on it.
The recommended approach
Software modules should be written in such a way that they can extend their behavior without having to edit their source code. The main recommendation is:
Prefer to evolve existing software via new classes, rather than by editing existing code in existing classes and methods.
Advantages of this approach
- Design freedom: we are not constrained by the limitations of existing classes which prevent us from using a better or more testable design
- Zero dependency trust: no existing code in the system depends on the new class that we are going to write (since it did not exist until now), which allows it to be modeled as desired
Warning signal: Cyclomatic complexity
A common sign that OCP is violated is a method that has a lot of conditional complexity, also known as cyclomatic complexity.
Most business logic requires some degree of conditional complexity. But if this complexity is significant and we notice that it frequently grows in response to new requirements, it is probably an issue that needs to be addressed.
Link with the Rules Engine Pattern
The Rules Engine Pattern allows you to follow the Open/Closed principle and extend the system with new behaviors simply by writing new classes, without modifying existing classes.
6. Define Rules
Extracting rules from a complex method
The key to reducing complexity in a large method is to start extracting individual cases, which we define as individual rules. When extracting these rules:
- Make them as small as practical (not as small as possible)
- Each rule must enforce only one case (Single Responsibility Principle, another SOLID principle)
- Each rule must have only one responsibility
Rule evaluation
Rules must be evaluated somewhere in the system:
- Initial refactoring phase: they can be evaluated manually in the existing method
- Final phase: the responsibility for evaluating a result from a collection of rules must reside in its own type — this is what the DiscountRuleEngine class will be (or more generally, the rules engine class)
Evaluation Strategies
When thinking about extracting rules, consider how they will be evaluated correctly:
| Strategy | Description |
|---|---|
| First matching rule | In some cases, the first rule that matches is the only one that matters |
| Complete assessment | In other cases, it is necessary to evaluate all the rules and then aggregate or filter the individual results |
| Evaluation order | The order in which rules are evaluated can be important, or at least provide performance benefits |
7. Demonstration: The initial rebate calculator
Problem Context
The example works with an application that calculates discounts given to customers. The company wants to offer different types of discounts for promotional reasons, to encourage customers to remain loyal.
Customer class structure (initial)
The Customer class stores the following information:
- DateOfFirstPurchase: the date of the customer’s first purchase
- DateOfBirth: the customer’s date of birth
- IsVeteran: indicates if the client is a military veteran
Initial logic of the rebate calculator
The initial DiscountCalculator applies the following logic via a CalculateDiscountPercentage method:
Initial Discount Table:
| Condition | Discount |
|---|---|
| First time purchase (no previous purchases) | 15% |
| Customer for over 15 years | 15% |
| Customer for over 10 years | 12% |
| Customer for more than 5 years | 10% |
| Customer for + 2 years (non-veteran) | 8% |
| Customer for + 1 year (non-veteran) | 5% |
| Veteran (regardless of loyalty) | 10% |
| Senior (over 65 years old) | 5% |
| No conditions | 0% |
Important observation: For customers with 1 or 2 years of seniority, the loyalty discount is only granted if they are not veterans, because in these cases the veteran discount (10%) is more advantageous than the loyalty discount (5% or 8%).
Issues identified in initial method
The CalculateDiscountPercentage method has all the classic symptoms of a problematic method:
- Nesting of many
if/elsemaking reading difficult - High cyclomatic complexity: each new business rule requires modifying this method
- Violation of the Open/Closed principle: impossible to add a rule without touching the existing code
- Testing difficulties: each modification risks breaking existing tests
Unit test suite
The code is accompanied by a suite of unit tests to verify correct behavior. These tests are essential to validate that the refactoring does not break existing behavior.
8. Demo: Adding new features
New requirement: Anniversary discount
A new urgent requirement has arrived from the sales team:
Birthday campaign: All customers receive 10% discount on their birthday. Plus, loyal customers receive an additional 10% on top of any discount they would normally receive.
Impact on existing method
This new rule perfectly illustrates the problem with the existing code: you have to modify the CalculateDiscountPercentage method to add this birthday logic, which:
- Further increases cyclomatic complexity
- Risk of breaking existing rules
- Makes testing even more numerous and complex
TDD (Test-Driven Development) approach
The instructor demonstrates the Red/Green/Refactor approach:
- Red: write the test that fails (expected
.10m, got something else) - Green: modify the code to pass the test
- Refactor: improve the code structure
To create a birthday customer, simply do not add AddDays(-1) to the birth date, unlike the existing CreateCustomer helper which deliberately ensures that the birth date does not correspond to today.
Discount table after adding the anniversary rule
| Condition | Discount |
|---|---|
| First purchase (anniversary) | 10% (birthday only) |
| Customer for 1 year (anniversary) | 15% (5% + 10%) |
| Customer for 2 years (anniversary) | 18% (8% + 10%) |
| Customer for 5 years (anniversary) | 20% (10% + 10%) |
| Customer for 10 years (anniversary) | 22% (12% + 10%) |
| Customer for 15 years (anniversary) | 25% (15% + 10%) |
| Veteran 1 or 2 years (birthday) | 20% (10% + 10%) |
Observation: The method is becoming uncontrollable
After adding the anniversary rule, the CalculateDiscountPercentage method became even more complex. This is where it becomes clear that the Rules Engine Pattern is the right solution.
9. Structure of the Rules Engine Pattern
The three main collaborators
The Rules Engine Pattern involves three main contributors:
┌─────────────────────────────────────────────────────┐
│ RULES ENGINE │
│ ┌──────────────────────────────────────────────┐ │
│ │ Collection de règles (Rules) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Rule 1 │ │ Rule 2 │ │ Rule N │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
↑
Context/Input
(ex: informations client)
| Collaborator | Responsibility |
|---|---|
| Rules | Individually represent how the system should behave under certain conditions |
| The engine (Rules Engine) | Responsible for applying rules to a given system context or scenario |
| The context (Context/Input) | Necessary input data (game state, customer information, etc.) |
Rules Engine structure (conceptual diagram)
IDiscountRule (interface)
└── CalculateDiscount(Customer customer, decimal currentDiscount) : decimal
FirstTimeCustomerRule : IDiscountRule
LoyalCustomerRule : IDiscountRule
VeteranRule : IDiscountRule
SeniorRule : IDiscountRule
BirthdayRule : IDiscountRule
DiscountRuleEngine
└── _rules : List<IDiscountRule>
└── CalculateDiscountPercentage(Customer customer) : decimal
DiscountCalculator
└── CalculateDiscountPercentage(Customer customer) : decimal
(utilise la réflexion pour trouver toutes les IDiscountRule)
Options for signing rules
The method defined in the interface or base class can:
- Take a context as a parameter:
decimal CalculateDiscount(Customer customer, decimal currentDiscount)— recommended approach to pass the necessary information - Be configured at construction: individual rules are created with the context information passed to the constructor
- Access the context globally: in some cases (local game), the context is accessible globally, so the signature can be very simple
Rule collection: creation and population
The collection of rules in the engine can be:
- Created within the engine itself during its construction (hardcoded)
- Passed to injection via the constructor (Dependency Injection)
- Populated by reflection: the engine uses reflection to automatically discover all classes that implement the rule interface — this is the most extensible approach (Open/Closed)
Best practices
- Keep individual rules simple: the beauty of this pattern is that it allows you to model potentially very complex logic
- Only one responsibility per rule: each rule must only evaluate one case
10. How to apply the Rules Engine to existing code
Basic prerequisites
Before applying the refactoring:
- Make sure to have tests to know if existing behavior has been broken
- Important reminder: refactoring is not supposed to change what the system does, only how it is organized
Pattern application steps
Here are the concrete steps to transform existing code into the Rules Engine Pattern:
Step 1: Extract individual methods
Apply Extract Method refactoring to extract individual conditions. Each condition becomes a private method in the original class.
Step 2: Harmonize signatures
Modify the extracted methods so that they have a common signature. In the case of the discount calculator, the signature will be: decimal CalculateDiscount(Customer customer) or similar.
Step 3: Create a common interface
Define an interface that represents the common contract for all rules. For example: IDiscountRule.
Step 4: Move methods to their own classes
Each extracted method becomes a class of its own with an evaluation method with a common name like CalculateDiscount, defined in the base class or interface.
Step 5: Create the rules engine
Once the individual rule classes do the heavy lifting, it’s time to extract the evaluation logic from the original method to a rules engine.
Step 6: Simplify the original method
Modify the original method so that it just creates the rules engine and calls its evaluation method.
Step 7: Automate rule discovery
Consider how to organize the rules such that adding new rules does not require touching the rules engine or the original method. Thinking or some configuration can help add this behavior.
Summary of steps
Code original complexe
↓
[1] Tests unitaires existants (filet de sécurité)
↓
[2] Extract Method → méthodes helper dans la même classe
↓
[3] Signature commune + interface IDiscountRule
↓
[4] Move to Class → chaque helper devient une classe Rule
↓
[5] Créer DiscountRuleEngine (gère la collection de règles)
↓
[6] Méthode originale simplifée (crée l'engine et l'appelle)
↓
[7] Réflexion → découverte automatique des nouvelles règles
11. Demonstration: Application of the Rules Engine Pattern to discounts
Phase 1: Extraction of helper methods
The first step is to extract the if statements into separate methods in the original class, ensuring that they all have the same signature.
// Signature cible pour chaque helper
private decimal CalculateFirstTimePurchaseDiscount(Customer customer) { ... }
private decimal CalculateLoyaltyDiscount(Customer customer) { ... }
private decimal CalculateVeteranDiscount(Customer customer) { ... }
private decimal CalculateSeniorDiscount(Customer customer) { ... }
private decimal CalculateBirthdayDiscount(Customer customer, decimal currentDiscount) { ... }
After this extraction, we replace the if statements in the main method with calls to the helpers, using Math.Max to keep only the most advantageous discount:
discount = Math.Max(discount, CalculateLoyaltyDiscount(customer));
Phase 2: Definition of the IDiscountRule interface
Once the helpers are in place with a uniform signature, we create the common interface:
public interface IDiscountRule
{
decimal CalculateDiscount(Customer customer, decimal currentDiscount);
}
Note: The
currentDiscountparameter is particularly useful for theBirthdayRulewhich adds 10% on top of the current discount rather than offering an independent fixed discount.
Phase 3: Creating individual rule classes
Each extracted condition becomes a class implementing IDiscountRule:
FirstTimeCustomerRule: 15% if no previous purchaseLoyalCustomerRule: from 5% to 15% depending on seniorityVeteranRule: 10% if veteranSeniorRule: 5% if over 65 years oldBirthdayRule: +10% on the current discount if it’s the birthday
Phase 4: Creation of the DiscountRuleEngine
The engine receives the list of rules and applies them:
public class DiscountRuleEngine
{
private List<IDiscountRule> _rules;
public DiscountRuleEngine(IEnumerable<IDiscountRule> rules)
{
_rules = new List<IDiscountRule>(rules);
}
public decimal CalculateDiscountPercentage(Customer customer)
{
decimal discount = 0m;
foreach (var rule in _rules)
{
discount = Math.Max(discount, rule.CalculateDiscount(customer, discount));
}
return discount;
}
}
Important subtlety: The
BirthdayRuleusescurrentDiscount + 0.10mas the return value. WithMath.Max, this rule will therefore be taken into account only if the result (current discount + 10%) is greater than the discount already calculated, which is always true if a non-zero discount has already been found.
Phase 5: Using reflection for automatic rule discovery
The final DiscountCalculator class uses reflection to automatically discover all classes that implement IDiscountRule in the current assembly:
public class DiscountCalculator
{
public decimal CalculateDiscountPercentage(Customer customer)
{
var ruleType = typeof(IDiscountRule);
IEnumerable<IDiscountRule> rules = this.GetType().Assembly.GetTypes()
.Where(p => ruleType.IsAssignableFrom(p) && !p.IsInterface)
.Select(r => Activator.CreateInstance(r) as IDiscountRule);
var engine = new DiscountRuleEngine(rules);
return engine.CalculateDiscountPercentage(customer);
}
}
Result: When the system starts, all rules are automatically detected. To add a new rule, simply create a new class implementing IDiscountRule — without touching the DiscountCalculator or the DiscountRuleEngine. The Open/Closed principle is perfectly respected.
12. Alternative structures and advanced considerations
12.1 The IsMatch method
A common alternative structure is to add an IsMatch method to the rules, in addition to the evaluation method. The extended interface looks like:
public interface IDiscountRule
{
bool IsMatch(Customer customer);
decimal CalculateDiscount(Customer customer, decimal currentDiscount);
}
Operation:
- Filter all non-matching rules first (
!rule.IsMatch(customer)) - Then evaluate only the rules that match, or evaluate them all and aggregate the results
Advantage: Separates the matching logic from the calculation logic (evaluation), which improves the readability and testability of each part.
12.2 Read-Only Rules
It is simpler to keep rules read-only (without side effects on the system they evaluate). However, if we choose to create rules that modify the system they evaluate, we must consider the dependencies between rules.
Example of problematic dependency:
- Rule A detects that a customer has been loyal for more than a year and sets
customer.IsLoyalCustomer = true - Rule B depends on
customer.IsLoyalCustomerfor its evaluation - In this case, there is a dependency and we must ensure that rule B always executes after rule A
12.3 Rule evaluation order
Several strategies to manage the order of rules:
| Strategy | Description | Advantages/Disadvantages |
|---|---|---|
| Explicit order | Rules always run in a predefined order | Simple but difficult to maintain if many rules |
| Digital Priority | Each rule has a priority which determines its order | Flexible but priority management can become complex |
| Short circuit (short circuit) | A matching rule replaces all others | Efficient but requires clear priority logic |
Caution: Evaluation order and rule dependencies can make rule management more difficult.
12.4 Rules that supersede all others
Some rules may bypass processing if they match. For example, a “VIP customer” rule could override all other discounts with its own value. A mechanism is then needed to signal this short circuit.
12.5 Dynamically managed rules
If the rules change frequently enough, you may want to manage them dynamically via:
- A configuration file (JSON, XML, YAML)
- A database with an administration interface
- A plugin system allowing you to load rules on the fly
13. Similar and Related Patterns
13.1 The Specification Pattern
The pattern most closely related to the Rules Engine Pattern is the Specification Pattern, a Domain-Driven Design (DDD) pattern.
Resources for further study:
- The DDD Learning Path on Pluralsight (with Julie Lerman and Steve Smith)
- Steve Smith’s Classic Design Patterns Library course
- The
csharp-specification-patterncourse available in the training courses
Specification Pattern Description:
- A specification describes a query within an object
- Allows you to separate query logic from other parts of the application (repositories, controllers, views)
13.2 Differences between Specification and Rules Engine
| Appearance | Specification Pattern | Rules Engine Pattern |
|---|---|---|
| Number of rules/specs | Typically 1 or 2 combined for a refined result | Often a significant collection of many rules |
| Usage | Application code works directly with specifications | Application code rarely interacts with rules directly — it goes through the engine |
| Context | Separation of query logic (often repository side) | Complex business logic evaluation |
14. Key Takeaways
Rules Engine Pattern Summary
The Rules Engine Pattern is a behavioral pattern frequently used to decompose complex logic residing in a single method and divide that logic into separate rule classes.
Common uses
- Calculating scores in games
- Calculation of customer discounts
- Any other complex calculation business logic
Main parts of the pattern
| Component | Role |
|---|---|
| Rules Engine | Manages the rule collection |
| Individual rule classes | Encapsulate each business condition |
| Application Strategy | How rules are applied to the system context |
Questions to ask during application
- Should rules be read-only?
- Do we want to authorize dependencies between rules?
- Do the rules need to be ordered or are there short-circuiting rules?
- Should we provide management facilities for rules (administration interface)?
Links and Resources
- Latest examples from the course: GitHub ardalis/DesignPatternsInCSharp
- Contact: ardalis on Twitter, GitHub, YouTube, ardalis.com
- Developer podcast: weeklydevtips.com
15. Full code demo files
15.1 Customer.cs
using System;
namespace DesignPatternsInCSharp.RulesEngine.Discounts
{
public class Customer
{
public DateTime? DateOfFirstPurchase { get; set; }
public DateTime? DateOfBirth { get; set; }
public bool IsVeteran { get; set; }
}
}
15.2 DiscountCalculator.cs — Final version with Rules Engine Pattern
This file contains the IDiscountRule interface, all individual rule classes, the DiscountRuleEngine and the final DiscountCalculator class using reflection.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
namespace DesignPatternsInCSharp.RulesEngine.Discounts
{
// View History
// https://github.githistory.xyz/ardalis/DesignPatternsInCSharp/blob/master/DesignPatternsInCSharp/RulesEngine/Discounts/DiscountCalculator.cs
// ──────────────────────────────────────────────────────────────────────────
// INTERFACE COMMUNE — Contrat que toute règle de remise doit respecter
// ──────────────────────────────────────────────────────────────────────────
public interface IDiscountRule
{
decimal CalculateDiscount(Customer customer, decimal currentDiscount);
}
// ──────────────────────────────────────────────────────────────────────────
// RÈGLE 1 : Premier achat → 15%
// ──────────────────────────────────────────────────────────────────────────
public class FirstTimeCustomerRule : IDiscountRule
{
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
{
if (!customer.DateOfFirstPurchase.HasValue)
{
return .15m;
}
return 0;
}
}
// ──────────────────────────────────────────────────────────────────────────
// RÈGLE 2 : Fidélité client → 5% à 15% selon l'ancienneté
// ──────────────────────────────────────────────────────────────────────────
public class LoyalCustomerRule : IDiscountRule
{
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
{
if (customer.DateOfFirstPurchase.HasValue)
{
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-15))
{
return .15m; // Client depuis + 15 ans
}
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-10))
{
return .12m; // Client depuis + 10 ans
}
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-5))
{
return .10m; // Client depuis + 5 ans
}
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-2))
{
return .08m; // Client depuis + 2 ans
}
if (customer.DateOfFirstPurchase.Value < DateTime.Now.AddYears(-1))
{
return .05m; // Client depuis + 1 an
}
}
return 0;
}
}
// ──────────────────────────────────────────────────────────────────────────
// RÈGLE 3 : Vétéran → 10%
// ──────────────────────────────────────────────────────────────────────────
public class VeteranRule : IDiscountRule
{
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
{
if (customer.IsVeteran)
{
return .10m;
}
return 0;
}
}
// ──────────────────────────────────────────────────────────────────────────
// RÈGLE 4 : Senior (65+) → 5%
// ──────────────────────────────────────────────────────────────────────────
public class SeniorRule : IDiscountRule
{
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
{
if (customer.DateOfBirth < DateTime.Now.AddYears(-65))
{
return .05m;
}
return 0;
}
}
// ──────────────────────────────────────────────────────────────────────────
// RÈGLE 5 : Anniversaire → remise actuelle + 10%
// Note : cette règle s'additionne au lieu de remplacer
// ──────────────────────────────────────────────────────────────────────────
public class BirthdayRule : IDiscountRule
{
public decimal CalculateDiscount(Customer customer, decimal currentDiscount)
{
bool isBirthday = customer.DateOfBirth.HasValue
&& customer.DateOfBirth.Value.Month == DateTime.Today.Month
&& customer.DateOfBirth.Value.Day == DateTime.Today.Day;
if (isBirthday) return currentDiscount + 0.10m;
return currentDiscount;
}
}
// ──────────────────────────────────────────────────────────────────────────
// MOTEUR DE RÈGLES — Applique toutes les règles et retourne la remise max
// ──────────────────────────────────────────────────────────────────────────
public class DiscountRuleEngine
{
List<IDiscountRule> _rules = new List<IDiscountRule>();
public DiscountRuleEngine(IEnumerable<IDiscountRule> rules)
{
_rules.AddRange(rules);
}
public decimal CalculateDiscountPercentage(Customer customer)
{
decimal discount = 0m;
foreach (var rule in _rules)
{
discount = Math.Max(discount, rule.CalculateDiscount(customer, discount));
}
return discount;
}
}
// ──────────────────────────────────────────────────────────────────────────
// CALCULATEUR — Point d'entrée; utilise la réflexion pour découvrir les règles
// ──────────────────────────────────────────────────────────────────────────
public class DiscountCalculator
{
public decimal CalculateDiscountPercentage(Customer customer)
{
var ruleType = typeof(IDiscountRule);
IEnumerable<IDiscountRule> rules = this.GetType().Assembly.GetTypes()
.Where(p => ruleType.IsAssignableFrom(p) && !p.IsInterface)
.Select(r => Activator.CreateInstance(r) as IDiscountRule);
var engine = new DiscountRuleEngine(rules);
return engine.CalculateDiscountPercentage(customer);
}
}
}
15.3 DiscountCalculate_CalculateDiscountPercentage.cs — Unit tests
This file contains the complete unit test suite (xUnit + FluentAssertions) that validates the correct behavior of the rebate calculator.
using FluentAssertions;
using System;
using Xunit;
namespace DesignPatternsInCSharp.RulesEngine.Discounts
{
public class DiscountCalculate_CalculateDiscountPercentage
{
private DiscountCalculator _calculator = new DiscountCalculator();
const int DEFAULT_AGE = 30;
// ── Tests de base ──────────────────────────────────────────────────────
[Fact]
public void Returns0PctForBasicCustomer()
{
var customer = CreateCustomer(DEFAULT_AGE, DateTime.Today.AddDays(-1));
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(0m);
}
[Fact]
public void Returns5PctForCustomersOver65()
{
var customer = CreateCustomer(65, DateTime.Today.AddDays(-1));
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.05m);
}
// ── Tests premier achat ────────────────────────────────────────────────
[Theory]
[InlineData(20)]
[InlineData(70)]
public void Returns15PctForCustomerFirstPurchase(int customerAge)
{
var customer = CreateCustomer(customerAge);
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.15m);
}
// ── Tests vétéran ──────────────────────────────────────────────────────
[Theory]
[InlineData(20)]
[InlineData(70)]
public void Returns10PctForCustomersWhoAreVeterans(int customerAge)
{
var customer = CreateCustomer(customerAge, DateTime.Today.AddDays(-1));
customer.IsVeteran = true;
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.10m);
}
// ── Tests fidélité ─────────────────────────────────────────────────────
[Theory]
[InlineData(1, .05)]
[InlineData(2, .08)]
[InlineData(5, .10)]
[InlineData(10, .12)]
[InlineData(15, .15)]
public void ReturnsCorrectLoyaltyDiscountForLongtimeCustomers(
int yearsAsCustomer, decimal expectedDiscount)
{
var customer = CreateCustomer(DEFAULT_AGE,
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(expectedDiscount);
}
// ── Tests fidélité + anniversaire ──────────────────────────────────────
[Theory]
[InlineData(1, .15)]
[InlineData(2, .18)]
[InlineData(5, .20)]
[InlineData(10, .22)]
[InlineData(15, .25)]
public void ReturnsCorrectLoyaltyDiscountForLongtimeCustomersOnTheirBirthday(
int yearsAsCustomer, decimal expectedDiscount)
{
var customer = CreateBirthdayCustomer(
DEFAULT_AGE,
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(expectedDiscount);
}
// ── Tests vétéran fidèle 1-2 ans ───────────────────────────────────────
[Theory]
[InlineData(1)]
[InlineData(2)]
public void ReturnsVeteransDiscountForLoyal1And2YearCustomers(int yearsAsCustomer)
{
// Pour 1 et 2 ans, la remise vétéran (10%) > remise fidélité (5%/8%)
var customer = CreateCustomer(DEFAULT_AGE,
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
customer.IsVeteran = true;
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.10m);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void ReturnsVeteransDiscountForLoyal1And2YearCustomersOnBirthday(int yearsAsCustomer)
{
// Vétéran + anniversaire = 10% (vétéran) + 10% (anniversaire) = 20%
var customer = CreateBirthdayCustomer(DEFAULT_AGE,
DateTime.Today.AddYears(-yearsAsCustomer).AddDays(-1));
customer.IsVeteran = true;
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.20m);
}
// ── Test anniversaire simple ────────────────────────────────────────────
[Fact]
public void Returns10PctForCustomerSecondPurchaseOnBirthday()
{
// Client qui a déjà acheté (hier) mais c'est son anniversaire aujourd'hui
var customer = CreateBirthdayCustomer(20, DateTime.Today.AddDays(-1));
var result = _calculator.CalculateDiscountPercentage(customer);
result.Should().Be(.10m);
}
// ── Méthodes helper de création de clients ─────────────────────────────
private Customer CreateCustomer(int age = DEFAULT_AGE, DateTime? firstPurchaseDate = null)
{
return new Customer
{
// AddDays(-1) garantit que ce n'est PAS l'anniversaire du client
DateOfBirth = DateTime.Today.AddYears(-age).AddDays(-1),
DateOfFirstPurchase = firstPurchaseDate
};
}
private Customer CreateBirthdayCustomer(int age = DEFAULT_AGE, DateTime? firstPurchaseDate = null)
{
return new Customer
{
// Sans AddDays(-1) : la date de naissance correspond à aujourd'hui (même mois/jour)
DateOfBirth = DateTime.Today.AddYears(-age),
DateOfFirstPurchase = firstPurchaseDate
};
}
}
}
16. Appendix: UML Class Diagram (textual)
┌─────────────────────────────────────────────────────────────────────┐
│ <<interface>> │
│ IDiscountRule │
│─────────────────────────────────────────────────────────────────────│
│ + CalculateDiscount(customer: Customer, currentDiscount: decimal) │
│ : decimal │
└─────────────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲ ▲
│ │ │ │ │
┌────────┴──┐ ┌────┴──────┐ ┌┴───────┐ ┌┴──────┐ ┌┴──────────────┐
│ FirstTime │ │ Loyal │ │Veteran │ │Senior │ │ BirthdayRule │
│ Customer │ │ Customer │ │ Rule │ │ Rule │ │ │
│ Rule │ │ Rule │ │ │ │ │ │ │
└───────────┘ └───────────┘ └────────┘ └───────┘ └───────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ DiscountRuleEngine │
│────────────────────────────────────────────────────────────────────│
│ - _rules: List<IDiscountRule> │
│────────────────────────────────────────────────────────────────────│
│ + DiscountRuleEngine(rules: IEnumerable<IDiscountRule>) │
│ + CalculateDiscountPercentage(customer: Customer): decimal │
└────────────────────────────────────────────────────────────────────┘
▲ utilise
│
┌────────┴───────────────────────────────────────────────────────────┐
│ DiscountCalculator │
│────────────────────────────────────────────────────────────────────│
│ + CalculateDiscountPercentage(customer: Customer): decimal │
│ (découverte des règles par réflexion) │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ Customer │
│────────────────────────────────────────────────────────────────────│
│ + DateOfFirstPurchase: DateTime? │
│ + DateOfBirth: DateTime? │
│ + IsVeteran: bool │
└────────────────────────────────────────────────────────────────────┘
17. Appendix: Discount Summary and Test Cases
| Scenario | Discount expected | Rule(s) applied |
|---|---|---|
| Standard customer (previous purchase, non-senior) | 0% | None |
| Senior customer (65+) | 5% | SeniorRule |
| Very first purchase | 15% | FirstTimeCustomerRule |
| Standard Veteran Customer | 10% | VeteranRule |
| Loyal customer 1 year | 5% | LoyalCustomerRule |
| Loyal customer 2 years | 8% | LoyalCustomerRule |
| Loyal customer 5 years | 10% | LoyalCustomerRule |
| Loyal customer 10 years | 12% | LoyalCustomerRule |
| Loyal customer 15 years | 15% | LoyalCustomerRule |
| Loyal Veteran 1 Year | 10% | VeteranRule (> loyalty) |
| Loyal Veteran 2 Years | 10% | VeteranRule (> loyalty) |
| Anniversary (2nd purchase) | 10% | BirthdayRule (0 + 10%) |
| Loyal customer 1 year (anniversary) | 15% | LoyalCustomerRule + BirthdayRule |
| Loyal customer 2 years (anniversary) | 18% | LoyalCustomerRule + BirthdayRule |
| Loyal customer 5 years (anniversary) | 20% | LoyalCustomerRule + BirthdayRule |
| Loyal customer 10 years (anniversary) | 22% | LoyalCustomerRule + BirthdayRule |
| Loyal customer 15 years (anniversary) | 25% | LoyalCustomerRule + BirthdayRule |
| Loyal Veteran 1 Year (Birthday) | 20% | VeteranRule + BirthdayRule |
| Loyal Veteran 2 Years (Anniversary) | 20% | VeteranRule + BirthdayRule |
Search Terms
c-sharp · design · patterns · rules · engine · pattern · testing · architecture · c# · .net · development · rule · method · phase · initial · application · approach · discount · evaluation · methods · anniversary · calculation · calculator · class