Migration to FTS: from Nested If to Executable Specification
Previous chapters showed FTS on a clean sheet. In real work, there is no clean sheet: there is a function of seventy lines that calculates a discount, fetches the client’s profile, writes to the log, and was last discussed with the business three years ago. This chapter is about how to transfer such a function into a model, without breaking anything, and how to understand that the transfer has indeed taken place.
Main thesis: migration ends not when the .fts-model is written, but
when it is proven that the model and the old code give the same answer on all
inputs that interest you. Everything else is preparation for this
proof.
The working set is in examples/fts/migration/: legacy-discount.mjs,
discount.fts and equivalence.test.mjs.
What we migrate
Legacy function calculates the discount for the order. Inside — a tiered tariff, seniority surcharge, partner surcharge, promo code, and a ceiling:
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 magic numbers. The function takes order.customerId and
goes after the profile by itself — therefore it cannot be called without
mocking the dependency and cannot be shown to the business as a rule. The
ceiling is implemented via silent clipping: if the stages count extra, no
one but logger.warn will know about it. And one threshold is recorded as > 50000,
although the tariff is called “from 50 000” — the difference is visible only
at exactly fifty thousand.
Such code is usually migrated: it is not terrible, it just stopped being readable as a rule.
Minimal example
Same logic as the model. Note two things: the stage boundaries are recorded on both sides, and the ceiling has become a property, not a line of code.
категория «Продажи»
объект Заказ
сумма является деньгами
«месяцев с клиентом» является числом
партнёр является признаком
промокод является строкой
утилита «Рассчитать скидку заказа»
принимает Заказ
возвращает деньги
начинает с 0
правило «Крупный заказ»
если сумма не меньше 50000
то добавить 10 процентов от поля сумма
правило «Средний заказ»
если сумма не меньше 10000
и сумма меньше 50000
то добавить 5 процентов от поля сумма
правило «Клиент больше года»
если «месяцев с клиентом» не меньше 12
то добавить 3 процента от поля сумма
правило «Партнёрский заказ»
если партнёр равен да
и сумма не меньше 20000
то добавить 2 процента от поля сумма
правило «Весенний промокод»
если промокод равен «ВЕСНА25»
то добавить 500
свойство «Скидка не больше четверти заказа»
результат не больше 25 процентов от поля сумма
пример «Порог десяти тысяч»
дано сумма равна 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. Extract the pure part. Go through the function from top to bottom and mark each
line with one of three labels: rule, data retrieval, effect. In the example above
loadProfile is data retrieval, both logger are effects, Math.round is presentation effect,
everything else is rule. If after marking the rule does not form a coherent chunk,
first perform regular refactoring: extract a pure function with explicit arguments.
FTS does not replace this step.
2. Fix the current behavior. Before the first line of .fts, write
characterization tests: they check not “how it should be”, but “how it is
now”. Their task is to catch the moment when you change the behavior, thinking
that you are rewriting the form. These tests will later become the skeleton of
an equivalence test.
3. Extract inputs as an object. Each rule argument becomes a field
of the FTS object with an embedded type. This resolves the main migration question: what
is truly scalar and what comes from the database. profile.monthsWithUs is not
a profile, but a number «месяцев с клиентом». profile.partner is not a link to
the partners table, but a flag. The FTS object describes not a row in the DB, but the input
of the rule: the narrower it is, the fewer reasons to pull the entire ORM entity into the model.
4. Move conditions and actions. And here lies a trap that causes
most first attempts to fail: there are no else in FTS. All
rules whose conditions are true are executed. The if / else if chain from legacy gives a discount
only for one stage, but two rules with conditions “not less than 10000” and “not
less than 50000” will both trigger on a sum of 60,000 — 15 percent instead of 10. Therefore,
each stage receives both boundaries:
правило «Средний заказ»
если сумма не меньше 10000
и сумма меньше 50000
то добавить 5 процентов от поля сумма
This is not a drawback, but a gain: ranges that were implicit in else if
are now written in words, and the gap between stages is visible to the eye.
5. Add properties. Look for invariants in legacy Math.min, Math.max, final
if (x > limit) x = limit and comments like “discount cannot exceed a quarter”. All of these are invariants that in code look like result corrections. In the model they become postconditions:
свойство «Скидка не больше четверти заказа»
результат не больше 25 процентов от поля сумма
The difference is fundamental. Math.min fixes the symptom and hides the cause; the property halts execution with FTS_UTILITY_PROPERTY and forces you to figure out which rule counted extra.
The limit form during migration repeats the legacy verbatim — cap = order.amount * 0.25 becomes 25 процентов от поля сумма. This is a deliberate debt: if
rewriting the limit to a number is done already at this stage, the discrepancies of the equivalence
test will stop being discoveries in the old code and become a result of rewriting.
The cost is known and visible with a tool: ftsmap examples/fts/migration/discount.fts --text reports that on a negative sum this limit goes into the negative and
fails where none of the rules applied. Tightening is a separate step
after the old code is removed; how it looks is discussed in
the chapter on antipatterns.
6. Move known cases to examples. Everything the team remembers by heart —
«exactly five hundred on ten thousand», «a partner with experience gets nine thousand discount on sixty thousand» — becomes examples inside the utility. This is regression,
which lives in the same file as the rule, and is executed by the command
fts test, not a separate test project.
7. Equivalence test and parallel run. About it — the next section. After a green test, enable shadow mode: the application calculates with both implementations, returns the legacy result to the client, and writes discrepancies to a metric. A week on real traffic checks what no input grid can provide — real data distribution. Only after this does the response start returning the model, and the old code is deleted. Not “disabled by a flag forever,” but deleted: a dead branch of business rules is more dangerous than a live one.
8. What remains in the application. Profile retrieval, logging, writing the result to an order, rounding. More on this below.
Equivalence test
You need to compare not legacy and utility, but legacy and adapter — what will actually take the place of the old function:
function calculateByModel(order, deps = {}) {
const profile = deps.loadProfile(order.customerId);
const input = {
сумма: order.amount,
'месяцев с клиентом': profile.monthsWithUs,
партнёр: profile.partner,
промокод: order.promoCode ?? '',
};
return Math.round(executeUtility(document, UTILITY, input));
}
Inputs are enumerated on a grid rather than generated randomly. The flickering equivalence test is no longer trusted by the second week, but it needs to be trusted for a long time. Values are selected around each threshold—one slightly below, exactly at it, and slightly 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'];
Further — the most important. Discrepancies are not silenced and not “fixed quickly”, but declared as a list: an input predicate plus a written explanation. The test requires the prediction and the fact to match in both directions — an undeclared discrepancy breaks the build as a regression, while an outdated entry without discrepancy breaks it as a lie in the documentation. An empty list is the migration goal; until it is empty, the old code cannot be disabled.
$ node --test examples/fts/migration/equivalence.test.mjs
входов: 360; совпало: 300; расхождений: 60 (Порог 50 000 включительно — 30; Промокод на заказе меньше 5 000 — 30)
✔ модель компилируется, и её собственные примеры сходятся (1.29387ms)
✔ легаси и модель совпадают на всей сетке, кроме объявленных расхождений (7.553157ms)
✔ каждое объявленное расхождение подтверждается сеткой и объяснено (1.06001ms)
✔ на сетке нет входов, отвергнутых утилитой по типам (1.74687ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
Both discrepancies — are discoveries worth pursuing.
Threshold 50 000. Legacy 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 an implementation difference, but a bug that nobody saw for
three years because hitting the exact threshold value on live traffic happens
rarely. The decision is made by the rule owner; the decision is fixed by the
example “Exactly fifty thousand” — and from that moment it stops being verbal.
Promo code on a small order. Fixed 500 rubles have no
minimum order amount, so on a total of 1 500 they exceed the 25
percent ceiling. Legacy code truncates the result to 375 and continues; the model
stops at FTS_UTILITY_PROPERTY. Formally a “discrepancy”, in essence —
the model detected a contradiction between two rules, which the old code
masked. The correct fix is not in the model, but in the business rule: promo codes should not
be issued for such orders.
Check that the test actually catches a regression can be done in ten seconds:
replace the first discrepancy predicate with () => false. The test will show a list
of unaccounted inputs with actual numbers — сумма=50000 … легаси: 2500, модель: 5000 — and will fail.
What Remained in the Application
After the migration, the application does exactly what the model cannot and should not do:
- profile retrieval —
loadProfile(order.customerId), any source, any caches and retries; - reduction to scalars —
order.promoCode ?? ''. The utility requires all declared fields and checks their types;nullfor a string is not a value, but a boundary defect. A field can be declared asиногда является строкой, but then it cannot be referenced in a rule: a condition on a missing field givesFTS_UTILITY_INPUT; - money rounding —
Math.roundon output. Rounding should be done once and at the boundary, otherwise you will get double rounding in reports; - logging, metrics, recording the result in the order and in the audit.
This is the correct boundary: the adapter takes up a dozen lines, is read as a whole and does not contain a single condition about the domain.
What Not to Transfer
Don’t pull into the model what makes it non-deterministic or incomplete:
- database and service calls — the utility accepts already received data;
- logs and metrics — side effect, no benefit for the rule;
- retries and timeouts — infrastructure property, not domain;
- working with current time —
Date.now()inside the rule turns examples into blinking tests. Age, term, overdue are calculated externally and arrive as numbers: not «registration date», but«месяцев с клиентом»; - formatting and localization — presentation, not solution;
- branching by feature flags — they change more often than rules and live in the application.
Practical rule: if the answer depends not only on the arguments, this is not an FTS utility.
How much does it cost
Honest count, no marketing.
What you gain. One source of truth: the rule ceases to exist simultaneously in code, in Confluence, and in the analyst’s mind. Tests in the domain language: examples within the model are read and edited by the person who formulates the tariff, not only by those who write node:test. Generation of TypeScript and input types — implementation and interface stop diverging. And, as this migration showed, a side effect of rewriting rules in words — discovered bugs: > instead of >= and a contradiction between the promo code and the ceiling were found not because they were being searched for, but because the model forced articulation of boundaries.
What you pay. Another artifact in the repository with its own life cycle,
checking in CI and review. Team training: absence of else and “all applicable
rules are executed” — counterintuitive for people with imperative experience, the
first two weeks everyone makes mistakes. Data boundary: you will have to write and
maintain the adapter, and the decision “what is scalar here” sometimes requires a separate discussion.
Plus version limitations: rounding, dates, and else are not present in the language, and part of the logic
still remains in the application.
When not to use: the rule changes once every three years and fits into two conditions; the logic is essentially procedural (parsing, tree traversal); the result depends on external state. When to use: tariffs, discounts, limits, classifiers, operation permissions — anything the business formulates as a table and changes more frequently than the service is released.
Practice in the sandbox
Exercise 1. Remove from the и сумма меньше 50000 rule the line and run the examples. The else if example will show 7500 instead of 5000: both stages triggered. This is exactly the fallacy made during the transfer.
Exercise 2. Replace in the “Spring promo code” rule the action with
то добавить 5000 and run the utility on the amount of 20 000. Instead of the answer receive
FTS_UTILITY_PROPERTY: the property caught what legacy would have cut silently.
Common Migration Fallacies
- Move
if / else ifas multiple rules without upper bounds. Most frequent and most expensive: rules do not exclude each other, the discount doubles. - Assume rules are executed until the first match. No: all true ones are executed, top to bottom, cumulatively.
- Take the ORM object into the model as a whole. The rule input is several scalars, not a table row with twenty fields and relations.
- Leave
Math.minin the adapter next to the property. Then the property will never trigger, and you will lose the diagnostics it was written for. - Silently “fix” a discrepancy in the model to make the test green. A discrepancy is a question for the rule owner, not for the developer. Fix legacy or declare the discrepancy with an explanation.
- Random inputs in the equivalence test. The first red run that cannot be reproduced devalues the entire test.
- Reference the
иногда являетсяfield in the rule. A condition on a missing field falls onFTS_UTILITY_INPUT; normalize the value in the adapter. - Disable old code on a schedule, not based on shadow-run metrics.
- Leave both implementations “just in case”. In half a year they will diverge, and no one will know which one is correct.
Checklist
- The rule is separated from data retrieval and effects by a regular refactoring.
- Characterization tests are written before the migration starts.
- The input is declared with scalars; the source of each field is clear.
- Each tariff stage has both boundaries; there are no overlaps or gaps.
- All
Math.min/Math.max/ “cannot exceed” have become properties. - Known cases are migrated to examples and pass
fts test. - The equivalence test runs a deterministic grid around all thresholds.
- The discrepancy list is empty or each entry is explained and confirmed by the grid.
- Discrepancies are shown to the rule owner, the decision is fixed in an example.
- The shadow run on real traffic executed for the agreed period.
- The adapter contains no business logic conditions.
- The old code is deleted, not left under a flag.