Intermediate

C-Sharp Design Patterns Rules Engine Pattern

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.

GitHub repository: ardalis/DesignPatternsInCSharp


Table of Contents

  1. Course Overview
  2. Introduction to the main module
  3. What is the Rules Engine Pattern?
  4. Examples of use
  5. What problem does the Rules Engine solve?
  6. Set Rules
  7. Demonstration: The initial rebate calculator
  8. Demo: Adding new features
  9. Structure of the Rules Engine Pattern
  10. How to apply the Rules Engine to existing code
  11. Demonstration: Application of the Rules Engine Pattern to discounts
  12. Alternative structures and advanced considerations
  13. Similar and Related Patterns
  14. Key Takeaways
  15. 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:

ComponentRole
The engineProcesses and applies rules to a given context, usually producing a result
The Rules CollectionSet of rules grouped for use by the engine
Individual rulesEach 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:

  1. Start from the pattern: approach a new problem with the implementation of the pattern in mind from the start
  2. 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.

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.

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:

  1. Initial refactoring phase: they can be evaluated manually in the existing method
  2. 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:

StrategyDescription
First matching ruleIn some cases, the first rule that matches is the only one that matters
Complete assessmentIn other cases, it is necessary to evaluate all the rules and then aggregate or filter the individual results
Evaluation orderThe 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:

ConditionDiscount
First time purchase (no previous purchases)15%
Customer for over 15 years15%
Customer for over 10 years12%
Customer for more than 5 years10%
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 conditions0%

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/else making 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:

  1. Further increases cyclomatic complexity
  2. Risk of breaking existing rules
  3. Makes testing even more numerous and complex

TDD (Test-Driven Development) approach

The instructor demonstrates the Red/Green/Refactor approach:

  1. Red: write the test that fails (expected .10m, got something else)
  2. Green: modify the code to pass the test
  3. 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

ConditionDiscount
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)
CollaboratorResponsibility
RulesIndividually 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:

  1. Take a context as a parameter: decimal CalculateDiscount(Customer customer, decimal currentDiscount) — recommended approach to pass the necessary information
  2. Be configured at construction: individual rules are created with the context information passed to the constructor
  3. 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:

  1. Make sure to have tests to know if existing behavior has been broken
  2. 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 currentDiscount parameter is particularly useful for the BirthdayRule which 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 purchase
  • LoyalCustomerRule: from 5% to 15% depending on seniority
  • VeteranRule: 10% if veteran
  • SeniorRule: 5% if over 65 years old
  • BirthdayRule: +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 BirthdayRule uses currentDiscount + 0.10m ​​as the return value. With Math.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:

  1. Filter all non-matching rules first (!rule.IsMatch(customer))
  2. 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.IsLoyalCustomer for 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:

StrategyDescriptionAdvantages/Disadvantages
Explicit orderRules always run in a predefined orderSimple but difficult to maintain if many rules
Digital PriorityEach rule has a priority which determines its orderFlexible but priority management can become complex
Short circuit (short circuit)A matching rule replaces all othersEfficient 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.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-pattern course 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

AppearanceSpecification PatternRules Engine Pattern
Number of rules/specsTypically 1 or 2 combined for a refined resultOften a significant collection of many rules
UsageApplication code works directly with specificationsApplication code rarely interacts with rules directly — it goes through the engine
ContextSeparation 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

ComponentRole
Rules EngineManages the rule collection
Individual rule classesEncapsulate each business condition
Application StrategyHow rules are applied to the system context

Questions to ask during application

  1. Should rules be read-only?
  2. Do we want to authorize dependencies between rules?
  3. Do the rules need to be ordered or are there short-circuiting rules?
  4. Should we provide management facilities for rules (administration interface)?

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

ScenarioDiscount expectedRule(s) applied
Standard customer (previous purchase, non-senior)0%None
Senior customer (65+)5%SeniorRule
Very first purchase15%FirstTimeCustomerRule
Standard Veteran Customer10%VeteranRule
Loyal customer 1 year5%LoyalCustomerRule
Loyal customer 2 years8%LoyalCustomerRule
Loyal customer 5 years10%LoyalCustomerRule
Loyal customer 10 years12%LoyalCustomerRule
Loyal customer 15 years15%LoyalCustomerRule
Loyal Veteran 1 Year10%VeteranRule (> loyalty)
Loyal Veteran 2 Years10%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

Interested in this course?

Contact us to book it or get a custom training plan for your team.