Refactoring Complex Business Rules
In this note, we are going to refactor some business logic to align with OCP (Open for extension and Closed for change, design principal)
You have this code:
public decimal CalculateDiscount(Order order)
{
if (order.CustomerType == CustomerType.Employee) return 0.30m;
if (order.CustomerType == CustomerType.Vip) return 0.15m;
if (order.Total > 1000m) return 0.10m;
return 0m;
}
New requirements arrive weekly; seasonal promos, partner discounts, regional rules, stacking rules (sometimes), etc.
What refactor would we do to satisfy OCP without creating a class explosion?
We are going to move the list of rules outside of the discount calculator by creating a set of rules that all implement IDiscountRule
public interface IDiscountRule
{
// returns null if rule not applicable
decimal? TryGetDiscount(Order order);
}
Concrete rules (yes, these are small classes—but you usually end up with ~5–20, not hundreds)
public sealed class EmployeeDiscountRule : IDiscountRule
{
public decimal? TryGetDiscount(Order order) =>
order.CustomerType == CustomerType.Employee ? 0.30m : null;
}
public sealed class VipDiscountRule : IDiscountRule
{
public decimal? TryGetDiscount(Order order) =>
order.CustomerType == CustomerType.Vip ? 0.15m : null;
}
public sealed class LargeOrderDiscountRule : IDiscountRule
{
public decimal? TryGetDiscount(Order order) =>
order.Total > 1000m ? 0.10m : null;
}
And the calculator becomes stable
public sealed class DiscountCalculator
{
private readonly IReadOnlyList<IDiscountRule> _rules;
public DiscountCalculator(IEnumerable<IDiscountRule> rules)
=> _rules = rules.ToList();
public decimal CalculateDiscount(Order order)
{
foreach (var rule in _rules)
{
var discount = rule.TryGetDiscount(order);
if (discount is not null) return discount.Value; // first-match wins
}
return 0m;
}
}
Now adding a new discount is:
- add a new IDiscountRule
- register it in DI (no code change to calculator)
Stacked Rules
That changes composition slightly. Two common policies:
A) “Best discount wins”
Compute all applicable and take max
public decimal CalculateDiscount(Order order) =>
_rules.Select(r => r.TryGetDiscount(order) ?? 0m).Max();
B) “Stacking with caps”
Return a richer result
public interface IDiscountRule
{
DiscountResult Apply(Order order, DiscountResult current);
}
public record DiscountResult(decimal Total, List<string> Reasons);
Why this avoids class explosion
You’ll have “one class per rule type,” not per combination.
You don’t create VipInRegionXInSummerDiscountCalculator, you create VipDiscountRule, RegionalDiscountRule, SeasonalPromoRule and compose them.