From C# Frankenstein to React Hero: як ми мігрували legacy-моноліт на React без зупинки розробки
Мене звати Антон Куц. Я CTO в стартапі Banister Staff. Маю дев’ять років в розробці, від стартапів до великих frontend-продуктів та legacy-систем.
У цій статті хочу поділитися практичним кейсом міграції одного з таких проєктів — від C#-моноліту з React 16, jQuery, кількома поколіннями UI-бібліотек і складною системою залежностей до окремого сучасного React-застосунку.
У теорії все звучить досить просто: створити новий проєкт, перенести функціонал, протестувати й переключити користувачів. На практиці ж продукт продовжує працювати, бізнес надсилає нові задачі, legacy-код змінюється прямо під час міграції, а команда має одночасно підтримувати поточну систему і будувати нову.
Що це за проєкт?
Це старий C#-моноліт з великою кількістю залежностей. Основними задачами проєкту були візуалізація, навігація та робота з великим обсягом взаємопов’язаних даних. Ієрархія даних мала велику глибину: від рівня штату та міської інфраструктури до конкретного конектора або кабелю в обладнанні дата-центру.
Звідси — багато UI-компонентів: вкладені таблиці, фільтри, складні форми та інструменти експорту.
Коли ми починали працювати над проєктом, він складався з трьох підпроєктів. Кожен підпроєкт — це кілька сторінок із таблицями, дашбордами та інструментами імпорту.
З чого почали працювати з монолітом?
Коли наша команда почала працювати над проєктом, його можна було описати одним словом — Frankenstein.
Основою був C#-моноліт, всередині якого були React-компоненти, jQuery, мікс різних UI-фреймворків.
Загальний стек виглядав приблизно так:
- React 16.8;
- React Router 5;
- Webpack 4;
- Babel;
- Material UI 4;
- Bootstrap 3;
- jQuery;
- Kendo UI та Kendo React;
- Moment.js;
- набір окремих бібліотек для modal, tabs, select, CSV/PDF export тощо.
Це був своєрідний «музей» різних поколінь frontend-розробки — з різними підходами та кількома реалізаціями одних і тих самих задач.
Про системне оновлення версій фактично не йшлося. Тести й актуальна документація фактично були відсутні — типовий набір проблем для legacy-системи. Майже без перевикористання компонентів та спільних хелперів.
Ми почали точково змінювати фронт на окремі React-компоненти, та наповнювати продукт новими фічами. В подальшому почали додавати підпроєкти.
Як був організований routing?
У системі існувало лише кілька основних маршрутів для великих підпроєктів, а інша частина була реалізована на if-логіці.
Схематично так:
if project A -> show component A
if project B -> show component B
if some state -> show another view
Звідси випливало, що URL накопичував зайві параметри, зокрема project, які фактично компенсували відсутність повноцінної routing structure.
Звісно, з додаванням нових підпроєктів така схема масштабувалася і підтримувалася все гірше.І ще один негативний наслідок — відсутність єдиної «точки входу» для всіх доступних React-маршрутів та зрозумілої логіки навігації застосунком.
Як підняти проєкт?
Одним із явних індикаторів незадовільного стану застосунку було розгортання проєкту на комп’ютері нового співробітника.
Я долучав нових співробітників до проєкту, і це ті самі «танці з бубном».
Щоб лише запустити застосунок, треба було покроково виконати дуже велику кількість ручних кроків.
Стандартна ситуація у legacy-проєктах:
- необхідні версії;
- локальні конфігурації;
- build scripts;
- всі необхідні залежності;
- специфічні налаштування Visual Studio;
- помилки, про які знає лише людина, яка вже один раз їх вирішувала.
Це все вказувало на те, що простим рефакторингом тут не обійтися.
Чому React?
Проєкт вже мав інтеграцію з React. Наші перші завдання були переписати старі views на React.
Наша команда мала гарну експертизу в React, і тому ми здійснювали заміну і розвивали продукт в межах існуючих обмежень.
Проблеми старої архітектури ускладнювалися з кожною фічею та особливо з кожним новим великим підпроектом.
Я майже одразу планував відокремити frontend, але для цього потрібно було завершити перехід на React всіх views та закрити великі задачі по розвитку продукту.
Чому ми створили новий проєкт, а не переписали старий зсередини?
Одне з перших і надважливих питань перед початком будь-якої міграції/зміни, це визначити, де ми її робимо.
Один з варіантів був залишити існуючу систему і поступово оновлювати/змінювати:
- React;
- Webpack;
- routing;
- UI libraries;
- build process;
- структуру директорій;
- integration points між C# та React.
Але це б створило два окремі треки: поточний розвиток і міграцію всередині production-системи. Керувати і тестувати зміни в такому вирі змін було б дуже важко.
При цьому:
- Frontend уже став фактично самостійним застосунком, тому подальше його утримання всередині C#-моноліту лише ускладнювало взаємодію між frontend і backend.
- Велика прірва між версіями на проєкті і останніми версіями технологій.
- Dependency conflicts. Зміна версії React одразу передбачала доопрацювання всіх React-компонентів під нову версію та зміну UI-бібліотек. Це, своєю чергою, передбачає зміну стилів в компонентах, а це вже змінює збірку — Webpack і так далі.
- У межах нашої поточної архітектури безпечно підтримувати компоненти на різних major-версіях React було б складно й створило б додатковий runtime та dependency complexity.
- Велика кількість файлів і підпроєктів, з перевикористанням великих модулів. Наприклад, карти і експорт систем.
- Такі зміни відбувалися б безпосередньо в застосунку, з яким щодня працюють сотні інженерів і менеджерів.
Разом отримаємо комбо: великий обсяг технічних змін, які не можуть бути зроблені послідовно на проєкті, що постійно доопрацьовується і використовується великою кількістю користувачів.
Тому ми прийняли рішення створити новий frontend-застосунок на актуальній версії React і сучасних версіях ключових бібліотек його екосистеми. Та поступово наповнювати цей застосунок доопрацьованими React-компонентами.
В результаті ми замінимо у продакшені не модернізований legacy, а повністю готовий і попередньо перевірений новий продукт.
Але чому тоді не Big Bang Rewrite?
Це інший варіант, коли фокусуємо всю команду на створенні нового застосунку, а після завершення одночасно переводимо користувачів на нього.
Але при такому варіанті ми отримуємо наступні проблеми:
- Нам треба призупинити роботу над поточним проєктом. Це був би негативний вплив на бізнес замовника. Нашим рішенням користувалися не тільки співробітники замовника, а й співробітники його контракторів. Постійно приходили завдання щодо удосконалення проєкту, які не могли чекати.
- Важко визначити строки. Об’єм роботи достатньо великий. Не можна просто переносити React-компоненти, їх треба доопрацьовувати відповідно до нової версії React та інших технологій. Тестувати вже перенесені компоненти. Наша команда не мала досвіду такої міграції.
У перемовинах з клієнтом ми прийшли до того, що ми не можемо припинити delivery, тим більше на невизначений строк.
В результаті ми обрали hybrid migration strategy: створили окремий Greenfield frontend, але наповнювали його поступово, паралельно підтримуючи production legacy application.
Як готувалися до міграції?
Планування міграції виявилося окремим суттєвим кроком. Разом з командою ми описали, що саме ми будемо будувати.
Не просто змінити React 16 на React 18, і змінити «цифру» всіх пов’язаних залежностей.
Ми визначили:
- структуру директорій;
- правила створення компонентів;
- правила внесення змін в існуючі компоненти;
- місце для reusable components;
- constants;
- utilities;
- pages;
- theme;
- підхід до API client;
- routing;
- правила документації.
- тестування
Це все було описано і погоджено з клієнтом.
Backlog і технічний борг — що з ними?
Ми переглянули backlog і розділили задачі на три категорії:
- потрібно реалізувати до міграції;
- потрібно продовжувати реалізовувати під час міграції;
- можна відкласти до моменту після переходу.
Задачі з високим пріоритетом, які надходили від клієнтів, реалізовувалися першими.
Так само, технічний борг був поділений на критичний і некритичний. Критичними були проблеми, що могли заблокувати міграцію чи створити ризики при трансфері компонентів. Ми виправили критичні проблеми до старту міграції, всі інші залишили.
Ми свідомо не виправляли все, бо частина буде переписана під нові технології, а частина не настільки критична, щоб її виправляти до початку міграції.
Що по залежностях?
Окремим напрямом став аудит залежностей.
У старому проєкті одночасно жили:
- Bootstrap;
- jQuery;
- Material UI;
- Kendo;
- Kendo React;
- Moment;
- кілька невеликих UI-бібліотек;
- власний Webpack stack.
У новому проєкті ми намагалися залишати залежності лише тоді, коли розуміли, навіщо вони нам потрібні.
До чого ми прийшли:
- React 16.8 → React 18.2;
- React Router 5 → React Router 6;
- Material UI 4 → MUI 5;
- jQuery — видалено;
- Bootstrap 3 — видалено;
- старий Kendo UI — видалено;
- Moment — замінений сучаснішими рішеннями;
- Axios оновлено;
- кількість вузькоспеціалізованих UI-залежностей суттєво скоротилась.
Для стилізації основою стали MUI та Emotion.
Інфраструктура збірки також стала простішою. Замість великої кількості явно підтримуваних Webpack loaders/plugins новий проєкт використовував CRA 5.
Новий проєкт дав можливість додати і працювати з Testing Library, Husky, lint-staged та Web Vitals.
Як виглядав application skeleton для нового проєкту?
Ми визначили просту структуру src і чіткі правила розміщення нового коду.
Структура достатньо проста і зрозуміла:
/src │ ├── /pages # Route-level screens │ ├── /SubprojectA │ │ └── /dashboard │ ├── /SubprojectB │ │ ├── /dashboard │ │ └── /details │ ├── /SubprojectC │ ├── /exportCenter │ └── /errorBoundary │ ├── /components # Reusable UI components │ ├── /inputs │ └── /tables │ ├── /utilities # Helpers, formatters, API-related logic │ ├── /api │ └── /helpers │ ├── /assets # Shared images and icons │ ├── /images │ └── /icons │ ├── /constants # Global configuration and constants │ ├── pagesConfig.js │ ├── versionsConfig.js │ └── commonConstants.js │ ├── /tests # Shared tests │ ├── apiClient.js # Backend communication ├── App.js # Application shell and routing ├── App.css # Global styles └── index.js # Entry point
Будь-хто при роботі з проєктом розуміє, де що знаходиться і куди додавати нові файли.
Ми зробили єдину точку опису всіх підпроєктів та routing. Всю навігацію можна побачити в одному файлі.
Чому ми рефакторили код, який все одно збиралися переносити?
Створені документи для міграції не тільки допомагали при створенні нового рішення, а й були орієнтиром при роботі з поточним проєктом.
Коли вносили бізнес-зміни в поточний проєкт, вже орієнтувалися на новий.
Приклади такої підготовки:
- перенесення файлу в правильну директорію (відповідно до структури нового застосунку),
- видалення чи зміна залежностей, які не будуть використовуватися в новому застосунку,
- структурування файлу відповідно до нових правил.
Така підготовка в майбутньому спрощувала трансфер таких компонентів.
Замість:
legacy component
→ refactor
→ dependency cleanup
→ structure change
→ migration
отримували:
prepared component
→ migration.
Що переносити першим?
Також мною був створений план перенесення компонентів.
Спочатку ми переносили більш стабільний код, тобто той, до якого рідко вносяться зміни.
А саме:
- icons;
- helpers;
- utilities;
- базові shared elements.
Після цього попроєктно переносили основні компоненти.
Були сформульовані відповідні правила:
1) Переносимо код відповідно до заздалегідь визначеного плану.
Міграція відбувається не хаотично: спочатку переносимо найбільш стабільні частини системи — компоненти, хелпери та інший код, який найрідше змінюється. Далі рухаємося за погодженою послідовністю від одного підпроєкту до наступного.
2) Старий застосунок залишається еталоном функціональної поведінки.
На кожному етапі ми порівнюємо новий застосунок із поточною production-версією. Перенесений функціонал повинен зберігати ту саму бізнес-логіку, поведінку та результат для користувача.
3) Зміни в уже перенесених компонентах обов’язково синхронізуються.
Якщо після перенесення компонента в старому застосунку з’являється нова бізнес-логіка, є два сценарії: якщо дозволяє час — одразу повторюємо зміни в новому застосунку; якщо ні — створюємо окрему migration-задачу з описом необхідних змін і посиланням на відповідну задачу поточного проєкту. Таким чином, жодна зміна не повинна загубитися між двома кодовими базами.
4) Не переносимо компонент, який активно змінюється.
Якщо бізнес-зміни стосуються компонента, який саме перебуває в процесі міграції, його перенесення призупиняємо. Спочатку зміни повністю реалізуються, тестуються та приймаються клієнтом у поточному застосунку, після чого міграція продовжується вже з актуальною версією логіки.
5) Міграція завершується тільки після досягнення функціонального паритету.
Компонент, сторінка або фіча вважаються перенесеними не тоді, коли код фізично опинився в новому проєкті, а лише тоді, коли нова реалізація повністю відповідає актуальній версії старого застосунку за бізнес-логікою та поведінкою.
Два продукти, одна команда: як не втратити синхронізацію?
Команда працювала над двома треками:
Перший — це поточні бізнес-задачі та визначений технічний борг.
Другий — створення нового frontend-застосунку та поступове перенесення в нього React-компонентів.
В основному над новим застосунком працював я. У випадках, коли треба було реалізувати нову складну логіку чи треба було швидко внести якісь зміни, підключався до команди, що працювала над поточним рішенням.
І навпаки, коли не було багато роботи на поточному треку, команда допомагала мені з міграцією.
Всі члени команди і клієнт знали, хто над чим працює і який прогрес міграції.
Що пішло не за планом?
Для кожного етапу ми мали орієнтовні строки й до цього моменту рухалися відповідно до плану.
Але ми призупинили міграцію майже на три тижні. І це не була неочікувана технічна проблема.
Це був бізнес-запит на додавання двох великих фіч до поточного продукту із визначеними часовими рамками їх імплементації.
Тож вся команда була залучена до їх реалізації. Вже після того, як клієнт прийняв ці зміни, ми повернулися до міграції та перенесли оновлену логіку в новий застосунок.
І це нормальна ситуація при такому типі міграції.
Також треба зазначити, що незважаючи на вимушені паузи в процесі міграції, ми завершили її у визначений і погоджений з клієнтом строк.
Як здійснювалося документування і тестування нового проєкту?
Після завершення перенесення компонентів і дописання необхідного коду, я написав документацію до проєкту на основі поточного рішення. У ній був описаний весь актуальний функціонал.
На основі цієї документації ми спочатку внутрішньою командою протестували нове рішення.
Потім його протестувала команда клієнта. Також був здійснений тестовий запуск для кількох користувачів.
І вже після цього ми вважали, що нове рішення готове замінити старе.
Коли новий проєкт замінив моноліт?
Ще на початку я закладав близько п’яти місяців на міграцію і планував production switch на різдвяно-новорічний час.
Бо працювали над цим проєктом не один рік і знали загальну тенденцію, що в цей період зменшується кількість запитів бізнесу на доопрацювання. Щоб ми не потрапили у нескінчений цикл: зробили зміни у поточному проєкті, поки переносили їх у новий, прилетіли нові запити на зміну у поточний.
В цей період команда могла більше зосередитися на фіналізації міграції.
У цей час зменшувалась і загальна активність користувачів. Що дає можливість краще контролювати заміну і визначати, чи все йде добре при запуску нового рішення.
Ми замінили поточне рішення на нове наприкінці грудня і вже в січні користувачі працювали на новому фронтенді.
Цікаво, що для користувачів функціонально майже нічого не змінилося. Відчутною різницею стала швидкість роботи застосунку.
Що я зробив би інакше?
Зараз я б ще на старті зафіксував набір метрик для порівняння старого та нового застосунків: local setup time, bundle size, rendering performance, кількість залежностей, частоту regression bugs. Це дозволило б оцінювати результат міграції більшою кількістю конкретних цифр.
Що ми отримали?
Міграція дала нам не тільки зміни версій у package.json.
Ми отримали після міграції:
- Фронтенд в окремому застосунку зі зрозумілою архітектурою та актуальним технологічним стеком і best practices;
- Новий застосунок мав тільки ті залежності, які були необхідні, що значно спростило його подальшу підтримку, оновлення та розвиток;
- Новий роутінг, опис всіх підпроєктів в одному файлі та групування загальних констант. Проєкт став зрозумілим і керованим;
- Підняття проєкту для нового співробітника в «дві команди»;
- Новий застосунок став гарною основою для подальшого розвитку.
Але це не все, паралельно було доопрацьовано поточний проєкт і перенесено в новий:
- Проєкт виріс із трьох до семи підпроєктів;
- Додано Google Maps з різними пошуковими модулями, фільтрацією, візуалізацією даних і відображенням взаємодії між дата-центрами;
- Замість багатовкладених таблиць — візуалізація поверхів, проходів і стійок в дата-центрах для швидкої навігації;
- Додано форми для додавання, зміни і копіювання даних, які валідували введену інформацію, в тому числі за характеристиками устаткування, що майже виключало помилки;
- Створено дашборди прогресу «буріння»;
- Додано зручний «центр» експорту даних, з вибором необхідних даних, їх відображення, та формату для експорту;
- Було здійснено міграцію для оптимізації зберігання даних;
- І багато іншого...
Що далі?
У новому проєкті з’явилися Testing Library та test scripts для CI.
Так само з’явилася можливість розвивати:
- документацію;
- code quality;
- Husky / lint-staged;
- CI/CD;
- Web Vitals;
- performance;
Нам вдалося відокремити frontend від legacy-моноліту, не зупиняючи delivery, зберегти функціональну відповідність старому застосунку та поступово перейти на нову архітектуру без ризику для користувачів і у визначені строки. Для мене головний висновок із цього кейсу простий: успішна міграція — це не момент, коли новий код задеплоєно в production, а момент, коли бізнес майже не помітив самого переходу, а команда після нього отримала систему, яку значно легше розвивати, тестувати й підтримувати.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівТеж свого часу прийшли до подібного типу міграції, як у статті. Типу створюється паралельний «Застосунок 1.9 NEW Lite», куди переносяться фіча за фічею («переноситься» тут — «створюється з нуля», а не «рефакториться і копіюється»). Ключова відмінність — його розробляла окрема команда, в тому числі щоб не захламляти новий застосунок старими упередженнями (спойлер — ходили по тим же граблям).
Як альтернатива розглядався варіант написати нове «ядро», винести все легасі в окрему частину і нові фічі робити по-новому, а старі тільки підтримувати. Але для конкретного проекту це б створювало додаткову невиправдану технічну і організаційну складність
Чи розглядали ви модні варіанти написати умовному клоду «є от таке от, зроби таке саме тільки with blackjack and hookers»? )))
Є різні підходи до міграції, і вибір залежить від контексту:
- обсягу та «віку» кодової бази,
- складності системи,
- кількості й рівня seniority людей у команді,
- кількості користувачів та їхньої активності,
- можливості або неможливості призупинити розробку поточного продукту,
- бачення клієнта щодо подальшого розвитку,
- інших факторів.
Щодо новомодного AI 🙂 Міграцію ми планували більше року тому. Тоді AI вже використовували, але переважно як помічника, а не як повноцінний «генератор коду». І ще невеликий коментар щодо масштабу: новий проєкт на момент запуску мав близько 600 файлів і майже 100 папок, без урахування node_modules. Старий був ще більшим. Тому просто «згодувати» весь проєкт моделі за один раз і отримати коректну міграцію було б, м’яко кажучи, проблематично.До речі, а як це перевірялось?
Мною була написана документація до нового застосунку — детальніше про її формат я описав в одному з коментарів нижче. На основі цієї документації ми провели перевірку нового застосунку в кілька етапів: спочатку всередині команди, потім з боку клієнта та окремих користувачів. Покривати код автоматизованими тестами ми почали вже після завершення міграції.
прикол, а почему тесты были после?
обычно тесты пишутся до — как раз чтобы можно было поймать регрессию. какой тогда смысл в тестах после миграции?
Тести під час міграції ми не писали системно з доволі практичної причини — це додатковий час. У нашому випадку було важливо провести міграцію достатньо швидко, щоб не потрапити в нескінченний цикл: поки переносимо компоненти, вони змінюються в поточному проєкті; поки переносимо ці зміни — з’являються нові.
Сама міграція була досить «waterfall-подібною»: був зрозумілий scope, порядок перенесення і відносно рідкі зміни, які прилітали з поточного продукту. Тому основний ризик був не стільки в регресіях усередині нового коду, скільки в затягування міграції і збнережеенні функціоналу.
Чи є сенс у тестах після міграції? Однозначно. Як я і писав у статті, міграція була великим етапом змін, але не самоціллю. Вона дала нам чисту технічну базу, на якій уже можна було нормально застосувати сучасні інструменти тестування і значно підвищити надійність проєкту.
а скільки часу пішло на міграцію? розраховували на пʼять місяців, чи вийшло?
Сама міграція зайняла близько п’яти місяців від моменту старту. Майже тритижнева пауза на великі бізнес-задачі входила в цей строк. Окремо приблизно три тижні пішло на підготовку: я формував план міграції, декомпозував роботи, погоджував підхід із командою та клієнтом.
У результаті ми завершили міграцію в грудні й наприкінці місяця повністю замінили старий frontend новим застосунком. Тут допоміг і запас часу приблизно у 10%, який ми заклали ще на етапі планування.
як ви визначили строк?
як це виглядає — це як сторі\таски як повинен працювати кожен компонент?
Строк визначали через декомпозицію міграції. Я зробив таблицю з усіма етапами: базовий каркас, routing, структура файлів, підпроєкти, сторінки, helpers тощо. Разом із командою оцінили кожен блок і додали приблизно 10% запасу. Після перших перенесених компонентів трохи скоригували оцінки. Частину буфера потім «з’їла» майже тритижнева пауза на дві великі бізнес-фічі, але загальну ціль ми досягли — завершили перенесення й тестування до новорічних свят. Наприкінці грудня переключили frontend на новий застосунок.
Щодо документації — це був не набір stories/tasks, а радше детальна інструкція по функціоналу кожної сторінки. У вигляді: дія/сценарій —> результат. Наприклад: змінюю вигляд таблиці —> змінюються такі блоки; вмикаю edit mode → відповідні поля стають доступними для редагування. Також описані базові validation cases: у формі вказаний порт, який вже занятий —> змінюється колір форми, вказується в якому блоці (конекторі) використаний цей порт, блокується зберігання. По цьому документу ми проводили внутрішнє тестування, потім передали його клієнту для перевірки. Там же фіксували статус кожного сценарію: реалізовано → перевірено командою → перевірено клієнтом → підтверджено.
Зараз я, скоріше за все, зробив би це ще ближче до test scenarios, щоб той самий опис можна було використовувати і для manual testing, і як основу для integration tests.
А я якщо викинути цей ai-слоп, то що ви конкретно зробили? У вас був не моноліт, а шарп бекенд плюс зоопарк на фронті і ви мігрували фронт на react?
Саме так