Материал Migration to FTS: from nested ifs to executable specification
0%

Migration to FTS: from nested ifs to executable specification

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 retrievalloadProfile(order.customerId), any source, any caches and retries;
  • reduction to scalarsorder.promoCode ?? ''. The utility requires all declared fields and checks their types; null for 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 gives FTS_UTILITY_INPUT;
  • money roundingMath.round on 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 timeDate.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

  1. Move if / else if as multiple rules without upper bounds. Most frequent and most expensive: rules do not exclude each other, the discount doubles.
  2. Assume rules are executed until the first match. No: all true ones are executed, top to bottom, cumulatively.
  3. 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.
  4. Leave Math.min in the adapter next to the property. Then the property will never trigger, and you will lose the diagnostics it was written for.
  5. 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.
  6. Random inputs in the equivalence test. The first red run that cannot be reproduced devalues the entire test.
  7. Reference the иногда является field in the rule. A condition on a missing field falls on FTS_UTILITY_INPUT; normalize the value in the adapter.
  8. Disable old code on a schedule, not based on shadow-run metrics.
  9. 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.

Cases in the catalog on this topic

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше