Архитектура фронтенда
Границы монорепозитория: граф зависимостей решает твой счёт за CI
Правка двух строк текста на маркетинговой странице висит в CI 41 минуту. Изменение задело один компонент, но пайплайн пересобрал и протестировал все 38 проектов, потому что импорт резолвился через @acme/utils — пакет «shared utils», от которого зависит каждое приложение и каждая библиотека в репозитории. Тронешь что-то, что трогает utils, — и affected-граф становится всем репозиторием. Вердикт команды на ретро: «монорепозиторий тормозит». Тормозил не монорепозиторий. Неправильной была граница.
Граф зависимостей — это и есть продукт
Монорепозиторий — это не «весь код в одной папке». Это просто папка. Быстрым или медленным монорепозиторий делает граф зависимостей — направленный граф того, какой пакет какой импортирует. Turborepo и Nx строят этот граф, разбирая твои импорты и workspaces в package.json, и используют его для двух задач: упорядочивание (собрать библиотеку до приложения, которое её потребляет) и определение масштаба (вычислить радиус взрыва изменения).
Каждый узел — пакет или проект; каждое ребро — зависимость. Здоровый граф широкий и неглубокий: много листовых библиотек, мало кто зависит от любой одной из них. Больной граф имеет хаб — один узел с огромным fan-in, который лежит на пути между всем. Этот хаб обычно зовётся utils, common или shared, и это самая дорогая ошибка в монорепозитории, потому что алгоритм affected-графа хорош ровно настолько, насколько хорош граф, который ты ему дал.
«Affected» против собрать-всё
Наивный CI-пайплайн запускает build && test по каждому пакету в каждом PR. На 5 пакетах никто не замечает. На 40 полный прогон — это 30–45 минут, и очередь на мердж встаёт. Фикс — affected (Nx) / filtered (Turborepo): вычислить diff с базовой веткой, найти изменённые проекты, пройтись по графу и добавить каждый проект, который зависит от изменённого, и запустить задачи только для этого набора.
Цифры драматичны, когда граф чистый. Workspace на 20 пакетов обычно падает с ~14 мин (собрать всё) до 3–5 мин (только affected). Когда изменение локально для листа, affected-набор может быть одним проектом, и CI завершается меньше чем за минуту. На этом стоит вся ставка монорепозитория: большинство PR трогают маленький срез, поэтому большинство PR должны платить за маленький срез.
| Стратегия | Что бежит на PR | ~CI на 40 пакетах | Режим отказа |
|---|---|---|---|
| Собрать всё | Все проекты, всегда | 30–45 мин | Растёт с размером репо, не изменения |
| Только affected | Изменённые проекты + их зависимые | 1–5 мин (типичный PR) | Хаб-пакет делает «affected» = всё |
| Affected + remote cache | Только задачи без кэшированного вывода | секунды–2 мин на тёплом кэше | Слишком широкий ключ → ложные промахи |
Remote caching: второй множитель
Affected-определение говорит «не запускай нетронутые проекты». Remote caching говорит «даже не запускай тронутые проекты, если кто-то уже произвёл ровно этот вывод». Turborepo и Nx хэшируют каждый вход задачи — исходники, зависимости, env-переменные, конфиг задачи — и хранят вывод (артефакты сборки, результаты тестов, логи) под этим хэшем в общем кэше. Если коллега или твой прошлый прогон CI уже собрал этот хэш, задача — это попадание в кэш: вывод скачивается, а не пересчитывается, за миллисекунды.
Это и замыкает петлю между разработчиками и CI. Vercel замерил падение времени публикаций Next.js на 80% после того, как сборки SWC стали шариться через remote cache. Mercari отчитался примерно о 50%-м сокращении длительности Turbo-задач и 30%-м сокращении общей длительности джоба после подключения remote cache Turborepo к GitHub Actions. Частая цифра тёплого кэша — CI с ~6 минут до ~45 секунд. Cache hit rate — это метрика, за которой следишь: в репозитории на много пакетов, где большинство PR локальны, достижимы hit rate в 80–90%, и каждый процент hit rate — это минуты CI (и деньги), которые ты не тратишь.
Почему это работает
Ключ кэша решает всё. Если забыть включить вход — скажем, общий tsconfig или env-переменную, меняющую вывод, — получишь ложное попадание: кэш отдаёт устаревший артефакт и выкатывает баг. Если включить слишком много — волатильный файл, меняющийся каждый прогон, или слишком широкий glob, — получишь ложный промах: хэш никогда не повторяется, кэш бесполезен, а за хранение ты заплатил впустую. Тюнинг cache hit rate — это в основном тюнинг того, что считается входом.
Границы: останови хаб до того, как он сформируется
Причина, по которой «shared utils» ломает affected-граф, структурная, а не баг инструмента: если 30 проектов его импортируют, то по определению изменение в нём задевает 30 проектов, поэтому кэш инвалидируется для всех них и CI пересобирает мир. Фикс — обеспечить границы модулей, чтобы граф вообще не мог выродиться в кашу хаб-и-спицы.
Nx делает это через теги и ESLint-правило @nx/enforce-module-boundaries. Ты тегируешь каждый проект (type:feature, type:ui, scope:checkout) и объявляешь depConstraints: feature может зависеть от ui, ui может зависеть только от util, ничто не может зависеть от feature. Импорты, нарушающие правило, валят линт — и то же правило ловит циклические зависимости («Circular dependency between A and B detected»), это другой способ превратить граф в спагетти. Turborepo опирается на границы пакетов и exports в package.json, чтобы публичная поверхность пакета была явной, и потребители не могли лезть в его внутренности. Ход сеньора — разбить god-пакет: вместо одного utils, который импортируют все, выпустить @acme/format-date, @acme/http, @acme/result, у каждого узкий fan-in, чтобы изменение в форматировании даты инвалидировало только горстку того, что реально форматирует даты.
CI добрался до 40+ мин: каждый PR пересобирает репо, потому что все импортируют один @acme/utils. Выбери фикс, который бьёт в причину.
Монорепозиторий против полирепозитория, честно
Суперсила монорепозитория — атомарное изменение: переименуй функцию в общей библиотеке и обнови всех вызывающих в одном PR, с одним прогоном CI, доказывающим, что всё ещё компилируется. Никаких бампов версий, реестра, «выкати либу, подожди, бампни потребителей, выкати их». Цена реальна: тебе нужны инструменты, знающие граф (Nx/Turborepo/Bazel), remote caching и обеспеченные границы, иначе репо рушится под собственным CI. Полирепозиторий переворачивает это: каждый репо тривиально быстрый и независимый, но общий код едет через реестр, и ломающее изменение в общем пакете означает синхронные бампы, дрейф версий и раскопки «какой сервис на старой версии». Честная формулировка: монорепозиторий меняет более высокую сложность инструментов на меньшую цену координации; полирепозиторий меняет меньшую сложность инструментов на более высокую цену координации. Выбирай монорепозиторий, когда пакеты много шарят и меняются вместе; выбирай полирепозиторий, когда команды по-настоящему независимы и общаются только по API.
В монорепозитории на 40 пакетов что на самом деле вычисляет «affected» (Nx) / «filtered» (Turborepo) для PR?
Cache hit rate высокий, но один PR отдал устаревшую сборку, которая выкатила баг. Самая вероятная причина?
Расставь, как инструмент, знающий граф, решает, что запускать для PR:
- 1 Построить граф зависимостей из импортов + workspace-файлов package.json
- 2 Сделать diff PR с базовой веткой, чтобы найти напрямую изменённые проекты
- 3 Пройти по графу и добавить каждый проект, зависящий от изменённого (affected-набор)
- 4 Захэшировать входы каждой affected-задачи и проверить remote cache по этому хэшу
- 5 Запустить только задачи-промахи; скачать выводы для задач-попаданий
- 01Объясни, почему один пакет «shared utils» может разогнать CI монорепозитория с 4 минут до 40, и что с этим делать.
- 02В чём разница между affected-определением и remote caching, и почему нужны оба?
Монорепозиторий быстр или медленен из-за одной вещи — графа зависимостей. Инструменты, знающие граф (Nx, Turborepo), разбирают импорты в направленный граф и используют его, чтобы вычислить affected-набор — изменённые проекты плюс всё, что от них зависит, — поэтому большинство PR собирают срез, а не мир, часто роняя репо на 40 пакетов с 30–45 минут до 1–5. Remote caching умножает это, хэшируя входы каждой задачи и скачивая сохранённые выводы при попадании, — так команды отчитываются о 50–80% сокращении CI и прогонах на тёплом кэше за секунды; подвох — в ключе кэша, где пропущенный вход даёт опасное ложное попадание, а слишком широкий — бесполезные ложные промахи. Ничто из этого не переживёт плохого графа: один хаб «shared utils» делает affected-набор всем репозиторием и промахивает кэш для каждого зависимого, поэтому дисциплина, которая реально держит монорепозиторий быстрым, — это обеспеченные границы модулей: теги Nx, правило enforce-module-boundaries и явные exports пакетов, которые разбивают god-пакеты и запрещают циклические и кросс-доменные импорты. Против полирепозитория сделка честна: монорепозиторий покупает атомарные кросс-пакетные изменения ценой инструментов, знающих граф, и дисциплины границ; полирепозиторий покупает тривиальную скорость на репо ценой версионирования через реестр и кросс-репозиторной координации.