Refactoring Nested Conditions into Testable Rules

A small, repeatable refactoring for turning tangled access checks into named rules with explicit inputs and focused tests.

Tangled conditional paths becoming clean independently tested rules

The worst conditional in a codebase is rarely the longest one. It is the one nobody can change confidently.

Access checks are a common example. A function starts with role checks, grows department exceptions, then absorbs account state and resource sensitivity. Shortening that function is helpful, but the real goal is to make the decision visible and testable.

Name the decision first

Before changing syntax, decide what the code is answering:

May this user read this resource?

Then define the smallest context that can answer it.

type AccessContext = {
user: {
active: boolean;
role: "admin" | "member";
department: string;
permissions: string[];
};
resource: {
department: string;
};
};

This step exposes accidental dependencies. If a rule needs the entire request object, it is probably not isolated yet.

Express the rule

import { createRuleEngine, createRuleHelpers } from "rule-engine-js";
const engine = createRuleEngine();
const rules = createRuleHelpers<AccessContext>();
const canReadResource = rules.and(
rules.eq("user.active", true),
rules.or(
rules.eq("user.role", "admin"),
rules.and(
rules.field.equals("user.department", "resource.department"),
rules.in("read", "user.permissions"),
),
),
);

The helper syntax produces a serializable rule. More importantly, the branches now read like the policy they implement.

Evaluation returns a result object:

const decision = engine.evaluateExpr(canReadResource, context);
if (!decision.success) {
throw new Error("Access denied");
}

Keep the HTTP response, logging, and other side effects outside the rule.

Test the matrix, not the implementation

Rules invite table-driven tests. Each row describes a business case rather than a branch in the old function.

const cases = [
["active admin", activeAdmin, true],
["inactive admin", inactiveAdmin, false],
["member in same department", sameDepartmentMember, true],
["member without read permission", memberWithoutRead, false],
] as const;
for (const [name, context, expected] of cases) {
test(name, () => {
expect(engine.evaluateExpr(canReadResource, context).success).toBe(
expected,
);
});
}

Add boundary cases: missing paths, empty permission arrays, unknown roles, and malformed remote rules. Those are usually more valuable than testing the helper calls themselves.

Refactor in two passes

First, preserve behavior. Put characterization tests around the existing function, introduce the rule, and compare both results against the same fixtures.

Second, improve the policy. Remove obsolete exceptions, split unrelated decisions, and give each rule a stable name and version. Combining cleanup with migration makes regressions much harder to diagnose.

A rule engine does not make complicated policy simple. It makes the complication explicit. That is enough to improve reviews, tests, and future changes.

Source: rule-engine-js v1.0.7.

Read more

All posts
  1. Glowing geometric rules transforming into structured JSON objects
  2. Geometric rule structure surrounded by JSON objects and code
  3. Business rules moving from a dense application core into separate modules
  4. A code editor connected to modular web development tools