JavaRush /Курси /Claude code /Strangler Fig: підміна фрагмента legacy-коду

Strangler Fig: підміна фрагмента legacy-коду

Claude code
Рівень 27 , Лекція 3
Відкрита

1. Маленького рефакторингу іноді недостатньо

На практиці досить швидко настає момент, коли звичайного «трохи почистимо метод і винесемо допоміжний метод» вже недостатньо: код занадто заплутаний, крихкий, дорогий. І саме тоді в команди зʼявляється спокуса сказати: «А давайте просто перепишемо весь mrr-engine нормально». Звучить майже героїчно. Але героїзм у legacy закінчується довгим diff, неясним rollback і фразою «здається, зламалися звіти за минулий місяць».

Якщо дивитися на CashFlow Dashboard тверезо, mrr-engine — не навчальний модуль: від нього залежать цифри у звітах, звірки, аналітика, іноді бізнесові рішення. Повне переписування майже завжди поганий перший хід. Замінюємо маленький зріз, а не весь механізм.

Добре це видно в порівнянні двох підходів:

Підхід Що відбувається Чим ризикуємо
Big Bang rewrite Старий модуль переписується цілком Величезний diff, важко порівняти поведінку, rollback дорогий
Поступова підміна Новий фрагмент ставиться поруч зі старим і обслуговує лише вузький сценарій Зміна менша, поведінку легше порівняти, rollback швидкий

У нашому випадку таким вузьким сценарієм може бути, наприклад, пошук тарифного плану для підписок, де в клієнта лише один активний plan. Не весь розрахунок MRR, не весь refund flow, не всі граничні випадки всесвіту й сусідніх галактик. А реальна частина поведінки, яку можна ізолювати й перемкнути.

І тут дуже важливо не підмінити мету. Я не змінюю бізнес-правила, не лагоджу старі баги «заодно», не перевожу сервіс на новий стек. Беру вузький зріз і спрямовую його через новий шлях так, щоб зовні система поводилася як раніше. Дивацтва старого коду, на які вже завʼязаний зовнішній контур, зберігаємо як частину сумісності. Не романтично. Зате безпечно.

2. Strangler Fig на людській мові

Назва Strangler Fig звучить як щось середнє між ботанікою та назвою інді-гурту, але сама ідея дуже практична. Рослина не зносить старе дерево одним ударом сокири: вона росте поруч, обплітає стару структуру, бере на себе дедалі більше функцій — і тільки потім стара частина перестає бути потрібна. В інженерії так само: нова реалізація не виходить на сцену з фразою «усім дякую, legacy вільний», а спершу живе поруч і доводить, що робить роботу не гірше.

Щоб такий підхід спрацював, потрібні чотири речі. Перше — маленький зріз, який ви вмієте порівнювати. Друге — точка перемикання, одна точка входу: запит іде або в старий шлях, або в новий. Третє — safety baseline, щоб довести збіг результату. Четверте — простий rollback: вимкнули прапорець — система знову на старому шляху.

Схема тут виглядає приблизно так:

flowchart TD
    A[Запит на розрахунок MRR] --> B[PlanLookupFacade]
    B -->|прапорець вимкнено| C[LegacyPlanLookup]
    B -->|прапорець увімкнено| D[PlanLookupV2]
    C --> E[Порівняння observable output]
    D --> E
    E --> F[Запис evidence у REFACTOR_LOG.md]

Зверніть увагу: старий код ми не ховаємо під килим — він залишається поруч еталоном для порівняння і безпечним rollback. Нова гілка не замінює стару «на віру». Вона заробляє це право через evidence.

Такий зріз зручно фіксувати в окремій секції вже знайомого REFACTOR_LOG.md, поруч із кроками рефакторингу: точка перемикання, прапорець, parity і rollback в одному місці.

## Зріз: single-plan lookup
- Точка перемикання: `PlanLookupFacade`
- Новий шлях: `PlanLookupV2`
- Прапорець: `cashflow.mrr.v2-single-plan=false`
- Паритет: порівняння old/new на фіксованих сценаріях
- Rollback: повернути прапорець у `false`

3. Вибір першого зрізу в mrr-engine

Найчастіша помилка під час знайомства зі Strangler Fig — вибрати занадто великий фрагмент. Здається, раз уже робити новий шлях, «нехай одразу буде корисно» — «захопимо і lookup тарифу, і proration, і refunds, і розрахунок для кількох підписок, і ще трохи звітності». На виході не перший безпечний зріз, а маленька громадянська війна між legacy і новою реалізацією.

Хороший перший зріз має бути нудним. Так, саме нудним: зрозумілий вхід, зрозумілий вихід, невеликий радіус залежностей, швидкий rollback. У mrr-engine таким кандидатом часто стає lookup тарифного плану для single-plan підписок. А весь refund flow — поганий перший кандидат: зачіпає billing, payments, граничні дати, proration та інші сюрпризи старих фінансових систем.

Це можна звести в невелику таблицю:

Кандидат Підходить для першого зрізу Чому
Lookup тарифу для single-plan Так Вузький вхід, зрозумілий output, легко порівнювати
Весь refund flow Ні Занадто багато залежностей і business edge cases
Перерахунок MRR для downgrade/upgrade Швидше ні Високий ризик і складна логіка періодів
Формування audit log Іноді так Невеликий side effect, якщо є чітка точка входу

Перш ніж щось перемикати, зручно спочатку змусити Claude Code попрацювати в режимі inspect. Тут нам не потрібен «розумний герой», який уже переписав півмодуля, — потрібен акуратний дослідник.

Дослідіть лише шлях розрахунку для single-plan підписок у mrr-engine.
Код не змінюйте.
Знайдіть точку перемикання, запропонуйте прапорець увімкнення і перелічіть перевірки
для порівняння старого і нового шляху.
Поверніть відповідь у форматі: файли → ризики → rollback → що поки не чіпати.

Хороша відповідь на такий запит покаже точку входу, залучені файли та небезпечні залежності. Якщо Claude першим кроком лізе в refunds, timezone normalization і історичні міграції — це не «сильна ініціатива», а сигнал, що межі задачі задані занадто широко.

І тут дуже корисний уже знайомий Workflow Kit. agents/tester.md збере список сценаріїв для порівняння, а agents/reviewer.md перевірить, що зріз справді маленький і не перетворився на «ще трохи, і перепишемо все».

4. Точка перемикання: фасад і прапорець увімкнення

Після того як зріз обрано, у вас зʼявляється наступне питання: де саме перемикати старий і новий шлях? Якщо відповідь звучить як «поставимо по if у трьох сервісах, двох helper’ах і ще одному контролері», ви будуєте не Strangler Fig, а пастку для майбутнього себе. Перемикання живе в одній точці.

Найпростіше думати про фасад як про одні двері: решта коду знає лише про цю точку входу, а що за нею — старий шлях чи новий — вирішує сам фасад. Це і не дає умовній логіці розповзтися по коду.

Спочатку зручно винести прапорець увімкнення в окремий компонент:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class MrrFeatureFlags {
    @Value("${cashflow.mrr.v2-single-plan:false}")
    private boolean v2SinglePlan;

    public boolean useV2ForSinglePlan() {
        return v2SinglePlan; // false за замовчуванням = працюємо legacy-шляхом
    }
}

А потім спрямовуємо виклик через фасад. Конструктори опускаю заради стислості, щоб приклад не перетворювався на простирадло.

import org.springframework.stereotype.Component;

@Component
public class PlanLookupFacade {
    private final LegacyPlanLookup legacy;
    private final PlanLookupV2 modern;
    private final MrrFeatureFlags flags;

    public PlanInfo find(String code, boolean singlePlan) {
        return singlePlan && flags.useV2ForSinglePlan() ? modern.find(code) : legacy.find(code);
    }
}

Що тут хорошого? Новий шлях вмикається лише для вузького сценарію — singlePlan. За замовчуванням усе вимкнено, отже безпечний стан — старий шлях, а rollback зводиться до повернення прапорця в false.

Якщо між старою і новою реалізацією різні внутрішні моделі, може знадобитися тонкий adapter або невеликий anti-corruption layer. Терміну лякатися не треба: це перекладач, який не дає дивним legacy-полям просочитися в новий код і навпаки. Тримайте його маленьким і прозорим. В архітекторів інколи є суперздатність збудувати захисний шар товстіший за стіну, яку він має захищати.

5. Доказ збігу поведінки шляхів

Ось тут починається найважливіша частина. Сам факт існування нової реалізації ще нічого не доводить: навіть зелені unit-тести нічого не гарантують, якщо не порівнюють старий і новий шлях на однакових даних. Потрібен не «тест на новий код», а доказ збігу observable output — зовні система виглядає однаково на узгодженому наборі сценаріїв.

Зазвичай це робиться кількома рівнями одразу. Characterization tests фіксують поточну поведінку на важливих сценаріях. Golden master дає знімок результату на еталонних даних. Parity-перевірка проганяє однакові входи через старий і новий шлях і порівнює результат. Manual check закриває те, що поки складно автоматизувати. Так, тут знову зʼявляється ваша safety baseline із попередніх лекцій — просто тепер вона застосовується не до одного методу, а до пари старий/новий шлях.

Дуже маленький приклад parity-тесту може виглядати так:

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

@Test
void single_plan_lookup_keeps_same_output() {
    PlanInfo oldResult = legacy.find("basic-month");
    PlanInfo newResult = modern.find("basic-month");
    assertEquals(oldResult.getMrr(), newResult.getMrr());                // MRR збігся
    assertEquals(oldResult.getBillingPeriod(), newResult.getBillingPeriod()); // період збігся
}

Звісно, у реальному проєкті у вас не один test case, а набір сценаріїв із CHARACTERIZATION_TESTS.md: різні тарифи, дати початку періоду, стани підписки. Принцип той самий: один вхід, два шляхи, одне порівняння. Результати розійшлися — це не привід сказати «ну новий код просто трохи логічніший», а привід зупинитися і розібратися.

Дуже корисно і тут використовувати Claude Code як акуратного помічника, а не як верховного суддю:

Порівняйте observable output старого і нового шляху для single-plan lookup.
Код не змінюйте.
Для кожної розбіжності покажіть:
- вхідні дані,
- старий результат,
- новий результат,
- поле, у якому є відмінність,
- припущення про причину.

Хороша практика — фіксувати результати в REFACTOR_LOG.md поруч із описом зрізу:

## Паритет для single-plan lookup
- Сценаріїв перевірено: 200
- Розбіжностей: 0
- Golden master на fixtures `may24`, `jun24`: збігається
- Rollback перевірено: прапорець вимикається без зміни API

Зверніть увагу ще на одну тонкість. Якщо на відому особливість старого шляху спираються зовнішні звіти, parity має підтвердити саме цю особливість. Виправлення бізнес-поведінки — окреме завдання: змішаєте модернізацію зі зміною логіки — отримаєте інший рівень ризику.

6. Claude Code в Strangler Fig без big rewrite

У такому процесі Claude Code особливо добрий не тоді, коли йому дають команду «ну давай, сучасний шлях, фасад, архітектура, щастя», а коли його тримають у зрозумілих рамках. Працює вже звичний вам цикл: inspect → plan → маленький diff → перевірки → review. Тільки тепер фокус не на чистці методу, а на поступовій підміні зрізу. Реальний новий код вмикається останнім — на накопиченому evidence.

Якщо подивитися на весь цикл на одному короткому сценарії, він виглядає так:

1. Зафіксувати зріз у REFACTOR_LOG.md
2. Знайти одну точку перемикання
3. Додати прапорець, який за замовчуванням вимкнений
4. Підключити новий шлях поруч зі старим
5. Порівняти old/new на baseline-сценаріях
6. Увімкнути новий шлях лише для вузького випадку
7. Залишити старий шлях як rollback

У CashFlow Dashboard це може виглядати зовсім буденно: parity на еталонних fixtures, увімкнення нового шляху лише для single-plan підписок, старий код і досі поруч. Стрибка в прірву немає. Пішло не так — вимикаєте прапорець і йдете розбиратися, а не пояснюєте половині команди, чому весь mrr-engine раптово живе за новою логікою.

Саме тут Strangler Fig перестає бути «розумним патерном із книжки» і стає дисциплінованою інженерною практикою. Ви не героїчно вбиваєте legacy — спокійно, майже нудно відрізаєте у нього маленький безпечний шматок, перевіряєте, що новий шлях дає той самий результат на тому самому зрізі, і тільки тоді довіряєте йому роботу. Для legacy це найкращий комплімент: модернізація стає настільки контрольованою, що перестає бути драмою.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ