Managing Checkout Discount Rules without Code

Thu Mar 10 20228 min read

Why maintaining Business Rules in code is a nightmare

Every engineer who has worked on a product, would have introduced some business logic as part of the development process.

All logic start simple with few rules, which seem harmless to be implemented using few lines of codes. But as the project grows and business rules get more complicated, what started as few lines of code, becomes more sinister.

Finally, a time comes when adding or modifying a simple condition in the business rules becomes a month-long project. Both Engineering and Business teams are unhappy with the product and rigidity of the software. The original team that started the project would have moved on already and the new teams have no idea what is going.

Finally, the decision to re-write is taken and the whole process starts all over again.

Decoupling Business Rules and Code

To avoid the above scenario it is very important to de-couple Business Rules from Code.

If business rules and engineering efforts are intertwined, it will always take development effort to make changes. Every change will have to go through the development cycle which defeats the whole purpose of changing business rules.

In an ideal scenario, business users should be free to add / modify the business rules without any assistance from Engineering team.

Engineering team should only be involved in development of new functionality. Business teams should be able to change or add new rules without any intervention from Engineering team.

Forward Chaining Rule Engines

An inference engine using forward chaining applies a set of rules and facts to deduce conclusions, searching the rules until it finds one where the IF clause is known to be true. The process of matching new or existing facts against rules is called pattern matching, which forward chaining inference engines perform through various algorithms, such as Linear, Rete, Treat, Leaps etc.

When a condition is found to be TRUE, the engine executes the THEN clause, which results in new information being added to its dataset.

In other words, the engine starts with a number of facts and applies rules to derive all possible conclusions from those facts. This is where the name “forward chaining” comes from – the fact that the inference engine starts with the data and reasons its way forward to the answer, as opposed to backward chaining, which works the other way around.

Building Discount Engine for Checkout

To demonstrate how we can isolate business rules from code, we will try to build a simple discount engine for eCommerce Checkout.

The complete code can be found below

Discount Engine for Checkout

Starting simple

We will start with a simple rule, and then keep updating the rules in every iteration.

Model and First Flow Chart

There are 2 important parts to the project

Model

This is the Domain Object which exposes various functionality and properties.

This should be done as a team where the Business team puts forward the possible attributes that would be needed for the rules and the Engineering team makes sure they expose it as part of the interface.

Business Rules flowchart

This is a more visual representation of the rules which helps to document the thought process and the evolution of the rules over time.

It is important to maintain it as a living document, so that new users can understand the logic behind the rules, and it will be easier for them to reason with the changes.

Using Trool as our Rule Engine

We will be using Trool as our Rule Engine.

Trool is easy to set up and use, once you understand the basics. A very detailed explanation is provided here

First Rule - Flat 20% for new users

To demonstrate how we can decouple rules from code, lets try to set up our first rule for flat 20% discount.

The Rule Spreadsheet looks like this

Flat 20 percent for new user

Carts is our Model which will be defined in code. As part of the first setup, both Engineering and Business teams must come together to decided on the structure of the model.

Once agreed upon, the Model should be represented by an Interface

The interface can be found in src/models/Cart.ts

export interface Cart {
    userId: number;
    name: string;
    age: number;
    isNewUser: boolean;
    items: Array<{ itemId: number, itemName: string; itemQty: number; itemUnitPrice: number }>;
    coupon: string;
    totalBeforeDiscount: number;
    discount: number;
    payableAmount: number;
    applyDiscountPercent: (percent: number) => void;
    applyDiscountAbsolute: (amount: number) => void;
}

The properties and modifiers exposed allows the business users to write rules without any help from the engineering team.

Once the model is agreed and coded, it is time to set up the actual rule engine.

We name the rule as checkout-rule and wire-up the necessary parts. Code is available at src/rules/checkout-rule.ts

First we set up the Facts mapping

interface IFactsHolder {
    Carts: Cart[]; // The key should match the table name in Rules File
}

Next we provide the path for the csv file.

Note : We are hard-coding the path here as part of the POC, but in actual production this file should be stored outside the code base.

const csvFilePath = '../rule-files/CheckoutRules.csv';

Finally, wire everything together to build the rule engine

export default async (cart: Cart): Promise<Cart> => {
    try {
        const csvFilePathFull = path.join(__dirname, csvFilePath);
        const facts: IFactsHolder = {
            Carts: [cart],
        };
        const engine = await trool(csvFilePathFull);
        const updatedFacts = engine.applyRules<IFactsHolder>(facts);

        return  updatedFacts.Carts[0];
    } catch (e) {
        console.error('Failed to run rules...', e);
        throw e;
    }
}

As we can see there is no business logic anywhere, which makes this whole thing so powerful.

To make sure we don’t break stuff when we add new rules, having automated tests built-in is a necessity. As part of the POC we will be adding Unit tests to check the business logic, but in actual production these should be run in isolated Sandbox environment where rules can be tested for regression before releasing.

Verifying our simple rule using unit tests

it('should give flat 20% discount to new user', async () => {
        const newUserCart = createCart({
            userId: 1,
            name: 'John Doe',
            age: 37,
            isNewUser: true,
            coupon: '',
            items: [{itemId: 1, itemQty: 2, itemName: 'Sample A', itemUnitPrice: 100}]
        });

        const cartResult = await calculateCheckoutDiscount(newUserCart);

        expect(cartResult.totalBeforeDiscount).toBe(200);
        expect(cartResult.discount).toBe(40);
        expect(cartResult.payableAmount).toBe(160);
    });

First Rule Test Results

How cool is that !!!

We didn’t have to do any code changes for those rules. Business users can change the discount percentage anytime without any engineering intervention. We have regression suite in place, which makes sure we don’t break existing business logic by mistake.

Second Rule - Flat 10% for existing users with order value greater than 1000

The first discount rule become very popular, and we have exponential growth in new sign-ups. But now it was getting difficult to make the existing users transact again on the website.

So Business decides to add a new offer - Flat 10% for existing users with order value greater than 1000.

As this was supposed to be an experiment, changes had to be made fast, and in case it didn’t work, it had to be reverted soon.

Involving development team meant at least development cycles till the results can be fully understood and acted upon.

But with Business Rules decoupled, it gives the power to the business users to make these changes without involving the Engineering team.

Now the rule flow looks like this

Second Flowchart with Order Value Rule

The Rule Spreadsheet is updated to reflect the additional rules

Second Rule with Order Value

Updating our regression suite to make sure the new rules are working as expected

    it('should give 10% discount to existing user if order value is greater than 1000', async () => {
        const newUserCart = createCart({
            userId: 1,
            name: 'Jane Doe',
            age: 37,
            isNewUser: false,
            coupon: '',
            items: [{itemId: 1, itemQty: 2, itemName: 'Sample B', itemUnitPrice: 1000}]
        });

        const cartResult = await calculateCheckoutDiscount(newUserCart);

        expect(cartResult.totalBeforeDiscount).toBe(2000);
        expect(cartResult.discount).toBe(200);
        expect(cartResult.payableAmount).toBe(1800);
    });

Finally, running our tests to make sure everything still works…

Second Rule Test Results

Adding Coupons

One last cool thing we want to do is to add coupons.

Now business wants to target users with cart value lower than 1000, and they want to give out coupons. The business users can make their own coupon codes without any engineering intervention and set the rules for them.

The updated rules flow looks like

Coupon Rule Flow

The rules’ spreadsheet now looks like

Coupon Rules Spreadsheet

Updating our regression suite to make sure coupon code works as expected

    it('should give 5% discount to existing user if WELCOMEBACK coupon is used', async () => {
        const newUserCart = createCart({
            userId: 1,
            name: 'Jane Doe',
            age: 37,
            isNewUser: false,
            coupon: 'WELCOMEBACK',
            items: [{itemId: 1, itemQty: 1, itemName: 'Sample Y', itemUnitPrice: 500}]
        });

        const cartResult = await calculateCheckoutDiscount(newUserCart);

        expect(cartResult.totalBeforeDiscount).toBe(500);
        expect(cartResult.discount).toBe(25);
        expect(cartResult.payableAmount).toBe(475);
    });

Finally, running our tests to make sure everything still works…

Coupon Regression Tests

So, we can see that decoupling business rules from code makes everyone’s life so much simpler. We can build more complex rules based on our business needs without adding a single line of code.

Hope you can use this practical example in your next project. All source code for this can be found here

rules

forward chaining

trool

checkout

discount

Built using Gatsby