12개월 동안 모든 규칙 함수에 대해 git 로그를 실행했습니다. 지점의 3% 가 넘는 음영이 모든 변경을 수행했습니다.

작성자

카테고리:

← 피드로
DEV Community · Revin · 2026-10-01 개발(SW)

Someone gave me read access to a pricing service last month with a question I could not settle by opinion: is it time to buy a rules engine? Node and TypeScript, roughly 40k lines, six files under src/pricing, three developers in its history and none of them still at the company.

That question has a canonical version on Software Engineering Stack Exchange, “How can one manage thousands of IF…THEN…ELSE rules?”, sitting at 109,657 views, score 218 and 18 answers. Most of those answers argue about which engine to adopt. Almost none of them start where I think you have to start, which is counting.

Before you move a single rule anywhere, you want one number: of all the branches in that module, how many did a human actually change in the last twelve months? A branch nobody has touched since 2021 costs you nothing in maintenance. It costs you regression risk the moment you move it, and moving it into an engine makes that worse.

Here is the procedure, in the order I ran it, including the two steps that handed me the wrong answer first.

Step 1: count the decision points

No AST tooling yet. I wanted a floor, fast, with comments stripped so that a commented-out block would not inflate the number.

git ls-files 'src/pricing/*.ts' \
  | xargs cat \
  | perl -0777 -pe 's{/\*.*?\*/}{}gs; s{//.*$}{}gm' \
  | grep -cE '\b(if|else if|case)\b|\?\?'

Enter fullscreen mode Exit fullscreen mode

1043

Enter fullscreen mode Exit fullscreen mode

That is the number that makes people want an engine. It is also the number that tells you the least, because it treats a tax table written in 2019 and never reopened the same as the discount rule that four people argued about in March.

Step 2: churn per file, which turned out to be useless

git log --since='12 months ago' --numstat --format='' -- src/pricing \
  | awk 'NF==3 { c[$3] += $1 + $2 } END { for (f in c) printf "%6d  %s\n", c[f], f }' \
  | sort -rn

Enter fullscreen mode Exit fullscreen mode

   412  src/pricing/discount.ts
   180  src/pricing/coupon.ts
    96  src/pricing/tax.ts
    41  src/pricing/index.ts
    12  src/pricing/freight.ts
     4  src/pricing/types.ts

Enter fullscreen mode Exit fullscreen mode

So discount.ts moved a lot. Fine. That file alone holds around 300 of the 1,043 branches. Knowing the file is hot tells you nothing about which twenty lines inside it are hot, and “rewrite discount.ts” is exactly the kind of proposal that eats a quarter.

Step 3: churn per function

git log -L :function:file is the part most people never run. It follows one function through history and prints only the commits that touched it.

for file in $(git ls-files 'src/pricing/*.ts'); do
  grep -oE '(export )?function [A-Za-z0-9_]+' "$file" \
    | awk '{print $NF}' \
    | while read -r fn; do
        n=$(git log --since='12 months ago' --format=%h -L ":$fn:$file" 2>/dev/null | grep -c .)
        [ "$n" -gt 0 ] && printf '%3d  %s  %s\n' "$n" "$fn" "$file"
      done
done | sort -rn

Enter fullscreen mode Exit fullscreen mode

 14  applyTierDiscount   src/pricing/discount.ts
  9  resolveCoupon       src/pricing/coupon.ts
  7  stackDiscounts      src/pricing/discount.ts
  5  stateTax            src/pricing/tax.ts
  3  roundToCents        src/pricing/index.ts
  ...
 12 functions with at least one commit in 12 months

Enter fullscreen mode Exit fullscreen mode

Twelve functions out of sixty-one. Then count the branches living inside those twelve, and only those:

awk '/function applyTierDiscount/,/^}/' src/pricing/discount.ts \
  | grep -cE '\b(if|else if|case)\b'

Enter fullscreen mode Exit fullscreen mode

Summed across the twelve: 34. Out of 1,043. A shade over 3% of the branches absorbed every change the business asked for in a year, and 22 of those 34 sat in three functions.

That changes the meeting completely. “We have a thousand rules and we need an engine” becomes “we have three functions that people rewrite every other month, and everything else is a frozen table”.

What did not work

First wrong turn: I started with cyclomatic complexity, because it is the reflex. ESLint’s complexity rule and a run of lizard both put freight calculation at the top of the list. Freight has not been edited since 2021. Complexity measures how hard something is to read, and I was trying to find out what is expensive to change. Those two lists barely overlapped, and the complexity list would have sent me to refactor the one file nobody ever opens.

Second wrong turn: git log -L is not free. It gave up on 11 of the 61 functions, and the reasons were boring. Two files got renamed in a big-bang import and history stops at the rename unless you add --follow, which -L does not accept. Several rules are not function declarations at all, they are arrow functions assigned inside an object literal, and the :name: matcher does not find them. I counted those by hand off git log -S'ruleName'. If your codebase is mostly object-literal handlers, budget an afternoon for this step instead of an hour.

Freezing the other 1,009

The rules that did not change in a year do not need to be pretty. They need to keep producing the same number after you touch anything nearby. Golden master, built from real traffic with the identifying fields dropped before anything hits the repo:

import { readFileSync } from 'node:fs';
import { price } from '../src/pricing';

// generated from 90 days of production requests,
// customer and order ids stripped at export time
const cases = JSON.parse(
  readFileSync('fixtures/pricing-90d.json', 'utf8'),
) as Array<{ input: unknown; chargedCents: number }>;

describe('pricing characterization', () => {
  test.each(cases.map((c, i) => [i, c]))(
    'case %i still charges what production charged',
    (_i, c) => {
      expect(price(c.input)).toBe(c.chargedCents);
    },
  );
});

Enter fullscreen mode Exit fullscreen mode

These tests are not there to say the rules are correct. They are there to say the rules are unchanged, which is the only property anyone can defend about a branch written by someone who left in 2022. About two thirds of the 1,043 branches were reachable from 90 days of traffic. The rest I left alone and documented as unreachable, which is its own finding.

Where the 34 went

The three hot functions went behind a versioned configuration table with an effective date, so a tier change becomes a row and not a deploy. The other nine stayed in code, because each one changed once or twice in the year and every one of them calls something else in the service. Pulling them into an engine would buy a round trip and a second place to look during an incident.

Nobody bought a rules engine. If the count had come back with 300 live branches instead of 34, I would have argued the other way, and the count is what makes that argument checkable instead of a preference.

What I am still unsure about

Twelve months is an arbitrary window, and in a service with heavy seasonality it is probably wrong. I also do not know how this holds up in a repo where a formatter or a lint migration rewrote every file at once, since -L would light up like a Christmas tree and the churn signal dies.

If you have taken a big if/else pile apart: what did you measure before deciding, and did the measurement survive contact with the people who wanted the engine anyway?

· · ·

Originally published on the Revin blog: https://revin.com.br/en/blog/thousands-if-then-else-rules-code-engine-spreadsheet

원문에서 계속 ↗