Design Pattern: Rules Engine – Simple Implementation with PHP

I came across some old codes that I wrote as a tech test solution while I was applying a software engineer role for an ecommerce company few years back in 2016. I remember as it was one of the more interesting code kata amongst many tech test that I had done. It was meant to be a straight-forward 2 hours exercise solving what it looked like an easy little problem.


## PROBLEM

[REDACTED] wants to offer a wide variety of discounts to our customers.

Your task is to develop a system to allow for discounts to be applied
to a customers cart. The system should be flexible, allowing
for the creation of new discount types easily.

Given these products:

SKU           | Name                         | Price
--------------|------------------------------|----------
9325336130810 | Game of Thrones: Season 1    | $39.49
9325336028278 | The Fresh Prince of Bel-Air  | $19.99
9780201835953 | The Mythical Man-Month       | $31.87
9781430219484 | Coders at Work               | $28.72
9780132071482 | Artificial Intelligence      | $119.92
--------------|------------------------------|----------

Initially we would like to offer our customers these discounts:

* Buy 10 or more copies of The Mythical Man-Month, and receive them at the discounted price of $21.99
* We would like to offer a 3 for the price of 2 deal on Coders at Work. (Buy 3 get 1 free);
* Customers who purchase Game of Thrones: Season 1, will get The Fresh Prince of Bel-Air free.


Examples:

Products in cart: 9780201835953 x 10, 9325336028278
Expected total: $239.89

Products in cart: 9781430219484 x 3, 9780132071482
Expected total: $177.36

Products in cart: 9325336130810, 9325336028278, 9780201835953
Expected total: $71.36


Example interface:

$cart = new Cart($pricingRules);
$cart->addProduct("9780201835953");
$cart->addProduct("9781430219484");
$cart->total();


* use any language you wish
* do not use any external libraries (Zend etc.)
* do not use a database
* do not create a GUI (we are only interested in your implementation)
* try not to spend more than two hours on this, we don't want you working all day!

This is actually some design pattern exercise meant to show a developer skills in decoupling classes. Few goals came across my mind upon designing a framework for the solution:

  • Checkout not need to know about product prices and its complex pricing strategies
  • Adding a new pricing rule in future shall be straightforward and flexible

For inspiration, I read Magento source code for handling pricing rules and Martin Fowler’s handsomely written article: Should I use a Rules Enginehttps://martinfowler.com/bliki/RulesEngine.html

What rules engine design pattern trying to solve:

  • Separate individual rules from rules processing logic
  • Allow new rules to be added without the need for changes in the rest of the system

This aligns to “Open / Closed Principle” that software entities should be open to extension but closed to modification and “Single Responsibility Principle” in which a class or method should only have one reason to change.

The Implementation

I implemented a very simple rule engine in PHP. It’s basically set up a bunch of rule objects storing its conditions and actions stored in a collection, and a decider to run through them to evaluate the conditions and execute the actions.

Rules, Condition and Action

Rule is basically just an object with a Condition and an Action class interface.

<?php

namespace TheNileTechTest\Model\Rule;

use TheNileTechTest\Model\Rule\Condition\ConditionInterface;
use TheNileTechTest\Model\Rule\Action\ActionInterface;

class Rule
{
    private $condition;
    private $action;

    public function __construct(ConditionInterface $condition, ActionInterface $action)
    {
        $this->condition = $condition;
        $this->action = $action;
    }

    public function getCondition()
    {
        return $this->condition;
    }

    public function getAction()
    {
        return $this->action;
    }
}

Thanks for PHP 5 back when I was writing this code, it does not have a return type feature, so you can’t really tell from below the just the code on what it supposed to be doing.

We have an Order object (not shown) which containing the list of products ordered and its quantity stored in an array.

The condition interface requires the class implementation to return a boolean (true or false) after checking the state of the Order object. The action interface allow the class implementation to return an integer (negative if it’s a discount) on the cents price changes. They’re both strictly is not allowed to modify the Order object (I use a hacky trick to achieve this! ~ #php).

<?php

namespace TheNileTechTest\Model\Rule\Condition;

use TheNileTechTest\Model\Order;

interface ConditionInterface
{
    public function isEligible(Order $order);
}
<?php

namespace TheNileTechTest\Model\Rule\Action;

use TheNileTechTest\Model\Order;

interface ActionInterface
{
    public function calculate(Order $order);
}

Decider

We would also have the decider logic that simply to run through the rules to evaluate the conditions and execute the actions. Since this is just a code kata exercise, I simply cram the decider logic into the Order class.

(See the nifty trick of using lazy and expensive deep copy to prohibit these Action and Condition classes to mutate the Order object that was being passed) ~ #php

<?php

namespace TheNileTechTest\Model;

use TheNileTechTest\Model\Rule\Rule;

class Order
{
    /**
     * @var OrderItem[]
     */
    private $orders;

	...
		
    public function calculateTotal(array $rules)
    {
        // Apply discount rules
        /** @var Rule $rule */
        $discount = 0;
        foreach ($rules as $rule) {
            $condition = $rule->getCondition();
            $action = $rule->getAction();
            $clonedOrder = clone unserialize(serialize($this));
            if ($condition->isEligible($clonedOrder)) {
                $discount += $action->calculate($clonedOrder);
            }
        }

        // Calculate final price
        $total = 0;
        foreach ($this->orders as $order) {
            $total += $order->getQuantity() * $order->getProduct()->getPrice();
        }
        return $total - $discount;
    }
}

Implementation

We setup list of the existing products and the rules in a memory collection with. Prices are stored in cents because PHP floats have limited precision. (https://www.php.net/manual/en/language.types.float.php) ~ #php

<?php

require_once __DIR__ . '/vendor/autoload.php';

use TheNileTechTest\Cart;
use TheNileTechTest\Model\Rule\Condition\BuyProductWithOrMoreQty;
use TheNileTechTest\Model\Product;
use TheNileTechTest\Model\Rule\Action\ApplyBuyXGetY;
use TheNileTechTest\Model\Rule\Action\UseFixedPrice;
use TheNileTechTest\Model\Rule\Rule;
use TheNileTechTest\Repository\ProductRepository;
use TheNileTechTest\Repository\RuleRepository;

$productRepository = new ProductRepository([
    "9325336130810" => new Product("9325336130810", "Game of Thrones: Season 1", 3949),
    "9325336028278" => new Product("9325336028278", "The Fresh Prince of Bel-Air", 1999),
    "9780201835953" => new Product("9780201835953", "The Mythical Man-Month", 3187),
    "9781430219484" => new Product("9781430219484", "Coders at Work", 2872),
    "9780132071482" => new Product("9780132071482", "Artificial Intelligence", 11992),
]);

$ruleRepository = new RuleRepository([
    new Rule(new BuyProductWithOrMoreQty("9780201835953", 10), new UseFixedPrice("9780201835953", 2199)),
    new Rule(new BuyProductWithOrMoreQty("9781430219484", 2), new ApplyBuyXGetY("9781430219484", 2, "9781430219484", 1)),
    new Rule(new BuyProductWithOrMoreQty("9325336130810", 1), new ApplyBuyXGetY("9325336130810", 1, "9325336028278", 1))
]);

// Let's run simple tests
$cart = new Cart($productRepository, $ruleRepository);
$cart->addProduct("9780201835953", 10);
$cart->addProduct("9325336028278");
assertTotal($cart, 239.89);

$cart = new Cart($productRepository, $ruleRepository);
$cart->addProduct("9781430219484", 3);
$cart->addProduct("9780132071482");
assertTotal($cart, 177.36);

$cart = new Cart($productRepository, $ruleRepository);
$cart->addProduct("9325336130810");
$cart->addProduct("9325336028278");
$cart->addProduct("9780201835953");
assertTotal($cart, 71.36);

function assertTotal(Cart $cart, $expected)
{
    $total = $cart->total() / 100;
    $pass = $total === $expected ? 'WOOT!! :)' : 'NOPE!! :(';
    echo $pass . " - Total: $$total. Expected: $$expected" . PHP_EOL;;
}

This implementation is fairly naive and linear. It doesn’t take consideration into more complex rules chaining when the condition of a rule is based on previous rule being executed, ie. only implement “Buy 1 and Get 1 Free” deals twice per cart, etc. It also does not implement state management mechanism.

After all, I was only allowed 2 hours for this tech test.

See the full implementation in Github: https://github.com/stellalie/thenile-techtest

Leave a Reply

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