Refactoring the business rules model: an exploration

In the previous post, I wrote about an emerging pattern to model business rules. These rules were modeled in a purely object-oriented fashion. It uses an interface and a number of implementations. In the current post, I want to explore where we can get from here.

Recap

The previous post ended with a pattern to model business rules. The pattern consists of a role interface that describes the purpose of the business rule, a number of classes that implement each specific rule, and also a number of implementations that take instances of the interface as constructor arguments. Here is one example of the previous post to demonstrate this:

// This defines the interface that each rule must implement.
public interface IDiscountCalculator
{
    decimal Calculate();
}

// Here are some implementations of business rules

public class ApplyHighestDiscount : IDiscountCalculator
{
    private readonly IDiscountCalculator[] mInner;

    public ApplyHighestDiscount(params IDiscountCalculator[] inner)
    {
        mInner = inner;
    }

    public decimal Calculate()
        => mInner
            .Select(i => i.Calculate())
            .Max();
}

public class FirstTimeCustomer : IDiscountCalculator
{
    private readonly Account _account;
    private readonly decimal _discount;

    public FirstTimeCustomer(Account account, decimal discount)
    {
        _account = account;
        _discount = discount;
    }

    public decimal Calculate() 
        => _account.IsFirstTimeCustomer()
            ? _discount
            : 0.0m;
}

public class SpecialItemDiscount : IDiscountCalculator
{
    private readonly Item _item;
    private readonly Cart _cart;
    private readonly decimal _discount;

    public SpecialItemDiscount(Cart cart, Item item, decimal discount)
    {
        _cart = cart;
        _item = item;
        _discount = discount;
    }

    public decimal Calculate() 
        => _cart.Contains(_item)
            ? _discount
            : 0.0m;
}

// Next, some ways in which business rules can be combined

public class ApplyHighestDiscount : IDiscountCalculator
{
    private readonly IDiscountCalculator[] mInner;

    public ApplyHighestDiscount(params IDiscountCalculator[] inner)
    {
        mInner = inner;
    }

    public decimal Calculate()
        => mInner
            .Select(i => i.Calculate())
            .Max();
}

public class SummedDiscount : IDiscountCalculator
{
    private readonly IDiscountCalculator[] mInner;

    public SummedDiscount(params IDiscountCalculator[] inner)
    {
        mInner = inner;
    }

    public decimal Calculate()
        => mInner
            .Select(i => i.Calculate())
            .Sum();
}

// Combine business rules in the entry point

var account = new Account();
var cart = new Cart();
var calculator = new SummedDiscount(
    new FirstTimeCustomer(account, 5.0m),
    new ApplyHighestDiscount(
        new SpecialItemDiscount(cart, new Item(), 0.1m),
        new SpecialItemDiscount(cart, new Item(), 1

As you see, every business rule or combination of rules requires a class. This makes the design very flexible. Changing requirements almost never require changing existing code (except for the part where the rules are instantiated): a new requirement will be added as a new class. Since existing code is not touched, the behavior application as a whole stays much more stable.

The design is also pure in object-oriented terms. Everything is an object and data is encapsulated. So if you have an object-oriented language, then this design is always applicable.

However, there are also a drawback: every rule requires its own class. No matter how simple the rule is, we need to mold it into a class in order to satisfy the interface constraint. This is also what the previous code shows: there is a lot of boilerplate code to create classes and constructors. Can we do better than this? Can we reduce the boilerplate?

First refactoring effort

At this point, we can make an observation: if an interface has a single method, then it can always be refactored into a lambda. An interface with a single member and a lambda function are isomorphisms: you can convert back and forth without losing information.

Let’s see what we’ll get if we blindly do this:

var discount = new[]
{
    account.IsFirstTimeCustomer() ? 5.0m : 0.0m,
    new[]
    {
        cart.Contains(new Item()) ? 0.1m : 0.0m,
        cart.Contains(new Item()) ? 1.0m : 0.0m
    }.Max()
}.Sum();

Although it is much shorter since all classes are gone, now the code is not meaningful anymore. I mean, why would someone take a Max() on some lines, and Sum() on others? Reading this code is painful, and the code is not flexible either. Even though the code is much shorter, there is also duplication: the cart lookup is written out twice. Clearly, I would not like working in a code base that is setup like this.

But wait, what would it look like if we, instead of a class, provide a method for each business rule? Let’s see how this turns out (I’ll use the same names for the methods as the classes above to indicate the similarity, even though these are not very good method names):

public static decimal SummedDiscount(params decimal[] discounts)
    => discounts.Sum();

public static decimal ApplyHighestDiscount(params decimal[] discounts)
    => discounts.Max();

public static decimal FirstTimeCustomer(Account account, decimal discount)
    => account.IsFirstTimeCustomer() ? discount : 0.0m;

public static decimal SpecialItemDiscount(Cart cart, Item item, decimal discount)
    => cart.Contains(item) ? discount : 0.0m;

// Entry point
var discount = SummedDiscount(
    FirstTimeCustomer(account, 5.0m),
    ApplyHighestDiscount(
        SpecialItemDiscount(cart, new Item(), 0.1m),
        SpecialItemDiscount(cart, new Item(), 1.0m)));

This is much better readable, since the intention was named by the different methods.

However, this and the previous code samples turn out to be deeply flawed. There are calculation functions (in the second example with good names), but they do not model the domain. You cannot, for example, have a Checkout class that takes this calculation as a dependency. You can only perform the calculation, but not pass it around.

Passing calculations around

If we want to pass a calculation around, then we need to mode that as such. In C#, we can use a Func<decimal> to replace the IDiscountCalculator. It then looks like:

public static Func<decimal> SpecialItemDiscount(Cart cart, Item item, decimal discount)
{
    return () => cart.Contains(item)
            ? discount
            : 0.0m;
}

public static Func<decimal> FirstTimeCustomer(Account account, decimal discount)
{
    return () => account.IsFirstTimeCustomer()
        ? discount
        : 0.0m;
}

public static Func<decimal> ApplyHighestDiscount(params Func<decimal>[] discounts)
{
    return () => discounts.Max(d => d());
}

public static Func<decimal> SummedDiscount(params Func<decimal>[] discounts)
{
    return () => discounts.Sum(d => d());
}

// Entry point
Func<decimal> calculator = SummedDiscount(
    FirstTimeCustomer(account, 5.0m),
    ApplyHighestDiscount(
        SpecialItemDiscount(cart, new Item(), 0.1m),
        SpecialItemDiscount(cart, new Item(), 1.0m)));

This time, the functions don’t return the calculation instead of the result. The static factory methods serve the same purpose as the constructors in the IDiscountCalculator variant, except now the overhead of creating a class for every rule is greatly reduced. (Under the hood, the compiler still creates a temporary class to capture the lambda, but at least this is automated now.)

This approach has the big advantage that the code is much more terse. But I see a few drawbacks to this approach:

  • The factory methods return a Func<decimal>, which is defined in-line. In C#, functions that return functions are pretty hard to interpret.
  • At the entry point where the factory methods are called, it is also difficult to understand what is going on. At first sight, it looks like that a calculation is made, but in fact only a combined rule is built up.
  • We changed type safety in a subtle way. In the version which uses the IDiscountCalculator interface, only objects that derived from IDiscountCalculator were usable. In case of a class that doesn’t implement IDiscountCalculator, we would need to add an adapter class. In the Func<decimal> example, any function that returns a decimal and takes zero arguments is fine. So on one hand, this saves adding conversions, but on the other hand, it removes prevention of using unwanted functions. (We could greatly simplify this by encapsulating the decimal by a Price or Discount type, but you get the point.)

More explicit naming

We could resolve these two issues by separating the factory function from the implementation and introducing a delegate. That looks like:

public delegate decimal DiscountCalculator();

public static DiscountCalculator CreateSpecialItemDiscount(Cart cart, Item item, decimal discount)
{
    return SpecialItemDiscount;

    decimal SpecialItemDiscount()
        => cart.Contains(item)
            ? discount
            : 0.0m;
}

public static DiscountCalculator CreateFirstTimeCustomer(Account account, decimal discount)
{
    return FirstTimeCustomer;

    decimal FirstTimeCustomer()
        => account.IsFirstTimeCustomer()
            ? discount
            : 0.0m;
}

public static DiscountCalculator CreateApplyHighestDiscount(params DiscountCalculator[] discounts)
{
    return ApplyHighestDiscount;

    decimal ApplyHighestDiscount()
        => discounts.Max(d => d());
}

public static DiscountCalculator CreateSummedDiscount(params DiscountCalculator[] discounts)
{
    return SummedDiscount;

    decimal SummedDiscount() => discounts.Sum(d => d());
}

// Entry point
DiscountCalculator calculator = CreateSummedDiscount(
    CreateFirstTimeCustomer(account, 5.0m),
    CreateApplyHighestDiscount(
        CreateSpecialItemDiscount(cart, new Item(), 0.1m),
        CreateSpecialItemDiscount(cart, new Item(), 1.0m)));

So now the interface has basically been replaced by a delegate. The factory methods are now also changed, so that no anonymous functions are used, but references to named functions. Sadly enough, this again increases the code a lot. Where in the previous samples every factory method was a one-liner, now they all need an additional local function.

After all, we’re still cramming a functional approach into an object-oriented language, even though it supports lambdas and function objects. There is always a price to pay.

Going functional

If we really want to express this in a terse way, I think we should use a functional language for that. Here’s the implementation in F#:

let SpecialItemDiscount cart item discount =
    if Contains cart item
    then discount 
    else 0.0m

let FirstTimeCustomer account discount =
    if IsFirstTimeCustomer account
    then discount
    else 0.0m

let SummedDiscount = List.sum

let ApplyHighestDiscount = List.max

// Entry point
let CalculateDiscount () =
    SummedDiscount [
        FirstTimeCustomer account 5m; 
        ApplyHighestDiscount [
            SpecialItemDiscount cart item 0.1m;
            SpecialItemDiscount cart item 1.0m]]

This variation has the least ceremony. That is because F#, as a functional language, makes it easy to apply higher-order functions (functions that return functions).

Leave a Reply

Your email address will not be published. Required fields are marked *