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. This is an implementation of the strategy pattern or, from another perspective it could be the replace conditionals with polymorphism. I prefer to think of this as the strategy pattern as it will be replacing conditional logic with a list.

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 record DiscountResult(decimal Total, List<string> Reasons);

public interface IDiscountRule
{
    int Priority { get; }
    DiscountResult Apply(Order order, DiscountResult current);
}

public sealed class VipStackRule : IDiscountRule
{
    public int Priority => 10;

    public DiscountResult Apply(Order order, DiscountResult current)
    {
        if (order.CustomerType != CustomerType.Vip) return current;

        var next = current.Total + 0.15m;
        return current with { Total = next, Reasons = new(current.Reasons) { "VIP" } };
    }
}

public sealed class CapAtThirtyPercentRule : IDiscountRule
{
    public int Priority => 100;

    public DiscountResult Apply(Order order, DiscountResult current)
    {
        var capped = Math.Min(current.Total, 0.30m);
        return current with { Total = capped };
    }
}

The calculator class will now order the rules and apply them to the order one by one

public sealed class DiscountCalculator
{
    private readonly IDiscountRule[] _rules;

    public DiscountCalculator(IEnumerable<IDiscountRule> rules)
        => _rules = rules.OrderBy(r => r.Priority).ToArray();

    public DiscountResult Calculate(Order order)
    {
        var result = DiscountResult.None;

        foreach (var rule in _rules) {
            result = rule.Apply(order, result);
        }

        return result;
    }
}

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.


Published 2026-02-25 >> 2026-02-25