A discount rule often begins as one readable if statement. Then the exceptions arrive: customer tiers, regional limits, campaign windows, minimum totals, and one-off exclusions. The problem is not conditional logic itself. The problem is that a frequently changing business decision has become tangled with application flow.
A rule engine gives that decision a boundary.
Start with the change rate
Do not move every condition into configuration. Code is still the clearest home for stable invariants and behavior tied closely to an algorithm.
Rules become useful when a decision:
- changes more often than the surrounding application;
- needs an audit trail or explicit version;
- appears in several entry points;
- must be tested against many combinations of input;
- may eventually be managed outside a deployment.
Here is a small eligibility rule with rule-engine-js:
import { createRuleEngine, createRuleHelpers } from "rule-engine-js";
type CheckoutContext = { customer: { tier: "standard" | "gold"; active: boolean }; order: { total: number };};
const engine = createRuleEngine();const rules = createRuleHelpers<CheckoutContext>();
const canUseGoldDiscount = rules.and( rules.eq("customer.tier", "gold"), rules.eq("customer.active", true), rules.gte("order.total", 100),);
const result = engine.evaluateExpr(canUseGoldDiscount, { customer: { tier: "gold", active: true }, order: { total: 140 },});
console.log(result.success);The application still owns what happens next. The rule only answers a question.
Keep actions outside the rule
A useful separation is:
- collect a trusted context;
- evaluate a rule;
- perform the action in application code.
That means a pricing rule can decide whether an order qualifies, but it should not charge a card or write directly to a database. Side effects stay in code where authentication, idempotency, retries, and observability are easier to enforce.
JSON is a transport format, not a governance model
A serializable rule can live in a file, database, or API response:
{ "and": [ { "eq": ["customer.tier", "gold"] }, { "eq": ["customer.active", true] }, { "gte": ["order.total", 100] } ]}That flexibility is useful, but it also creates responsibility. Validate rule shape before activation. Limit rule depth and operator count. Version changes, record who approved them, and keep rollback simple. A bad configuration can break production just as effectively as bad code.
The real payoff
The main benefit is not fewer lines. It is a clearer ownership boundary: the engine handles evaluation, the rule describes a decision, and the application handles effects. Each part can change and be tested without hiding the others.
As of v1.0.7, rule-engine-js includes typed path helpers, nested path resolution, validation helpers, state-change operators, and a stateful wrapper. Start with the plain engine. Add the advanced pieces only when the problem calls for them.
Sources: rule-engine-js repository, npm package.
