
Rules engine
Bronislav Klučka, Aug 03, 2025, 09:01 PM
Design patterns are like tools in your workshop. They can greatly simplify both writing code and the code itself. Today, we will start with examples of patterns specifically related to a pattern called Rules Engine.
The rules engine allows you to improve both separation of concerns and high cohesion and low coupling, thereby significantly reducing code complexity.
Let's start with two examples.
Example 1: spam filter
The task is to add code to the application that will evaluate whether an email is spam or not based on the following rules.
- We'll start with a score of 0.
- If the subject of the email contains a word that is in our spam dictionary, we increase the score by 2 points.
- If the text of the email contains a word that is in our spam dictionary, we increase the score by 1 point.
- If there is a URL in the email text, we will increase the score by 1 point.
- If there is a URL in the email text and the domain is less than six months old, we will increase the score by 1 point.
- If the sender of the email is in our contact list, we will reduce the score by 3 points.
- If the resulting score is greater than 0, the email is spam.
Pseudo-implementation
interface Email {
from: string;
subject: string;
text: string;
}
function isSpam(email: Email): boolean {
let score = 0;
if (containsSpamWord(email.subject)) {
score += 2;
}
if (containsSpamWord(email.text)) {
score += 1;
}
if (containsUrl(email.text)) {
score += 1;
}
const allUrl = extractAllUrl(email.text);
const found = allUrl.find(url => getDomainAgeInMonths(url) < 6);
if (found) {
score += 1;
}
if (emailInContacts(email.from)) {
score -= 3;
}
return score > 0;
}
function containsSpamWord(text: string): boolean { ... }
function containsUrl(text: string): boolean { ... }
function extractAllUrl(text: string): string[] { ... }
function getDomainAgeInMonths(url: string): number { ... }
function emailInContacts(email: string): boolean { ... }
The above code will work, but try to imagine how it could be further developed: add a SPF and DKIM checks, attachment check, reverse DNS lookup, domain blocklist check, sender reputation....
Over time, the code becomes a confusing jumble of different conditions and evaluations. Various conditions are evaluated, different parts of emails are evaluated, and scores are manipulated in between...
The code will become so large that it will have its own gravitational field, and four moons will orbit it...
Example 2: price calculation
Imagine an e-commerce application in which you have to calculate the price of a product for users based on the following rules:
- If the user is logged in (has an account), we will provide a 5% discount.
- We will give you a 4% discount on every 10th order.
- We will offer a 3% discount to users within the EU.
- If the user enters a discount code, we will give a 5% discount.
- If the user purchases 3 or more items, we will offer a 6% discount.
- Every Saturday we offer a 7% discount.
Pseudo-implementace
interface User {
isLoggedIn: boolean;
address: UserAddress;
}
interface UserAddress {
country: string;
}
interface UserOrderHistory {
getOrdersCount: number;
}
interface OrderItem {
price: number;
count: number;
}
function isCountryInEU(country: string): boolean { ... }
function isValidCode(code: string): boolean { ... }
function countPrice(item: OrderItem, user: User, orderHistory: UserOrderHistory, code: string): number {
let newPrice = item.price;
if (user.isLoggedIn) {
newPrice *= 0.95;
}
if ((orderHistory.getOrdersCount > 0) && (orderHistory.getOrdersCount % 10 === 0)) {
newPrice *= 0.96;
}
if (isCountryInEU(user.address.country)) {
newPrice *= 0.97;
}
if (isValidCode(code)) {
newPrice *= 0.97;
}
if (item.count >= 3) {
newPrice *= 0.95;
}
if ((new Date()).getDay() === 6) {
newPrice *= 0.93;
}
return newPrice;
}
The above is even worse than in Example 1. Not only does it have a similar problem, where it can grow without restriction into a complete mess of conditions, but it also has another, even worse feature.
For some reason, the code for calculating the price knows the user's structure, addresses, has access to order history... This is exactly the kind of example where a mega function/class is created, which breaks no matter where you touch it, because it refers to the details of a large number of other objects with which it actually has nothing to do. Imagine that it may also be a user of your partners (B2B) and you will adjust the discount based on the partner to whom the user belongs. And this functionality knows detailed information about another type of object (Partner).
Refactoring into an object and dividing a large function into a sequence of small functions does not solve the problem at all.
Rules engine.
A rules engine is a pattern that allows business rules and their definitions to be separated from their execution during program runtime.
The rules engine is defined by three parameters:
- Input - data to be evaluated
- Rules - a sequence of rules to be executed
- Output - evaluation result
The essence lies in the fact that rules are not part of the implementation of the rules engine, but rather the rules engine is composed of them.
Example 1: spam filter
/************************************
* spamEngine.mts
*/
interface SpamRule {
/**
* function accepting email and returning score modifier
* @param email
* @returns
*/
processEmail: (email: Email) => number;
}
class SpamEngine {
protected rules: SpamRule[];
addRules(rules: SpamRule[]) {
this.rules.push(...rules);
}
execute(email: Email): boolean {
const score = this.rules.reduce((acc, rule) => {
return acc + rule.processEmail(email);
}, 0);
return score > 0;
}
}
/************************************
* email.mts
*/
function containsSpamWord(text: string): boolean { ... }
interface Email {
from: string;
subject: string;
text: string;
}
class SpamText implements SpamRule {
processEmail(email: Email): number {
if (containsSpamWord(email.text)) {
return 1;
}
return 0;
}
}
class SpamSubject implements SpamRule {
processEmail(email: Email): number {
if (containsSpamWord(email.subject)) {
return 2;
}
return 0;
}
}
/************************************
* textInfo.mts
*/
function extractAllUrl(text: string): string[] { ... }
function getDomainAgeInMonths(url: string): number { ... }
class SpamTextUrl implements SpamRule {
processEmail(email: Email): number {
if (extractAllUrl(email.text).length > 0) {
return 1;
}
return 0;
}
}
class SpamUrlDomainAge implements SpamRule {
processEmail(email: Email): number {
const allUrl = extractAllUrl(email.text);
const found = allUrl.find(url => getDomainAgeInMonths(url) < 6);
if (found) {
return 1;
}
return 0;
}
}
/************************************
* contact.mts
*/
function emailInContacts(email: string): boolean { ... }
class SpamContact implements SpamRule {
processEmail(email: Email): number {
if (emailInContacts(email.from)) {
return -3;
}
return 0;
}
}
/************************************
* programm.mts
*/
const spamEngine = new SpamEngine();
spamEngine.addRules([
new SpamSubject(),
new SpamText(),
new SpamTextUrl(),
new SpamUrlDomainAge(),
new SpamContact(),
]);
console.log(spamEngine.execute({
from: 'john.doe@company.com',
subject: 'text email',
text: 'this is spam',
}));
Example 2: price calculation
/************************************
* orderModule.mts
*/
export interface OrderItem {
price: number;
count: number;
}
/************************************
* priceEngine.mts
*/
export interface PriceRule {
/**
* function accepting current price of item and item and returns new current price
*/
computePrice: (currentPrice: number, item: OrderItem) => number;
}
class PriceEngine {
protected rules: PriceRule[];
addRules(rules: PriceRule[]) {
this.rules.push(...rules);
}
execute(item: OrderItem): number {
const newPrice = this.rules.reduce((acc, rule) => {
return rule.computePrice(acc, item);
}, item.price);
return newPrice;
}
}
/**********************
* userModule.mts
*/
interface User {
isLoggedIn: boolean;
address: UserAddress;
}
interface UserAddress {
country: string;
}
function isCountryInEU(country: string): boolean { ... }
class UserPriceRule implements PriceRule {
constructor(protected user: User) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (this.user.isLoggedIn) {
return currentPrice * 0.95;
}
return currentPrice;
}
}
class UserAddressPriceRule implements PriceRule {
constructor(protected user: User) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (isCountryInEU(this.user.address.country)) {
return currentPrice * 0.97;
}
return currentPrice;
}
}
export function getPriceRules(user: User): PriceRule[] {
return [
new UserPriceRule(user),
new UserAddressPriceRule(user),
]
}
/**********************
* userOrderModule.mts
*/
interface UserOrderHistory {
getOrdersCount: number;
}
class UserOrderPriceRule implements PriceRule {
constructor(protected orderHistory: UserOrderHistory) {};
computePrice(currentPrice: number, item: OrderItem): number {
if ((this.orderHistory.getOrdersCount > 0) && (this.orderHistory.getOrdersCount % 10 === 0)) {
return currentPrice * 0.96;
}
return currentPrice;
}
}
export function getPriceRules(user: User): PriceRule[] {
const userOrder = new UserOrderHistory(user);
return [
new UserOrderPriceRule(userOrder),
]
}
/**********************
* orderCodeModule.mts
*/
function isValidCode(code: string): boolean { ...; }
class OrderCodePriceRule implements PriceRule {
constructor(protected code: string) {};
computePrice(currentPrice: number, item: OrderItem): number {
if (isValidCode(this.code)) {
return currentPrice * 0.97;
}
return currentPrice;
}
}
export function getPriceRules(code: string): PriceRule[] {
return [
new OrderCodePriceRule(code),
]
}
/**********************
* purchasePromotionsModule.mts
*/
class DatePriceRule implements PriceRule {
computePrice(currentPrice: number, item: OrderItem): number {
if ((new Date()).getDay() === 6) {
return currentPrice * 0.93;
}
return currentPrice;
}
}
class ItemCountPriceRule implements PriceRule {
computePrice(currentPrice: number, item: OrderItem): number {
if (item.count >= 3) {
return currentPrice * 0.95;
}
return currentPrice;
}
}
export function getPriceRules(code: string): PriceRule[] {
return [
new DatePriceRule(),
new ItemCountPriceRule(),
]
}
/****************************
* programm.mts
*/
const priceEngine = new PriceEngine();
priceEngine.addRules(userModule.getPriceRules(currentUser));
priceEngine.addRules(userOrderModule.getPriceRules(currentUser));
priceEngine.addRules(orderCodeModule.getPriceRules(currentCode));
priceEngine.addRules(purchasePromotionsModule.getPriceRules());
console.log(priceEngine.execute({count: 2, price: 1.25}));
const noPromotionsPriceEngine = new PriceEngine();
noPromotionsPriceEngine.addRules(userModule.getPriceRules(currentUser));
noPromotionsPriceEngine.addRules(userOrderModule.getPriceRules(currentUser));
noPromotionsPriceEngine.addRules(orderCodeModule.getPriceRules(currentCode));
console.log(noPromotionsPriceEngine.execute({count: 2, price: 1.25}));
As you can see in the examples, the rules engine knows nothing about the implementation details of individual parts. Each "module" keeps its implementation details to itself. In addition, the rules engine allows you to have multiple instances of a process and enforce it with rules based on other application rules.
At this level/size of pseudo-implementation, the resulting code is larger than the original. However, as more and more conditions are added, the rules engine itself does not change, only additional rules are added. The level of complexity and readability remains unchanged.