Migration: from nested ifs to an executable rule
The previous chapters showed a model on a blank page. Real work has no blank page: there is a seventy-line function that computes a discount, fetches the customer profile, writes to the log and was last discussed with the business three years ago. This chapter is about moving such a function into a model without breaking anything, and about knowing that the move really happened.
The main thesis: a migration ends not when a .flang file is written, but
when it is shown that the model and the old code give the same answer on every
input you care about. Everything else is preparation for that check.
The working kit lives in examples/spec/migration/: legacy-discount.mjs,
discount.flang and equivalence.test.mjs.
What we are moving
The legacy function computes an order discount. Inside are a tiered tariff, a loyalty bonus, a partner bonus, a promo code and a cap:
export function calculateOrderDiscount(order, deps = {}) {
const { logger = silentLogger, loadProfile = loadCustomerProfile } = deps;
const profile = loadProfile(order.customerId);
let discount = 0;
if (order.amount >= 10000) {
if (order.amount > 50000) {
discount += order.amount * 0.1;
} else {
discount += order.amount * 0.05;
}
}
if (profile.monthsWithUs >= 12) {
discount += order.amount * 0.03;
}
if (profile.partner && order.amount >= 20000) {
discount += order.amount * 0.02;
}
if (order.promoCode === 'ВЕСНА25') {
discount += 500;
}
const cap = order.amount * 0.25;
if (discount > cap) {
logger.warn(`скидка ${discount} обрезана до ${cap} по заказу ${order.id}`);
discount = cap;
}
const rounded = Math.round(discount);
logger.info(`заказ ${order.id}: сумма ${order.amount}, скидка ${rounded}`);
return rounded;
}
What is wrong here besides the magic numbers. The function takes
order.customerId and fetches the profile itself — so it cannot be called
without stubbing a dependency and cannot be shown to the business as a rule.
The cap is implemented by silent clipping: if the tiers computed too much, nobody
but logger.warn will know. And one threshold is written as > 50000, although
the tariff is called “from 50 000” — the difference shows only at exactly fifty
thousand.
This is the kind of code that usually gets migrated: it is not terrible, it has simply stopped being readable as a rule.
Minimal example
The same logic as a model. Note two things: every rule has become a separate
function with its own name, and the cap has become an обеспечивает promise
instead of a line of code.
модуль «Скидка заказа»
объект «Заказ»
«сумма»: неотрицательное
«месяцев с клиентом»: неотрицательное
«партнёр»: признак
«промокод»: строка
тотальная функция «Скидка за сумму»
принимает заказ: «Заказ»
возвращает число
пример «порог пятидесяти тысяч включён»
дано заказ равно запись «Заказ» с «сумма» равным 50000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
ожидается 5000
если заказ.«сумма» не меньше 50000
то (заказ.«сумма» умножить на 10) делить на 100
иначе если заказ.«сумма» не меньше 10000
то (заказ.«сумма» умножить на 5) делить на 100
иначе 0
тотальная функция «Надбавка за стаж»
принимает заказ: «Заказ»
возвращает число
если заказ.«месяцев с клиентом» не меньше 12
то (заказ.«сумма» умножить на 3) делить на 100
иначе 0
тотальная функция «Партнёрская надбавка»
принимает заказ: «Заказ»
возвращает число
если заказ.«партнёр» и притом заказ.«сумма» не меньше 20000
то (заказ.«сумма» умножить на 2) делить на 100
иначе 0
тотальная функция «Бонус по промокоду»
принимает заказ: «Заказ»
возвращает число
если заказ.«промокод» равен "ВЕСНА25"
то 500
иначе 0
тотальная функция «Рассчитать скидку заказа»
принимает заказ: «Заказ»
возвращает число
обеспечивает «скидка не больше четверти заказа» результат не больше ((заказ.«сумма» умножить на 25) делить на 100)
пример «мелкий заказ без скидки»
дано заказ равно запись «Заказ» с «сумма» равным 5000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
ожидается 0
пример «порог десяти тысяч»
дано заказ равно запись «Заказ» с «сумма» равным 10000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
ожидается 500
пример «ровно пятьдесят тысяч»
дано заказ равно запись «Заказ» с «сумма» равным 50000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
ожидается 5000
пример «партнёр со стажем»
дано заказ равно запись «Заказ» с «сумма» равным 60000 и «месяцев с клиентом» равным 24 и «партнёр» равным да и «промокод» равным ""
ожидается 9000
пример «промокод на среднем заказе»
дано заказ равно запись «Заказ» с «сумма» равным 20000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным "ВЕСНА25"
ожидается 1500
(«Скидка за сумму» от заказ) плюс («Надбавка за стаж» от заказ) плюс («Партнёрская надбавка» от заказ) плюс («Бонус по промокоду» от заказ)
The model is shorter than the original function not because it does less, but because it does nothing extra: no profile, no log, no rounding.
Step by step
1. Isolate the pure part. Walk through the function from top to bottom and
mark every line with one of three labels: rule, data fetching, effect. In the
example above loadProfile is data fetching, both logger calls are effects,
Math.round is a presentation effect, everything else is the rule. If after
labelling the rule does not form one connected piece, do an ordinary
refactoring first: extract a pure function with explicit arguments. A model
does not replace this step.
2. Pin down the current behaviour. Before the first line of flang, write characterization tests: they check not “how it should be” but “how it is now”. Their job is to catch the moment you change behaviour while thinking you are rewriting form. These tests later become the skeleton of the equivalence test.
3. Write the inputs as an object. Every argument of the rule becomes a
field of an object with a built-in type. This is where the main question of the
migration is settled: what really is a scalar and what came from the database.
profile.monthsWithUs is not a profile but the number «месяцев с клиентом».
profile.partner is not a link to a partners table but a flag. The object
describes not a database row but the input of the rule: the narrower it is, the
fewer reasons to drag an entire ORM entity into the model. Choose a strict type:
the amount and the tenure are declared неотрицательное, and a negative amount
never reaches the rule at all.
4. Move the conditions. The nested if statements of the legacy code move
over as an если … иначе если chain: the branches are tried top to bottom, and
exactly one runs.
модуль «Ступени скидки»
тотальная функция «Скидка за сумму»
принимает сумма: неотрицательное
возвращает число
пример «ниже первой ступени»
дано сумма равно 9999
ожидается 0
пример «первая ступень»
дано сумма равно 10000
ожидается 500
пример «ровно пятьдесят тысяч — уже вторая ступень»
дано сумма равно 50000
ожидается 5000
если сумма не меньше 50000
то (сумма умножить на 10) делить на 100
иначе если сумма не меньше 10000
то (сумма умножить на 5) делить на 100
иначе 0
The trap waits on the other side: the tiers must not be spread over separate
functions and summed. Adding functions is addition, and two tiers “at least
10000” and “at least 50000” at an amount of 60 000 give 15 percent instead of
10. Only contributions that add up by meaning are summed: tenure, partnership,
promo code. The > 50000 threshold is written here the way the tariff is
named — “from 50 000”; what that changes, the equivalence test will show.
5. Turn clipping into a promise. Look in the legacy code for Math.min,
Math.max, final if (x > limit) x = limit and comments like “the discount
cannot exceed a quarter”. All of these are invariants that look like fixing the
result in code. In the model they become an обеспечивает line:
результат не больше ((заказ.«сумма» умножить на 25) делить на 100).
The difference is fundamental. Math.min treats the symptom and hides the
cause; a promise stops the call with a FLANG_PROPERTY error and makes you find
out which rule computed too much.
The proof report says honestly what this promise rests on:
постусловие «скидка не больше четверти заказа» функции «Рассчитать скидку заказа» — сетка 5 значений (примеры функции): нарушений НЕ ИСКАЛИ — прогона примеров не было, посчитано только их число. Это не доказательство — теоремы при утверждении нет
“Сетка 5 значений” (a grid of five values) means the cap was checked on five
examples and is not proved for all inputs, and flang check --proof exits with
code 3. The proof kernel does not compare percentages of an amount. That is why
the printed model checks the promise on every call — and below the promise
turns out to be broken on an input that is not among the examples. The form of
the limit repeats the legacy code word for word: cap = order.amount * 0.25.
This is a conscious debt — tighten the limit already at this step, and the
mismatches in the equivalence test stop being findings in the old code and
become artefacts of the rewrite. What the limit looks like after tightening is
covered in the chapter on antipatterns.
6. Move the known cases into examples. Everything the team knows by heart —
“exactly five hundred at ten thousand”, “nine thousand off for a long-standing
partner at sixty thousand” — becomes examples inside the function. This is a
regression suite that lives in the same file as the rule and runs with
flang test, not in a separate test project:
$ flang test examples/spec/migration/discount.flang
examples/spec/migration/discount.flang: примеров 6, прошло 6, не прошло 0
7. Equivalence test and a parallel run. It gets the next section. After the test is green, switch on shadow mode: the application computes with both implementations, returns the legacy result to the customer and writes the mismatches to a metric. A week of real traffic checks what no input grid will give you — the real distribution of data. Only after that does the model start returning the answer, and the old code is deleted. Not “switched off by a flag forever” but deleted: a dead branch of a business rule is more dangerous than a live one.
8. What stays in the application. Fetching the profile, logging, writing the result into the order, rounding. More on that below.
Equivalence test
Compare not the legacy code with the model, but the legacy code with the
adapter — what actually takes the old function’s place. The model is printed
into JavaScript (flang emit --target js), and the adapter calls the printed function — the very one that
will ship with the application:
const calculate = (await importModel(MODEL_FILE)).functions.get('Рассчитать скидку заказа');
function calculateByModel(order, deps = {}) {
const profile = deps.loadProfile(order.customerId);
const input = {
сумма: order.amount,
'месяцев с клиентом': profile.monthsWithUs,
партнёр: profile.partner,
промокод: order.promoCode ?? '',
};
return Math.round(calculate(input));
}
importModel from examples/spec/lib/flang.mjs prints the model once and loads
the module. There is no compiler at run time: the rule is an ordinary function
without dependencies.
The inputs are enumerated on a grid, not generated at random. People stop trusting a flaky equivalence test in its second week, and it has to be trusted for a long time. The values are picked around every threshold — one just below, one exactly on it, one just above:
const AMOUNTS = [0, 999, 1500, 9999, 10000, 19999, 20000, 49999, 50000, 50001, 99999, 250000];
const MONTHS = [0, 6, 11, 12, 36];
const PARTNER = [false, true];
const PROMO_CODES = ['', 'ВЕСНА25', 'ЛЕТО10'];
Now the most important part. Mismatches are not hushed up or “quickly fixed”; they are declared as a list: a predicate over the input plus a written explanation. The test demands that prediction and fact agree in both directions — an undeclared mismatch breaks the build as a regression, and a stale entry without a mismatch breaks it as a lie in the documentation. An empty list is the goal of the migration; while it is not empty, the old code must not be switched off.
$ node --test examples/spec/migration/equivalence.test.mjs
входов: 360; совпало: 300; расхождений: 60 (Порог 50 000 включительно — 30; Промокод на заказе меньше 5 000 — 30)
✔ модель проходит проверку, и её собственные примеры сходятся (259.934045ms)
✔ легаси и модель совпадают на всей сетке, кроме объявленных расхождений (7.02131ms)
✔ каждое объявленное расхождение подтверждается сеткой и объяснено (1.129933ms)
✔ на сетке нет входов, отвергнутых моделью по типам (1.7331ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
Both mismatches are findings, the very reason all this was started.
The 50 000 threshold. The legacy code checks order.amount > 50000, the
tariff is called “from 50 000”. At exactly fifty thousand the old code gives 5
percent instead of 10. This is not a difference of implementations but a bug
nobody saw for three years, because hitting the exact threshold value in live
traffic rarely happens. The owner of the rule makes the decision; the decision
is pinned by the example «ровно пятьдесят тысяч» (exactly fifty thousand) —
and from that moment it is no longer oral.
A promo code on a small order. The fixed 500 roubles have no minimum order
amount, so at an amount of 1 500 they break the 25 percent cap. The legacy code
clips the result to 375 and keeps going; the printed model stops with a
FLANG_PROPERTY error on the promise «скидка не больше четверти заказа» (the
discount is at most a quarter of the order). This is the very promise the report
called “a grid of five values”: the examples did not break it, and the test grid
found an input where it is false. Formally a “mismatch”, in substance the model
found a contradiction between two rules that the old code was masking. The
right fix is not in the model but in the business rule: the promo code should
not be handed out for such orders.
You can check that the test really catches a regression in ten seconds: replace
the predicate of the first mismatch with () => false. The test will list the
unaccounted inputs with the actual numbers and fail.
What stayed in the application
After the migration the application does exactly what the model cannot and should not do:
- fetching the profile —
loadProfile(order.customerId), any source, any caches and retries; - bringing values to the declared types —
order.promoCode ?? ''. A model record requires every declared field;nullfor a string is not a value but a defect of the boundary. A field can be declaredиногда является строкой, but then a missing value becomes a separate case the rule must know about; it is simpler and more honest to normalize the value in the adapter; - rounding money —
Math.roundat the exit. Round once and at the boundary, otherwise you get double rounding in reports; - logging, metrics, writing the result into the order and the audit trail.
This is the right boundary: the adapter takes a dozen lines, reads in one go and contains not a single condition about the domain.
What not to move
Do not drag into the model what makes it non-deterministic or incomplete. Some of this flang will not let you write at all — a function has no access to the network, files or the clock:
- calls to databases and services — the function takes data already fetched;
- logs and metrics — an effect, zero use for the rule;
- retries and timeouts — a property of infrastructure, not of the domain;
- work with the current time —
Date.now()inside a rule turns examples into flaky tests, and flang has no “today”. Age, term, overdue days are computed outside and arrive as a number: not “registration date” but«месяцев с клиентом»; - formatting and localization — presentation, not decision;
- branches on feature flags — they change more often than the rules and live in the application.
A practical rule: if the answer depends on more than the arguments, it is not a model function.
What it costs
An honest count, without marketing.
What you gain. One place of truth: the rule stops existing at once in the
code, in Confluence and in an analyst’s head. Tests in the language of the
domain: the examples inside the model are read and edited by the person who
formulates the tariff, not only by whoever writes node:test. Printing into
JavaScript, TypeScript, Python or Go — the implementation and the model stop
drifting apart. The proof report names which promises hold for all inputs and
which only on examples. And, as this migration showed, a side effect of
rewriting a rule in words is bugs found: > instead of >= and the
contradiction between the promo code and the cap were found not because anyone
looked for them, but because the model forced the boundaries to be spelled out.
What you pay. One more artefact in the repository with its own life cycle,
CI check and review. Team training: a promise that checks rather than fixes,
the difference between “proved” and “grid”, the ban on effects — in the first
two weeks everyone gets it wrong. The data boundary: the adapter has to be
written and maintained, and deciding “what is a scalar here” sometimes needs a
separate discussion. Plus the limits of this version: the language has no date
type, money without rounding errors needs the сотых type, and the proof
kernel does not prove percentages of an amount — such promises remain a check at
run time.
When it is not worth it: the rule changes once in three years and fits into two conditions; the logic is procedural at heart and exists for its effects; the answer depends on external state. When it is worth it: tariffs, discounts, limits, classifiers, permissions for an operation — everything the business states as a table and that changes more often than the service is released.
Practice in the sandbox
Copy examples/spec/migration/discount.flang into the sandbox — or open the
course discount model via the link below, it is built the same way.
Exercise 1. In the function «Скидка за сумму» replace the condition
не меньше 50000 with больше 50000, as in the legacy code, and run the
check. The answer:
FLANG_EXAMPLE: пример «порог пятидесяти тысяч включён» функции «Скидка за сумму»: значение не совпало с ожидаемым: ожидалось 5000, получено 2500
FLANG_EXAMPLE: пример «ровно пятьдесят тысяч» функции «Рассчитать скидку заказа»: значение не совпало с ожидаемым: ожидалось 5000, получено 2500
This is exactly the legacy bug the equivalence test found — now two examples guard it.
Exercise 2. In «Бонус по промокоду» replace то 500 with то 5000 and run
the check. Instead of an answer you get
FLANG_PROPERTY: нарушено свойство «скидка не больше четверти заказа» on the
example «промокод на среднем заказе»: the promise caught what the legacy code
would have clipped silently.
Typical migration mistakes
- Spreading the
if / else iftiers over separate functions and summing them. The most common and the most expensive: the tiers do not exclude each other, the discount doubles. Tiers are oneесли … иначе еслиchain. - Moving a threshold “as in the code” without asking what the tariff calls it.
>andне меньшеdiffer exactly at the threshold; the owner of the rule decides. - Taking an entire ORM object into the model. The input of a rule is a few fields, not a table row with twenty fields and relations.
- Leaving
Math.minin the adapter next to the promise. Then the promise never fires, and you lose the diagnostic you wrote it for. - Clipping the result in the model body so that the promise gets proved. The report turns into “proved”, and the rule error goes silent.
- Silently “fixing” a mismatch in the model to make the test green. A mismatch is a question for the owner of the rule, not for the developer. Fix the legacy code or declare the mismatch with an explanation.
- Random inputs in the equivalence test. The first red run that does not reproduce devalues the whole test.
- Reading code 0 of
flang checkas “proved”. Without--proofnothing is said about proofs; with it, code 3 means “not everything is proved”. - Switching off the old code on a schedule instead of on the shadow-run metric.
- Keeping both implementations “just in case”. Half a year later they diverge, and nobody knows which one is right.
Checklist
- The rule is separated from data fetching and effects by ordinary refactoring.
- Characterization tests are written before the migration starts.
- The input is declared as an object with strict types; for every field it is clear where it comes from.
- The tariff tiers are one
если … иначе еслиchain; no overlaps and no gaps. - Every
Math.min/Math.max/ “cannot exceed” has become anобеспечиваетpromise. - Known cases are moved into examples and pass
flang test. - The
flang check --proofreport is read: it is clear which promises are proved and which rest on examples. - The equivalence test runs a deterministic grid around every threshold.
- The list of mismatches is empty, or every entry is explained and confirmed by the grid.
- The mismatches are shown to the owner of the rule, and the decision is pinned by an example.
- The shadow run on real traffic has lasted the agreed period.
- The adapter contains not a single condition about the domain.
- The old code is deleted, not left behind a flag.