Rules Engine Pattern in dotnet

The Problem

Before you can determine the rules engine you need to be clear what the rules actually are. For this example, I needed to determine how much it would be for someone to attend an educational course. This meant that there were quite a few factors to take into account as they all affected the cost. This is just a few of the factors.

  1. The students age — The price of some courses varied according to age, some were reduced over a certain age, others were free under a certain age.
  2. Previous courses — if the student had undertaken certain courses previously, the price was different to if they hadn’t
  3. Income — Some courses were free if the student was on specific benefits or if they were drawing a pension.
  4. Course type — Some types of course didn’t have a discount, others did.

Attempt 1

The first attempt at this ended up with some horrific switch/if statements and was soon abandoned as too complex, particularly as the rules looked likely to change, potentially every few months.

Attempt 2

This led to the rules engine approach which was much more flexible and easier to test. I setup two classes, one for the student and one for the course. this is sufficient to determine how much a course costs when it’s calculated manually.

public class Student{private decimal? hoursWorkedPerWeek{get;set;};public DateTime DOB {get;set;}public bool? JobSeekersAllowance { get; set; }public bool? EmploymentSupportIncomeAllowance { get; set; }public bool? EmploymentSupportWRAGAllowance { get; set; }public bool? UniversalCredit { get; set; }public bool? LearningLoan24Plus { get; set; }public bool? PensionGuaranteeCredit { get; set; }public bool? CouncilTaxBenefit { get; set; }public bool? HousingBenefit { get; set; }public bool? WorkingTaxCredit { get; set; }public bool? FirstFullLevel2QualificationCourse { get; set; }public bool? FirstFullLevel3QualificationCourse { get; set; }public bool? IsOver65On31Aug(DateTime ageCalculationDate){if (this.DOB == null){return null;}// else we have a valuevar dt65YearsAgo = ageCalculationDate.AddYears(-65);return this.DOB <= dt65YearsAgo;}public bool? IsOver60On31Aug(DateTime ageCalculationDate){if (this.DOB == null){return null;}// else we have a valuevar dt60YearsAgo = ageCalculationDate.AddYears(-60);bool? result = this.DOB <= dt60YearsAgo;return result;}// Is  between 60 and 64 on 31 Augpublic bool? IsMid6064On31Aug(DateTime ageCalculationDate){if (this.DOB == null){return null;}return this.DOB.Value <= ageCalculationDate.AddYears(-60) &&this.DOB.Value > ageCalculationDate.AddYears(-65);}/// <summary>///     Is student between 16 and 18 on 31 August/// </summary>/// <param name="ageCalculationDate"></param>/// <returns></returns>/// <exception cref="InvalidOperationException">The <see cref="P:System.Nullable`1.HasValue" /> property is false.</exception>public bool? IsMid1618On31Aug(DateTime ageCalculationDate){if (this.DOB == null){return null;}return this.DOB.Value <= ageCalculationDate.AddYears(-16) &&this.DOB.Value > ageCalculationDate.AddYears(-19);}/// <summary>///     Is the student between 19 and 23 on 31 august/// </summary>/// <param name="ageCalculationDate"></param>/// <returns></returns>/// <exception cref="InvalidOperationException">The <see cref="P:System.Nullable`1.HasValue" /> property is false.</exception>public bool? IsMid1923On31Aug(DateTime ageCalculationDate){if (this.DOB == null){return null;}return this.DOB.Value <= ageCalculationDate.AddYears(-19) &&this.DOB.Value > ageCalculationDate.AddYears(-24);}public bool? IsWorkingLessThan37HoursPerWeek(){if (this.HoursWorkedPerWeek == null){return null;}// else we have a valuereturn this.HoursWorkedPerWeek.Value < 37 ? true : false;}public bool? IsUnemployed(){return (this.HoursWorkedPerWeek != null && this.HoursWorkedPerWeek.Value == 0) ||(this.JobSeekersAllowance != null && this.JobSeekersAllowance == true);}}
/// <summary>///     The course./// </summary>public class Course : CourseBase{public string Category { get; set; }public string CategoryCode { get; set; }public string SubCategory { get; set; }/// <summary>///     Is Vocational/// </summary>/// <exception cref="ArgumentNullException"></exception>/// <returns>true if the course is vocational, false otherwise</returns>/// <remarks>///     Determines if the course is vocational///     or not./// </remarks>public bool IsVocational(){var result = false;var c = this.SubCategory.Trim();if (c.Substring(0, 1).ToLower() == "q"){result = true;}}return result;}public string Title { get; set; }public double Code { get; set; }public string Reference { get; set; }public string StartDate { get; set; }/// <summary>///     Start DateTime/// </summary>/// <remarks>///     This is a datetime representation of the///     startdate/// </remarks>public DateTime? StartDateTime{get{// parse for normal dateif (DateTime.TryParse(this.StartDate, out var dt)){return dt;}// if we get here we have a string that may contain a th after a number// or may contain a day or may contain bothvar words = this.StartDate.Trim().Split(' ');// now we check the first wordif (words[0].EndsWith("th")){// if we get here the first word contains th// so we remove the th from the entrywords[0] = words[0].Substring(0, words[0].Length - 2);}else{// if we get here then the first word doesn't contain a th// in this case the second may do - note that in this case the first// word will be ignored as it's likely that the first word is a day nameif (words[1].EndsWith("th")){// if we get here the second word contains th// so we remove the th from the entrywords[1] = words[1].Substring(0, words[1].Length - 2);words[0] = "";}else{// if we get here we have no th so we try removing the first word// which we are assuming is a daywords[0] = "";}}// when we get here the words can be reconstructed in an attempt to parsevar modifiedDate = "";foreach (var s in words){modifiedDate = modifiedDate + s.Trim() + " ";}// now we have reconstructed the date we can try parsing itif (DateTime.TryParse(modifiedDate.Trim(), out dt)){return dt;}// else we drop out ie return null below// if we get here it's gone wrongreturn null;}set => this.StartDate = value == null ? "" : value.ToString();}public string EndDate { get; set; }public string Term { get; set; }public string Time { get; set; }public string Duration { get; set; }public decimal CourseFee{get => decimal.Round(this.courseFee, 2);set => this.courseFee = value;}/// <summary>///     Calculate half the course fee/// </summary>/// <exception cref="ArgumentOutOfRangeException">Invalid Course Fee Calculation</exception>public decimal HalfCourseFee{get{return decimal.Round(this.CourseFee / 2, 2);}}public decimal AdminFee{get => decimal.Round(this.adminFee, 2);set => this.adminFee = value;}public string Venue { get; set; }public string VenueCode { get; set; }public string Concessions { get; set; }/// <summary>///     Concessions Allowed/// </summary>/// <exception cref="ArgumentNullException"></exception>/// <returns>Are concessions allowed, true = they are, false = they are not, nothing = unknown</returns>/// <remarks>///     This attempts to determine if concessions are allowed for this course.///     This is only needed as the concessions field is stored as a string/// </remarks>public bool ConcessionsAllowed(){var result = false;// as the field is likely to either contain "true" or "false" so we assume it is one of thesevar c = this.Concessions.Trim();if (c.Substring(0, 1).ToLower().Equals("t") || c.Substring(0, 1).ToLower().Equals("y")){result = true;}else{if (!(c.Substring(0, 1).ToLower().Equals("f") || c.Substring(0, 1).ToLower().Equals("n"))){//Messages.Add("Invalid Concessions String)throw new ArgumentNullException("Invalid Concessions");}}return result;}public string Inactive { get; set; }public bool IsInactive(){var result = false;var c = this.Inactive.Trim();if (c.Substring(0, 1).ToLower().Equals("t") || c.Substring(0, 1).ToLower().Equals("y")){result = true;}else{if (!(c.Substring(0, 1).ToLower().Equals("f") || c.Substring(0, 1).ToLower().Equals("n"))){result = false;}}return result;}public string FundCode { get; set; }public string Notes { get; set; }public decimal ExamFee{get => decimal.Round(this.examFee, 2);set => this.examFee = value;}public decimal FullFee(){return this.CourseFee + this.ExamFee + this.AdminFee;}public abstract DateTime? AgeCalculationDate();#endregion}/// <summary>///     Age Calculation Date/// </summary>/// <returns>DateTime? corresponding to the start of the academic year</returns>/// <exception cref="ArgumentOutOfRangeException">///     Either startIndex or count is less than zero.-or- startIndex plus count///     specify a position outside this instance./// </exception>public DateTime? AgeCalculationDate(){//TODO: need to extract the date conversion into the library{if (this.StartDate == null || this.StartDate.Trim() == ""){return null;}if (DateTime.TryParse(this.StartDate.Trim(), out var dt)){// dt is the date of the start of the course// so now we need to calculate the start of the academic year// this is the last day of the previous academic yearreturn this.AcademicYearStartDate(dt).AddDays(-1);}// strips out the first wordvar testDate = this.StartDate.Remove(0, this.StartDate.FirstWord().Length).Trim();// gets rid of extraneous "th"testDate = testDate.Replace("th", "");if (DateTime.TryParse(testDate, out dt)){return this.AcademicYearStartDate(dt).AddDays(-1);}return null;}}// for any given year this returns the Start date of the academic yearpublic DateTime AcademicYearStartDate(DateTime date){if (date.Month >= 9){return new DateTime(date.Year, 9, 1);}{return new DateTime(date.Year - 1, 9, 1);}}}}
public interface ICourseFeeRule
{
Decimal CalculateCourseFee(ICourse course, IStudent student);
}
public class JSAAllConcessionCoursesIsFreeRule : IAdultCourseFeeRule
{

public decimal CalculateCourseFee(
IAdultCourse course,
IAdultStudent student)
{
return (student.JobSeekersAllowance == true) && course.ConcessionsAllowed()?0:course.FullFee();
}
}
public class NonVocationalConcessionOver65IsAdminFeeRule : IAdultCourseFeeRule
{
public decimal CalculateCourseFee(
IAdultCourse course,
IAdultStudent student)
{
// effectively disables rule without totally removing it
return course.FullFee();
}
}
public class Vocational24PlusLearningLoanConcessionRule : IAdultCourseFeeRule
{
public decimal CalculateCourseFee(
IAdultCourse course,
IAdultStudent student)
{
return (student.LearningLoan24Plus != null) && student.LearningLoan24Plus.Value && course.IsVocational() &&
course.ConcessionsAllowed()
? course.ExamFee
: course.FullFee();
}
public class CourseFeeCalculator
{

private readonly List<IAdultCourseFeeRule> rules = new List<IAdultCourseFeeRule>();
public CourseFeeCalculator()
{
this.rules.Add(new JSAAllConcessionCoursesIsFreeRule());
this.rules.Add(new ESAWragAllConcessionCoursesIsFreeRule());
this.rules.Add(new ESAIncomeNonVocConcessionCoursesIsAdminFeeRule());
// _rules.Add(new UnemployedUCConcessionMandatedRule());
this.rules.Add(new Vocational24PlusLearningLoanConcessionRule());
this.rules.Add(new VocationalMid1618ConcessionIsFreeRule());
this.rules.Add(new VocationalConcessionMid1923FirstFullLevel2IsFreeRule());
this.rules.Add(new VocationalConcessionMid1923FirstFullLevel3IsFreeRule());
this.rules.Add(new NonVocationalConcessionESAIsAdminOnlyRule());
this.rules.Add(new NonVocationalConcessionOver60NotFullTimeIsAdminPlusHalfCourseFeeRule());
// _rules.Add(new NonVocationalConcessionOver65IsAdminFeeRule());
// _rules.Add(new NonVocationalConcessionOver60PensionGuaranteeCreditIsAdminOnlyRule());
this.rules.Add(new NonVocationalConcessionCouncilTaxIsAdminRule());
this.rules.Add(new NonVocationalConcessionHousingBenefitIsAdminRule());
this.rules.Add(new NonVocationalConcessionWorkingTaxCreditIsAdminRule());
}

public decimal CalculateCourseFee(
IAdultCourse course,
IAdultStudent student)
{
var result = course.FullFee();
foreach (var rule in this.rules)
{
result = Math.Min(
rule.CalculateCourseFee(
course,
student),
result);
}
return result;
}
}

--

--

Senior Integrations Officer at Doncaster Council Any views expressed are entirely my own.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Steve Ellwood

Steve Ellwood

Senior Integrations Officer at Doncaster Council Any views expressed are entirely my own.