Как разрубить дракона: 10 месяцев миграции и одна ошибка, которую я бы исправил
Содержание
TL;DR: Эволюционная архитектура – это не “правильное проектирование сразу”, а способность дёшево менять решения по мере накопления знаний. На примере 10-месячной миграции с Ruby on Rails на .NET 9 показываю, как Strangler Fig, ACL, ADR и тесты-сверки работают вместе. И почему моей главной ошибкой был не выбор инструментов, а одна не проведённая ES-сессия.
Принципиально новый тип задания
Бизнес приходит с запросом: нужен новый тип задания. Не похож на существующие – другая логика, другой интерфейс, другая модель. Команда открывает код.
Задания в этой системе хранились в jsonb-колонке без чёткой схемы. В коде они читались в динамический тип, а потом по косвенным признакам – где-то по наличию ключа, где-то по строковому полю – превращались в нужный конкретный тип. У каждого вида задания была своя бизнес-логика, размазанная по контроллерам, helper-методам и стораджу. Везде, кроме того места, где она должна жить.
Ответ команды – стандартный:
“Архитектурно невозможно без масштабной переработки.”
Знакомо?
Это была не разовая ситуация. За десять лет в системе сложился ритуал: бизнес просит, аналитики пишут отчёт “почему нельзя”, разработка вздыхает, продакт идёт договариваться об упрощении. Кажется, что это про конкретный JSONB-маппер – а по факту про архитектуру, которая с какого-то момента перестала отличать “что мы поддерживаем” от “что мы вообще сможем сделать”.
Когда “архитектурно невозможно” звучит чаще, чем “сделаем”, архитектура превратилась в музей.
Эта статья – про то, как мы из такой точки пришли в место, где запросы бизнеса стоят дни, а не кварталы. И про одну ошибку, которой не хватило, чтобы пройти этот путь дешевле.
Я пришёл первым на этот проект в конце августа 2024-го. Собрал команду – нас стало четверо. Десять месяцев, чтобы разрубить дракона: монолит на Ruby on Rails, который рос 10 лет, и который все боялись трогать.
Дальше я буду рассказывать историю в том порядке, в котором мы принимали решения. Каждое решение тащило за собой инструмент: YAGNI, Last Responsible Moment, Strangler Fig, ACL, ADR, fitness functions. Это не справочная статья про каждый из них – справочников и без меня хватает. Я хочу показать, как они собираются в одну стратегию, и почему одна несостоявшаяся ES-сессия может стоить дороже, чем вся техническая часть.
Что заменяем, что нет
Самый частый разговор в начале миграции звучит так: “Ну ладно, раз переписываем – давайте сразу спроектируем правильно. Микросервисы, отдельная БД, новая модель данных. Чтобы потом не переписывать ещё раз.”
Это самый дорогой разговор в проекте.
У нас такой разговор тоже был. Аргументы за “правильно сразу”: монолит на Rails доказал, что общая БД и единое приложение – плохая идея. Раз уж мы переписываем application-слой, давайте переедем и в новую БД, и сразу выделим bounded contexts, и сразу подключим event bus, и сразу…
Мы не пошли этим путём. Сознательно. Я считаю, что это было самое правильное стратегическое решение на самом старте проекта. И не потому, что я какой-то особенный лид. У меня к этому моменту был опыт, в котором BDUF в условиях legacy-системы, которую все боятся, заканчивался одинаково: командой, которая полгода рисует диаграммы и не пишет код.
Хорошее проектирование на старте – не то, которое предусмотрело всё. Это то, которое позволило передумать потом.
Принцип, который мы зафиксировали в первую неделю: минимально достаточная замена. Заменяем application-слой, Rails → .NET 9 + Web API. Оставляем общую БД. Схему меняем точечно, по ходу – там, где она конкретно мешает. В отдельную новую БД не переезжаем.
Цена у этого решения тоже есть. Общая БД остаётся узким местом: Rails и .NET могут писать в одни и те же таблицы, и каждое изменение схемы требует, чтобы обе системы были с этим согласны. Когда команда одна и переключает контекст между стеками, это работает. Если бы у меня было две команды на двух стеках, я бы думал дважды.
Дальше я разделил все архитектурные решения на два класса.
Reversible. Замена endpoint’а в gateway – это reversible. Если новая версия ведёт себя не так – откатил маршрут, и трафик снова идёт в Rails. Цена ошибки – час работы и pull request.
Irreversible. Изменение схемы БД – это irreversible. Миграции назад не катают почти никогда. Цена ошибки – недели на придумывание, как жить с этим дальше.
Reversible-решения мы делали смело. Irreversible – точечно и поздно, после того как новая модель доказывала, что она работает.
Главное, что мы тогда сделали, – решили, чего не делать. В старом Rails-приложении было примерно 65-70 уникальных эндпоинтов. Скоупом миграции мы взяли где-то 30: те, которые блокировали продуктовое развитие или несли активную разработку. Оставшиеся 35-40 (админка, отчёты, какие-то старые интеграции) пометили как out-of-scope.
Не потому что они хорошие. Потому что они работали и не болели. Никто из бизнеса о них не вспоминал. Никто из команды не считал, что их нужно трогать. И это было главное правило: если оно работает и не блокирует – оно остаётся в легаси, пока кто-нибудь специально не попросит.
За все 10 месяцев проекта никто не попросил. Эти 35-40 эндпоинтов до сих пор в Rails. И, скорее всего, будут там ещё долго.
Это и есть YAGNI на архитектурном уровне: не переписывайте то, что не болит, даже если у вас уже есть инструменты, чтобы переписать. Зуд переписать “правильно” – самый дорогой инстинкт в инженерной культуре, и я наблюдал, как он превращает миграции в бесконечные проекты.
С какого маршрута начинать переключение
Strangler Fig весь держится на одном компоненте – router. У нас он назывался просто gateway: маленькое приложение на .NET, которое стояло перед всем – Rails, новый .NET, фронтенд. Внутри – правила маршрутизации, по которым каждый запрос направлялся либо в старое, либо в новое.
Ничего экзотического. Свой код, без зависимости от готовых API gateway-решений: нам были нужны не их фичи, а только маршрутизация по path и методу. Чем меньше магии, тем легче дебажить.
Главный вопрос гейтвея – не “как написать”, а “что переключаем первым”.
Можно было идти по фичам: взять самую болезненную и переписать её целиком. Интуитивно, но рискованно. Самая болезненная фича обычно большая, со связями, записями в БД и интеграциями. Если что-то идёт не так на первом же маршруте, доверия к gateway больше нет.
Можно было идти по бизнес-приоритетам: что больше всех просят. Тоже опасно. Бизнес просит сложное, и первая попытка миграции попадает под пристальный взгляд продакта.
Мы пошли по уровню риска. Сначала самое безопасное – потому что нам нужен был сигнал, что gateway вообще работает. Потом, по мере роста доверия, всё более стрёмное. План разделился на 5 фаз.
Сразу про ловушку этого подхода, потому что я в неё попался. Read-only фазы проходят слишком гладко (на read-only трудно сломать), и команда подходит к фазам с writes расслабленной. Каждый сложный write-маршрут возвращает риск к стартовому, и недооценка этой границы стоит дорого. Перестраивать темп оценок при переходе через эту границу я научился не сразу.
Фаза 1. Read-only статичное. Каталог курсов, дерево курса (курс → модули → блоки → уроки → задания), профиль преподавателя. Данные меняются редко, читаются часто, нет записи – нет проблем с консистентностью. Идеальный кандидат для первого маршрута. Мы переключили один эндпоинт, каталог, и неделю смотрели на метрики и логи. Когда стало понятно, что всё работает и фронт ничего не заметил, переключили остальные read-only-маршруты пачками.
Фаза 2. Read-only состояние. Прогресс студента, оценки. Тоже read-only снаружи, но данные меняются Rails’ом, и читать их нужно консистентно. Здесь впервые упёрлись в вопрос инвалидации кэша: дерево курса можно кэшировать на час и даже день, прогресс – нельзя. Эта фаза стоила раза в два дольше, чем первая.
Фаза 3. Writes низкого риска. Загрузка контента, обновление профиля, мелкие админ-операции. Параллельно с тестами-сверками (про них – отдельный раздел). На каждый маршрут была возможность отката одним конфигом.
Фаза 4. Writes критичные. Запись оценок, прохождение, выдача доступов. Самое стрёмное. Переключали по одному маршруту, держали ручку отката в зоне досягаемости.
Фаза 5. API v2. Это поворотный момент. К моменту, когда основные критичные writes уехали в .NET, наша новая система перестала быть просто переписанным Rails. Команда уже не хотела дублировать форму старого API. Ей хотелось делать API так, как нужно фронту, а не как было удобно Ruby 10 лет назад.
В Rails было примерно 12 уникальных маршрутов, которые мы реально планировали заменить. В .NET после миграции их стало 7-8 – потому что часть мы консолидировали (один новый endpoint вместо двух старых). Плюс мы вытащили 4-5 принципиально новых v2 эндпоинтов – взяли “запросы-монстры”, из которых фронт получал по 20 разных кусков данных одним запросом, и разрезали их на нормальные ресурсы. Фронтендеры плакали от радости.
Strangler Fig – это не “переписать всё, что было”. Это две фазы: сначала parity, потом – освобождение от формы legacy.
Без второй фазы Strangler Fig – это переписывание. Полезное, но не эволюция. Вы остаётесь заложником старого API, тестов-сверок и собственных ожиданий “как было в Ruby”. Чтобы получить эволюцию (способность системы меняться в смысле, а не только в коде), нужно в какой-то момент сказать “всё, parity больше не наш приоритет”.
В нашем случае этот момент пришёл к концу пятого месяца. Мы это не планировали. Просто заметили, что обсуждение “как лучше сделать этот endpoint” внутри команды стало интереснее, чем “как этот endpoint работает в Ruby”.
ACL, который рос вместе с пониманием
Чтобы команда вообще смогла начать думать в новой модели, у неё должна была быть эта новая модель. Не переписанные эндпоинты, а домен, изолированный от формы legacy. Иначе команда продолжает думать в старых терминах, потому что других у неё нет. Этим занимался ACL.
Anti-Corruption Layer – слой, который защищает новую модель от формы старой. Звучит просто. На практике это слой, который вы постоянно перепиливаете, потому что каждый раз кажется “вот теперь точно правильно”.
В книгах ACL обычно рисуют как один большой блок: вот legacy-система, вот наш новый код, вот ACL между ними. Чёткая граница. На реальном проекте всё происходит совершенно иначе.
Мы не спроектировали ACL единым большим слоем. У нас не было такой возможности – мы не знали достаточно о legacy-схеме, чтобы заранее придумать “правильную” границу. Любая попытка сделать это в первую неделю была бы гаданием.
Поэтому мы делали ACL по контурам появляющейся боли.
Слой 1. Дерево курса. Самые статичные данные системы: курс, модули, блоки, уроки, задания. Структура, по которой проходят студенты. Без этого нельзя ничего – все остальные сущности на неё ссылаются.
Здесь мы написали первый mapping-слой – простой и общий. Читаем нужные таблицы напрямую через DbContext, маппим в типизированные DTO, кэшируем дерево целиком на час. Этого хватало для большинства чтений. Радостно записали себе галочку “ACL готов”.
Слой 2. Задания. Через несколько недель команда начала писать новые endpoint’ы, которые работали с заданиями. И тут всё посыпалось.
Задания, как я писал в начале, хранились в jsonb. У них не было схемы. У одного типа задания одно поле могло значить “правильный ответ”, у другого – “список вариантов”, у третьего – “метаданные для фронта”. Чтобы понять, какой это тип задания, нужно было смотреть на сочетание трёх косвенных признаков: наличие ключа, формат значения, иногда – контекст из родительского блока.
Первый код, который это разбирал, был написан внутри сервиса заданий. Через две недели – на код-ревью – кто-то из команды сказал ровно одну фразу:
“Я открываю этот метод. Я не понимаю, что я только что прочитал.”
Это было правдой. Метод вырос до сотни строк, и чтобы добавить новый тип задания, нужно было удерживать в голове всю историю того, как косвенные признаки взаимодействовали друг с другом.
Тогда мы вынесли это в отдельный слой – точечный маппер именно для заданий. Не из-за элегантности. Из-за того, что общий mapping-слой больше не справлялся.
Слой 3. Прохождение. Структура прохождения студентом курса – какое задание сделал, какую оценку получил, как агрегировалось наверх – отрабатывала похожим образом. Накрутили её поверх первых двух слоёв позже, после того как дерево курса было полностью восстановлено и задания типизированы.
Получился ACL из трёх разных по природе слоёв: общий mapping-слой для статичных структур, точечный маппер для заданий и маппер прохождения, который опирался на оба нижних. Никакого общего фреймворка ACL у нас не было, и мы его не пытались сочинить. Каждый слой появился под конкретную боль. Единственное, что у них было общее – то, что все они появились по факту, не по плану.
Если бы мы попытались спроектировать это в первую неделю, у нас получился бы либо абстрактный универсальный slim, который не справлялся бы с заданиями, либо переусложнённая трёхслойная конструкция, в которой 80% кода работало бы вхолостую.
Last Responsible Moment – это не прокрастинация. Это решение, принятое в момент, когда у тебя есть достаточно информации, и ни моментом раньше.
Когда у вас есть legacy-система, про которую вы ещё не знаете, как именно она устроена в неудобных местах, оставляйте абстракцию плоской. Боль найдёт вас сама. Когда найдёт, у вас будет достаточно информации, чтобы выбрать правильную границу.
За LRM есть цена, и про неё в книгах пишут реже, чем про сам принцип. Пока боль не появилась, существующий код опирается на плоскую абстракцию. Когда выносишь точечный маппер – приходится переделывать то, что уже использовало старый. У нас mapping-слой перепиливался два-три раза. Это нормальная цена за то, что мы не пытались угадать структуру в первую неделю, но команде, которая платит за рефакторинг существующего кода каждый раз, приходится держать это в голове.
Вот как это выглядело в коде:
// BEFORE: чтение JSONB, dynamic, switch по косвенным признакам
public Exercise ReadExercise(JsonDocument raw)
{
dynamic obj = JsonSerializer.Deserialize<dynamic>(raw);
if (obj.options is not null && obj.correct is not null)
return BuildMultipleChoice(obj); // косвенный признак №1
if (obj.text is not null && obj.checker is not null)
return BuildOpenAnswer(obj); // косвенный признак №2
if (obj.audio_url is not null)
return BuildListening(obj); // косвенный признак №3
throw new InvalidOperationException("Unknown exercise type");
}
// AFTER: маппер с явным контрактом, типизированная модель
public Exercise ReadExercise(JsonDocument raw)
{
var legacy = LegacyExerciseParser.Parse(raw);
return legacy.Kind switch
{
LegacyExerciseKind.MultipleChoice => MultipleChoiceExercise.From(legacy),
LegacyExerciseKind.OpenAnswer => OpenAnswerExercise.From(legacy),
LegacyExerciseKind.Listening => ListeningExercise.From(legacy),
_ => throw new UnknownLegacyExerciseException(legacy.RawType)
};
}
В этом коде важна не красота. Важен один шов: LegacyExerciseParser. Всё, что выше него, наша доменная модель. Всё, что ниже, старая система. Шов прошёл в той точке, где это начало болеть, а не там, где мы провели его на диаграмме.
Что мы не записали и пожалели
Через 6-7 недель после того, как мы выкатили новый access manager, в систему пришёл партнёр.
Крупная компания, отправляющая своих сотрудников на языковые курсы. Их HR хотел сделать в системе три вещи: видеть прогресс сотрудников своей компании (но не чужих), докупать места на компанию, а не на конкретного человека, и раздавать доступы внутри своих сотрудников по своим правилам.
Мы посмотрели на это и поняли, что наш access manager этого не умеет. Совсем.
Что мы строили: access manager под 3 роли – студент, преподаватель, админ. Это были “громкие” роли в Rails: вокруг них крутилась бо́льшая часть кода, бизнес-логики и документации. Когда мы переписывали этот модуль, мы их и взяли как основу.
В Rails-коде, конечно, был и HR. Реализован он был через какие-то хардкодные исключения в permission-логике – кто-то когда-то добавил их под одного клиента, никто не задокументировал, что эти исключения вообще существуют, и ни в одном обсуждении дизайна нового access manager они не всплыли.
В новой модели их не было.
Цена ошибки: примерно 3 спринта. Пришлось переосмыслить, что такое “принадлежность” в нашей системе. Раньше у нас было “студент → курс”: студент имеет доступ к курсам, на которые ему выданы лицензии. Теперь нужно было ввести понятие компании, отношений между компанией и студентами, и научить access manager отвечать на вопрос “у этого пользователя есть прогресс этого студента?” через цепочку “студент → компания → HR → сотрудники компании → этот студент”.
Это не катастрофа. Систему мы доделали, партнёра подключили, проект продолжился. Но это были 3 спринта работы поверх уже задеплоенного модуля – ровно та история про стоимость изменений, о которой я писал в первой статье цикла. Решение, которое стоило бы час обсуждения в начале, в середине миграции стоит трёх спринтов. Если бы я тогда знал, что эти 3 спринта потребуются, я бы сделал по-другому.
Главный вопрос: что могло бы заставить нас узнать про роль HR раньше? Не “нужно было лучше работать”, а конкретный механизм.
Один из таких механизмов – ADR (Architecture Decision Record). Маленькая запись в репо на каждое заметное архитектурное решение: контекст, решение, последствия. Не магия – дисциплина.
Если бы мы вели ADR, для access manager там было бы написано примерно следующее:
ADR-007: Модель ролей в access manager
Контекст:
- В Rails существуют три явные роли: student, teacher, admin.
- Permission-логика реализована через if-else по этим ролям.
- Предполагается, что других ролей в системе нет.
Решение:
- В новой модели заводим три enum-значения: Student, Teacher, Admin.
- Всё, что не подпадает под эти три роли, считаем багом и логируем.
Последствия:
- Новая модель не покроет роли, реализованные в Rails через
хардкод-исключения, если такие существуют.
Сильнейшее место этого ADR – последняя строка про “хардкод-исключения, если такие существуют”. Записать её – означает заставить кого-то на ревью этого ADR ответить на вопрос существуют ли такие исключения. Команда из четырёх человек, обсуждая этот ADR, скорее всего, потратила бы час на то, чтобы посмотреть в код и найти HR-исключение. Час против трёх спринтов.
ADR – это не бюрократия. Это память команды о решениях, которые казались очевидными, пока не стали неочевидными.
Главный механизм работы ADR – даже не запись, а ревью записи. ADR заставляет вас зафиксировать допущения, которые вы делаете. Допущение, которое записано, видно. Допущение, которое вы держите в голове, проверить нельзя.
У ADR есть один способ умереть – стать кладбищем, в которое никто не ходит. Если их пишут для галочки и не пересматривают, когда реальность подсказывает иное, они ничего не стоят. Без ритуала “прочитал перед PR” это просто папка с файлами. Я бы предпочёл 5 живых ADR, чем 30 в папке, про которую все забыли.
Айсберг ролей нужно поднимать ДО того, как пишешь модель.
Это работает не только с ролями. С платежами, заказами, пользователями – везде, где у бизнеса есть “обычные случаи” и “исключения”, шансы, что вы знаете обо всех исключениях с порога, очень малы. ADR – самый дешёвый способ обнаружить, что вы про них не знаете, до того, как написали код.
ADR полезны не только для архитектурных решений. У нас был ещё один пример – короткий, но болезненный. На Ruby пересчёт оценок работал так: на каждое действие пользователя система пересчитывала оценки за все задания по всему дереву прохождения курса, с агрегациями вверх (на урок, блок, модуль, курс) и долями в процентах от максимальной. На больших курсах это занимало секунды. У нас в .NET на старте это поведение мы скопировали ради соответствия. Потом несколько недель чесали голову, почему отдельные операции тормозят, перебирали стратегии оптимизации, пробовали кэши, инкрементальные пересчёты, очереди.
Мини-ADR на эту тему, написанный в первый же спринт миграции этого модуля, был бы простым: контекст – Ruby пересчитывает агрегированные оценки на каждое действие по всему дереву; на больших курсах это секунды. Решение – на этапе parity повторяем поведение Ruby, фиксируем как архитектурный долг и план перехода на инкрементальный пересчёт через domain events (тема отдельной статьи цикла про event-driven архитектуру). Последствия – замедление останется до перехода, и команда не должна тратить время на ad hoc оптимизации этой части, потому что план уже есть.
Если бы такая запись лежала в репозитории, мы бы не потратили эти недели на эксперименты. Делали бы parity, шли дальше, и в нужный момент закрыли долг по плану.
И ещё одна категория решений, которые я бы тогда не подумал записывать в ADR, – операционные. Observability в наших новых сервисах появилась поздно: расследовали баги вручную, тратили часы на то, что трейс закрывал бы за секунды, и session trace id на фронте команда внедряла со скрипом. Это не “архитектурные решения” в классическом смысле, но мы их не записали и поэтому не пересмотрели вовремя.
Урок: ADR полезны не только архитектурным решениям. Любое решение, которое должно сработать через полгода, стоит записать сейчас.
Почему сверки оказались нашим главным fitness function
Если открыть книгу Нила Форда “Building Evolutionary Architectures”, fitness functions там определены как автоматизированные проверки, охраняющие архитектурные свойства системы: метрики связности, dependency rules, бюджеты производительности. Тесты, которые ловят, когда новый код начинает дотягиваться туда, где ему не место.
У нас всё это было на минималках. Базовые тесты архитектуры: доменный слой не зависит от инфраструктурного, новый код не ходит в Rails-таблицы напрямую через DbContext. Полезно, но не главное.
Главным fitness function у нас были тесты-сверки. В книгах их не описывают – они узко-специфичны для миграции. Но в наших условиях это была единственная штука, на которой держался каркас.
Как они работали. На каждый Ruby-маршрут, который мы планировали переписать, мы вручную описывали все его исходы: входы, ответы, изменения в БД до и после, side-эффекты (письма, пуши, события). Неделя работы на каждый сложный маршрут, иногда дольше, если в Rails этот маршрут делал что-то, что в документации не было описано. А такого оказалось больше половины.
Звучит как кошмар. Так и было. Если умножить “неделя на сложный маршрут” на тридцать маршрутов в скоупе, получаются десятки человеко-недель только на спецификации – до того, как мы написали хоть строчку нового кода. Когда команда спорила, нельзя ли быстрее, у меня не было ответа на вопрос “и как тогда мы убедимся, что ничего не сломали”.
Потом мы переписывали эту спецификацию в виде тестов. Сначала прогоняли тесты против Rails – фиксировали реальное поведение. Потом писали .NET-реализацию, прогоняли те же тесты против неё. Diff между ответами – это и был наш fitness function. Если diff пустой – переключаем маршрут в gateway. Если есть – разбираемся, кто прав: Rails или новая версия.
Это был не лучший способ. Это был единственный способ. Своих тестов в Rails-проекте не было – за десять лет их не написали. Документация в Confluence существовала, но опровергалась кодом примерно через предложение.
В сумме эти сверки нашли где-то 50 багов в Rails. Не в новой системе – в старой, которая работала в проде уже 10 лет. Часть этих багов мы воспроизвели в .NET сознательно: фронт уже на них опирался, и исправить значило бы сломать пользовательский опыт. Часть исправили, после явного решения “это не должно так работать”. Каждый такой баг – это разговор в команде на полчаса: что Ruby делает, что мы хотим вместо этого и какое из двух мы воспроизводим в .NET.
Самое страшное в этой миграции было не то, что в документации написано одно, а в коде другое. Привычная история.
Самое страшное было в том, что в это верили все.
Включая самих рубистов. Они годами работали с этим кодом, сами его писали. Говорили мне “этот метод делает X”. Я смотрел в код вместе с ними, и метод делал что-то другое. Они смеялись: “да, точно, я забыл, мы это поправили”. Потом мы запускали сверки, и оказывалось, что и поправка делала не то.
Включая руководителя проекта, которая работала с этим продуктом с самого его основания, 10 лет. Она лучше всех знала, как устроена бизнес-логика. И в нескольких узловых местах её представление о том, что делает Ruby, расходилось с тем, что Ruby действительно делал.
Это не амнезия. Никто ничего не забыл – все были уверены, что знают. Это коллективная уверенность без проверки. Сценарий, в котором каждый отдельный человек точно знает, как работает система, а вместе они её не знают. Страшнее амнезии, потому что амнезию можно заметить.
Главная защита эволюции – не “новая система остаётся чистой”, а “новая система ведёт себя как старая, пока мы сами не решим, что должно быть иначе”.
Сверки нашли поведение, о котором не знал никто из живых носителей контекста.
Если бы я писал главу в книге Форда, я бы добавил отдельную категорию fitness functions для миграций – behavioural parity tests. Архитектурными они не являются. Но в момент, когда у тебя нет других тестов вообще, parity-фикстуры остаются единственным, что отделяет управляемую миграцию от рискованной.
30% мёртвого груза, дракон без головы и одна ES-сессия
К концу десятого месяца у нас был работающий .NET-сервис, gateway, который маршрутизировал бо́льшую часть трафика на новую систему, и Rails, в котором осталось то, что мы решили не трогать.
Из старой кодовой базы мы вычистили большую часть мёртвых фич – те, по которым стало понятно, что они никому не нужны. Удаляли, закрывали PR, мержили в master. По нашим оценкам, около 30% мёртвого кода всё-таки осталось. Слишком связное, слишком переплетённое с живым. Цена за то чтобы выдрать это из кода перевешивала пользу.
Это не провал. Это граница эволюции в реальных условиях.
Эволюционная архитектура не значит, что всё переедет. Иногда часть остаётся как памятник – и это нормально, пока вы знаете, что именно памятник.
Главное – знать. Если эти 30% помечены, описаны и про них есть консенсус “не используется, не трогаем” – это управляемый долг. Если про них думают “вроде используется, но на всякий случай” – это бомба замедленного действия.
Тактический DDD мы применяли постоянно, когда выносили бизнес-логику из контроллеров и хранимых процедур в код. Без этого parity-тесты ловили бы только формы ответов, не смыслы. Доменная модель – это то, что переводит “база делает Y” в “бизнес делает Y”; без этого перевода эволюция превращается в замену синтаксиса.
Стратегический DDD – мы выделяли bounded contexts по ходу миграции, а не на старте. Сработало, но не оптимально. О том, что я сделал бы иначе, – через пару абзацев.
Техдолг – 30% мёртвого кода в Rails – это осознанный кредит. Мы знаем, сколько он стоит и при каких условиях его придётся погасить. Это не “грязный код”, это кредит, который мы взяли с пониманием процента.
Если бы меня сейчас спросили, что я сделал бы по-другому в этом проекте – это не gateway, не ACL и не тесты-сверки. Это Event Storming.
За 10 месяцев я провёл одну ES-сессию. Одну.
Если бы в первую неделю я собрал продакта, продажи, методистов и преподавателей у одной стены со стикерами – мы бы узнали про роль HR до того, как написали первую строчку нового access manager’а. Один день ES в начале вместо трёх спринтов на переделку модели в середине.
Если бы я повторял такие сессии раз в спринт – бизнес узнавал бы о новых возможностях архитектуры одновременно с командой, а не через 2 недели. Они бы не отвечали клиенту “в Rails нельзя” в моменты, когда в .NET уже было можно.
Это не была моя ошибка как разработчика. Это была моя главная ошибка как лида.
И возвращаясь к началу – к тому JSONB-заданию, с которого мы стартовали. В новой системе это уже не “архитектурно невозможно”. Принципиально новый тип задания – это новый mapping в LegacyExerciseParser или, если структура иная, новая ветка в типизированной модели. Дни работы, не кварталы.
Всё остальное – gateway, ACL, ADR, тесты-сверки – было ради того, чтобы это стало правдой.
Лучшая архитектура – та, которую дёшево менять. На этом проекте я перестал пытаться угадывать будущее. Теперь стараюсь делать так, чтобы цена любой ошибки была низкой. И сколько бы инструментов я ни знал, общий язык с бизнесом всё равно остаётся главным, что определяет, попаду ли я с первого раза в нужное направление.
Это была последняя статья цикла “Проектирование, которое работает”. Если у вас был свой опыт миграции легаси – удачной или застрявшей на полпути – расскажите в телеграм-канале. Там же скоро разберу, что не уместилось в восемь статей и что будет дальше.
Что почитать
- Neal Ford et al., “Building Evolutionary Architectures” – каноническая книга про эволюционные архитектуры и fitness functions.
- Michael Feathers, “Working Effectively with Legacy Code” – лучшая книга про работу со старым кодом, который страшно трогать.
- Sam Newman, “Monolith to Microservices” – Strangler Fig подробно, от стратегии до конкретных паттернов.
- Eric Evans, “Domain-Driven Design” – глава про Anti-Corruption Layer, оригинал.
- Michael Nygard, “Documenting Architecture Decisions” (2011) – статья, с которой пошла практика ADR.