The Rules Engine Pattern
As software engineers, whether professional or aspiring, our primary responsibility is to translate business rules into logic that a computer can understand. This often involves modeling problem domains—typically using classes—and writing business logic that mirrors real-world rules. However, as business rules evolve, our code must adapt, which introduces significant complexity.
The Issue with Changing Business Rules
A common sentiment among software engineers is that good code should be easy to modify. Let's consider a simple example: calculating shipping costs for an e-commerce platform. The current rules might look like this:
Now, imagine the business introduces a new rule: domestic shipping is half-price during December. A quick and straightforward approach might involve adding another condition to the existing if statement.
At first glance, this may seem manageable. However, if the business later extends the discount to international orders, the code becomes cluttered and harder to maintain. This is a clear sign that we need to refactor the code.
The Open-Closed Principle
If you've participated in a technical interview in the last decade, you're likely familiar with the SOLID principles. One of the most important, yet often misunderstood, principles is the Open-Closed Principle (OCP). It states that a system should be open for extension but closed for modification. In simple terms, you should be able to add new functionality without altering the existing code.
Does our current shipping calculation code adhere to this principle? Unfortunately, no. Every time a new rule is introduced, we modify the existing logic, increasing the risk of introducing bugs. Moreover, such code is rarely well-covered by up-to-date unit tests, making changes even riskier.
Introducing the Rules Engine Pattern
To address this issue, we can apply the Rules Engine pattern to improve our shipping logic.
Defining a Rule Interface
We start by defining an interface that outlines what a rule should look like in our system. This interface will contain two key methods:
The decision to separate the eligibility check from the calculation is based on personal preference and may vary. If you find it more practical to combine both into a single method, that's perfectly fine. My preference is to keep them separate because it allows the calculate method to safely assume that the eligibility check has passed, erasing the overhead of providing fallback values.
Implementing the Rules Engine
Next, we create a Rules Engine that processes a collection of rules and determines the final shipping cost.
Recommended by LinkedIn
Defining Shipping Rules
We now define each shipping rule as an individual class, for example:
With this approach, we gain several benefits:
The Benefits of Modularity: Adding New Rules
With the Rules Engine in place, adding new rules becomes a breeze. For example, suppose the business introduces a birthday discount that halves the free shipping threshold. We can easily add a new rule without modifying the existing ones.
Since these rules have been written as pure functions (they have no side effects), they are easy to test, which makes our system more maintainable and less prone to bugs. Utilising a rule engine isn't a guarantee that you have function purity, but writing your rules with purity in mind will significantly aid testability.
Rule Evaluation Strategies
The implementation described here is relatively simple and designed for a basic problem domain. Depending on the complexity of the problem you're solving, the strategy for rule evaluation may vary.
In my case, I opted for a "lowest applicable price" evaluation strategy, where all rules are evaluated and the lowest eligible price is selected. However, there are other strategies you might consider, such as aggregating the outputs of all applicable rules (e.g., calculating surcharges) or running rules in a specific order or priority.
Conclusion
By implementing the Rules Engine pattern, we can model complex business logic as a series of modular rules. This reduces cyclomatic complexity and makes our code more maintainable. While design patterns should be applied with care, this approach is highly beneficial when your code:
Making your code easier to work with reduces friction, accelerates development, and speeds up delivery—sometimes making the difference between success and failure for a business.