1. Декомпозиция уменьшает радиус следующего изменения
Когда говорят «декомпозиция», многим сразу представляется что-то между clean architecture и корпоративной диаграммой на сорок стрелок. На практике всё гораздо спокойнее. Забудьте: идеальная архитектура не нужна. Нужно, чтобы следующая правка цепляла меньше зависимостей, побочных эффектов и мест, которые внезапно загораются красным.
Проблема legacy-модуля редко в том, что метод просто длинный. Гораздо чаще беда в том, что один кусок кода знает слишком много: читает из базы, считает бизнес-логику, пишет аудит, вызывает внешний сервис и по дороге правит старый edge case, о котором помнят только бухгалтерия и один уставший тимлид.
Вот типичный пример из mrr-engine:
import java.math.BigDecimal;
import java.math.RoundingMode;
public BigDecimal calculate(Subscription sub) {
BigDecimal annualPrice = jdbcTemplate.queryForObject(SQL, BigDecimal.class, sub.getPlanCode());
auditService.record("mrr-calc", sub.getId()); // побочный эффект
BigDecimal monthly = annualPrice.divide(new BigDecimal("12"), RoundingMode.HALF_UP);
return refundPolicy.applyLegacyAdjustment(sub, monthly);
}
На первый взгляд код даже не выглядит ужасно — несколько строк, а внутри слеплены четыре ответственности: чтение данных, аудит, расчёт, legacy-коррекция refund-логики. Захотите поменять один расчёт — всё равно продеретесь мимо базы, аудита и refund-policy. Слишком много связей в одной точке.
Декомпозиция — не «нарезать проект на папки», а чтобы расчёт знал про расчёт, чтение — про чтение, побочный эффект — про побочный эффект. Чем меньше код знает лишнего, тем дешевле его менять.
2. Seam — место для безопасного разреза
Термин seam звучит чуть академично, но сама идея очень земная. Это место, где вы подменяете или изолируете поведение, не переписывая весь модуль вокруг. Молния на куртке. Куртку иногда надо открыть, но лучше молнией, а не ножницами. В legacy-коде ножниц и так хватает.
Важно, что не каждая функция автоматически становится seam: метод, намертво связанный с конкретным SQL, системным временем, логгером и глобальным состоянием, точкой разреза не станет. У хорошего seam три признака: понятный вход и выход, он прячет нестабильную деталь, его поведение видно через текущий baseline.
Для CashFlow Dashboard особенно полезны три вида seam:
| Вид seam | Пример в mrr-engine | Почему это хороший кандидат |
|---|---|---|
| Чистый расчёт | месячный MRR из годовой цены | легко покрывается тестом по входу и выходу |
| Чтение данных | поиск цены тарифа по planCode | инфраструктурная деталь прячется за интерфейсом |
| Побочный эффект | запись audit log | можно изолировать и не смешивать с расчётом |
Ниже — очень простой пример seam для чтения данных:
import java.math.BigDecimal;
public interface PlanLookup {
BigDecimal annualPriceFor(String planCode);
}
Теперь SQL можно спрятать в адаптере, а остальной код будет работать с абстракцией, а не с конкретным jdbcTemplate:
import java.math.BigDecimal;
public class JdbcPlanLookup implements PlanLookup {
public BigDecimal annualPriceFor(String planCode) {
return jdbcTemplate.queryForObject(SQL, BigDecimal.class, planCode); // SQL спрятан здесь
}
}
Заметьте, мы не сделали мир лучше целиком. Мы всего лишь перестали тащить SQL-детали внутрь расчётного кода. Это скромный шаг, но именно из таких шагов складывается безопасная модернизация.
3. Архитектурная граница — это ответственность
Очень соблазнительно объявить границей модуля папку. Есть папка mrr, значит, вот и модуль. К сожалению, legacy-код не обязан уважать наши папки. Если внутри mrr живут SQL-запросы, прямые вызовы биллинга, логирование, форматирование отчёта и кусок API-логики, то папка есть, а границы нет. Есть просто склад всего подряд.
Границу лучше определять по ответственности. Если кусок кода отвечает за расчёт, он не должен одновременно отвечать за способ получения цены тарифа. Если кусок отвечает за аудит, он не должен решать, как считается monthly recurring revenue. Как только роли разделяются, зависимости тоже начинают выпрямляться: внешний слой знает про внутренний, но не наоборот.
Схематично это может выглядеть так:
flowchart LR
A[API / отчёт] --> F[MrrFacade]
F --> C[CalcCore]
F --> P[PlanLookup]
F --> S[AuditSink]
P --> J[(Legacy SQL)]
S --> L[(Audit log)]
В такой схеме CalcCore не знает ни про JDBC, ни про логирование. Он получает уже подготовленные данные и возвращает результат расчёта. Способ получения данных можно менять отдельно, и формулу — отдельно. Это резко снижает стоимость изменений.
Проверка очень простая. Если вы меняете расчёт и вдруг вынуждены трогать код логирования, SQL или контроллер, значит, граница пока слабая. Если же можно поменять одну часть и остальным по-прежнему достаточно того же контракта на входе и выходе, граница стала заметно лучше.
И ещё один полезный вопрос: в какую сторону смотрят зависимости? Если зависимость идёт от внешнего слоя к внутреннему, это нормально. Если бизнес-логика начинает зависеть от деталей инфраструктуры так, что без них не живёт, это уже сигнал тревоги. В CashFlow Dashboard как раз выгодно стремиться к тому, чтобы формула MRR зависела от данных, а не от способа, которым эти данные достали.
4. Facade, Adapter и anti-corruption
Как только разговор доходит до декомпозиции, на сцену сразу выходят знакомые актёры: Facade, Adapter и anti-corruption layer. Проблема в том, что в команде их часто используют как заклинания. Сказали «нужен adapter» — все кивнули, никто не понял. Давайте разложим по-человечески.
Facade — это стабильный вход в неровный legacy-модуль. Снаружи вы получаете один понятный интерфейс, а внутри можно постепенно наводить порядок. Adapter — это переводчик между старым и новым интерфейсом. Он нужен, когда одна сторона говорит на одном языке, а другая — на другом. Anti-corruption layer — это уже не просто переводчик, а защитный экран: он не даёт старым моделям и странным форматам протечь в новый код.
Небольшая таблица обычно снимает половину путаницы:
| Инструмент | Что делает | Когда полезен |
|---|---|---|
|
даёт один понятный вход в legacy-модуль | когда внутри хаос, а снаружи нужен стабильный контракт |
|
переводит старый интерфейс в новый | когда источник данных или сервис неудобен для нового кода |
|
изолирует новый код от legacy-моделей | когда старые сущности слишком токсичны и не должны выйти наружу |
Например, фасад для MRR может выглядеть так:
import java.math.BigDecimal;
public class MrrFacade {
public BigDecimal calculate(Subscription sub) {
BigDecimal annualPrice = planLookup.annualPriceFor(sub.getPlanCode());
return calculator.monthlyFromAnnual(annualPrice);
}
}
Адаптер может забирать LegacyRow и превращать его в нормальную структуру, с которой уже работает новый код:
public class LegacyPlanAdapter {
public PlanInfo load(String planCode) {
LegacyRow row = legacyRepository.findByCode(planCode);
return new PlanInfo(row.getCode(), row.getAnnualPriceCents()); // наружу уходит уже новая модель
}
}
Главное здесь — не переусердствовать. Иногда anti-corruption layer делают такой толщины, что его самого пора оборачивать ещё одним защитным слоем. Хватает одного интерфейса PlanLookup — не стройте пятислойную архитектуру ради красоты. Это модернизация, а не архитектурный театр.
5. Расчёт отдельно, побочные эффекты отдельно
Самый дешёвый тип декомпозиции почти всегда не про классы, а про разделение чистой логики и побочных эффектов: одна часть считает, другая пишет в лог, ходит в базу, смотрит на время, шлёт событие наружу. Пока они в одном комке, любой рефакторинг дорогой и нервный.
Хорошая новость в том, что именно такие разрезы обычно лучше всего держатся на characterization baseline: вынесенный в отдельный метод чистый расчёт гоняется на тех же входах и даёт тот же наблюдаемый результат, а инфраструктура остаётся снаружи и меняется реже.
Например, чистый расчёт можно сделать таким:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class MrrCalculator {
public BigDecimal monthlyFromAnnual(BigDecimal annualPrice) {
return annualPrice.divide(new BigDecimal("12"), RoundingMode.HALF_UP); // чистый расчёт
}
}
А orchestration-слой пусть собирает данные и вызывает побочные эффекты отдельно:
public BigDecimal calculate(Subscription sub) {
BigDecimal annualPrice = planLookup.annualPriceFor(sub.getPlanCode());
BigDecimal monthly = calculator.monthlyFromAnnual(annualPrice);
auditSink.recordCalculation(sub.getId(), monthly); // побочный эффект отдельно
return refundAdjustment.apply(sub, monthly);
}
Заметьте, мы не обязаны вытаскивать вообще всё прямо сейчас. Если refundAdjustment тесно завязан на соседние модули, спорную legacy-логику и внешние побочные эффекты, его совершенно нормально оставить в колонке «пока не трогаем». И это не слабость. Наоборот: хороший рефакторинг часто определяется не тем, сколько вы вынесли, а тем, сколько осознанно не тронули.
6. Claude Code ищет точки разреза в inspect-режиме
Claude Code особенно полезен не в режиме «сделай красиво», а в режиме «помоги найти кандидатов и объяснить риски». Если дать слишком общую команду, он с удовольствием предложит вам полкапремонта сервиса, переименование пяти классов и, возможно, духовное обновление команды. Поэтому здесь важен строгий inspect-only режим: сначала анализ, потом решение, и только потом правки.
Хороший запрос просит не только кандидатов на seam, но и доказательства: где файл, что именно изолируется, чем это покрыто из baseline и почему это можно брать сейчас. Отдельно полезно просить колонку «не трогать». Она экономит очень много времени.
Например, запрос может быть таким:
Исследуй модуль `cashflow-dashboard/mrr-engine` без изменений в коде.
Найди кандидаты на безопасные seams.
Для каждого кандидата верни:
- файл и метод;
- что изолируется: чтение данных, побочный эффект или legacy-формат;
- чем это покрыто из characterization baseline;
- риск ошибочного извлечения;
- статус: "можно брать сейчас" или "лучше отложить".
После этого полезно подключить reviewer-подход из Workflow Kit уже к самому diff, а tester — к покрытию baseline. То есть Claude сначала помогает выбрать точку разреза, потом другой контекст или агент проверяет, что diff действительно не тащит лишнего. Так Workflow Kit перестаёт быть абстрактным набором конфигов и начинает реально помогать CashFlow Dashboard на конкретном legacy-срезе.
И, конечно, стоит избегать запросов вроде «приведи модуль в порядок». Это почти гарантированный путь к broad refactor. Claude не умеет читать вашу скрытую мысль «только чуть-чуть». Эту «чуть-чуть» нужно написать явно.
7. Признаки области, которую лучше не трогать
Одна из самых взрослых привычек в legacy-модернизации — уметь честно говорить «не сейчас». Обычно новички думают, что хороший инженер обязан смело идти в самые страшные места. На практике хороший инженер сначала отмечает опасные места на карте и не наступает туда без необходимости. Да, звучит менее героически. Зато с работающим продом.
Есть простая разница между хорошим кандидатом на extraction и зоной, которую пока лучше оставить в покое.
| Признак | Обычно можно брать сейчас | Обычно лучше отложить |
|---|---|---|
| Наблюдаемое поведение | детерминированное, легко сравнить | зависит от времени, окружения, сторонних сервисов |
| Побочные эффекты | нет или один понятный | несколько записей, транзакции, каскадные вызовы |
| Покрытие baseline | есть fixtures и golden master | baseline слабый или не покрывает этот путь |
| Радиус влияния | один модуль, один контракт | цепляет billing, refunds, отчёты и API сразу |
| Цена rollback | один commit revert | ручное восстановление данных или конфигов |
На примере CashFlow Dashboard это обычно означает, что PlanLookup — хороший старт, а refund-handling в середине billing-потока — плохой. Не потому что refund не важен, а потому что как первый шаг он слишком дорог. Сначала выпрямляют лёгкие и полезные границы, глубже заходят потом.
И вот тут колонка defer становится особенно ценной. Запись «refund recalculation — не трогаем в этой фазе» — не позор, а знак, что вы контролируете scope, а не идёте из любопытства в неизвестную пещеру без фонаря.
8. Рабочая заметка по декомпозиции
Когда анализ уже сделан, очень полезно зафиксировать его в короткой рабочей заметке. Не бюрократия, а инженерный черновик: секция внутри REFACTOR_LOG.md или временный markdown-файл в ветке. Главное, чтобы следующий человек, включая вас самого через два дня, понял, где мы видим границы и почему.
Пример такой заметки может быть совсем компактным:
# Декомпозиция mrr-engine
## Текущее сцепление
Расчёт MRR смешан с SQL-чтением тарифа, audit log и legacy refund-adjustment.
## Кандидаты-швы
- PlanLookup: чтение цены тарифа
- AuditSink: запись аудита
- CalcCore: monthlyFromAnnual()
## Извлечение с низким риском
- PlanLookup
- CalcCore
## Пока не трогаем
- refund-adjustment
- cross-module billing sync
- формат отчётного API
## Первый slice
1. вынести CalcCore
2. спрятать SQL за PlanLookup
3. прогнать characterization + golden master
Хорошая заметка такого типа делает важную вещь: превращает декомпозицию из абстрактного разговора про архитектуру в конкретный, проверяемый план. У вас появляется не просто мысль «тут бы разрезать», а карта местности: что смешано, где возможен seam, что безопасно вынести, а что пока сознательно остаётся на месте. Когда такие границы становятся явными, следующий шаг модернизации уже воспринимается не как хирургия в темноте, а как аккуратное движение по размеченной тропе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ