Из 10+ скопированных Angular-проектов в один Nx-монорепозиторий
На этой странице
Проблема: копия — это долг, который растёт
Схема знакомая. Есть приложение для первого заказчика. Приходит второй: «нам то же самое, но с нашим логотипом и парой отличий». Самое быстрое — скопировать папку. Через год копий десять, и:
- Баг живёт в N местах. Починил в одной копии — в остальных он остался. Узнаёшь об этом от пользователя.
- Копии расходятся. В одной поправили форму, в другой — таблицу. Через полгода это уже десять разных приложений с похожими названиями файлов.
- Обновление фреймворка умножается на N. Каждая копия обновляется отдельно, и почти всегда какая-то отстаёт на несколько версий.
Ключевая мысль: стоимость копии не в момент копирования, а в каждом следующем изменении. Если изменение надо сделать в N местах, его цена — N × (работа + шанс забыть).
Что такое Nx-монорепозиторий, если по-простому
Монорепозиторий — один git-репозиторий, в котором живут несколько приложений и библиотек. Nx — инструмент, который знает, кто от кого зависит, и умеет запускать сборку, тесты и линт только для того, что затронуто изменением.
Структура, к которой мы пришли:
apps/
region-a/ ← тонкая оболочка: конфиг, логотип, переводы, окружения
admin-region-a/
region-b/
...
shared/
assignments/
data-access/ ← сервисы, HTTP, сторы
feature-assignments/ ← страницы и диалоги
meetings/
users/
...
ui/ ← презентационные компоненты без данных
ui-widgets/ ← компоненты, которым нужны данныеПриложение региона почти ничего не содержит. Оно подключает общие маршруты, общие интерцепторы и общий UI. Отличия — логотип, адрес API, переводы и набор включённых модулей.
Что переносить первым
Самая частая ошибка — пытаться перенести всё сразу. Порядок, который сработал:
1. Сначала самое «листовое»: UI-кит
Кнопки, инпуты, таблицы, диалоги. У них нет зависимостей от бизнес-логики, поэтому их можно вынести, ничего не сломав. Заодно сразу видно, сколько копий одной кнопки накопилось за годы.
2. Потом data-access: сервисы и модели
HTTP-сервисы, интерфейсы ответов, сторы. Здесь копии расходятся сильнее всего: где-то поле переименовали, где-то добавили параметр. Каждое расхождение — это вопрос «это особенность заказчика или чей-то баг?». Обычно — баг.
3. Потом feature: страницы целиком
Только когда UI и данные уже общие, страница переносится почти механически.
4. Последним — приложения-оболочки
Когда все модули в библиотеках, приложение региона сводится к конфигурации.
Почему именно такой порядок: переносить можно только то, у чего зависимости уже перенесены. Это обычная топологическая сортировка графа: сначала узлы, от которых зависят другие, потом те, кто от них зависит.
Границы библиотек: зависимости только в одну сторону
Правило, ради которого всё затевалось:
apps / feature-* → ui-widgets → data-access → uiСтрелка значит «может импортировать». Обратных стрелок нет. ui — лист графа: он не знает ни о сервисах, ни о сторах.
Куда класть новый компонент — решается тремя вопросами:
| Вопрос | Ответ «да» → куда |
|---|---|
| Нужен только одному экрану? | в components/ своей feature-библиотеки |
| Нужны сервисы или сторы? | в ui-widgets |
| Чистая презентация, нужна в нескольких местах? | в ui |
Как мы обожглись: кольцо импортов
Сначала ui-widgets не было, и компоненты с данными лежали прямо в ui. Получилось кольцо: ui импортирует сервис из data-access, а data-access импортирует тип из ui.
Пока кольцо маленькое, сборщик справляется. Когда оно выросло, в рантайме появилась ошибка Angular NG0919 («Cannot read @Component metadata»): класс компонента из imports: [...] на момент чтения ещё не был инициализирован, потому что модули в кольце грузятся в непредсказуемом порядке.
Лечение — не «поменять порядок импортов», а разорвать кольцо: вынести компоненты с данными в отдельную библиотеку ui-widgets, которая стоит между ui и feature-кодом.
Проверять границы должна машина
У Nx есть ESLint-правило @nx/enforce-module-boundaries. Учебный пример, как это выглядит с тегами:
// eslint.config.mjs (учебный пример)
'@nx/enforce-module-boundaries': ['error', {
depConstraints: [
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
{ sourceTag: 'type:widget', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['*'] },
],
}],Теги ставятся в project.json каждой библиотеки: "tags": ["type:ui"].
Честно: у нас правило включено, но теги пока не размечены, и ограничения стоят в режиме «все могут всё». Направление держится на README и ревью. Это следующий шаг, и это именно тот случай, где правило дешевле ревью — подробнее в посте custom-eslint-rules.
5 минут на новый регион
Когда весь код общий, новый регион — это:
- Скопировать оболочку ближайшего региона (≈25 файлов, из них кода — пара сотен строк конфигурации).
- Поменять имя, логотип, адрес API, переводы.
- Добавить строку в список деплоя.
Никакой бизнес-логики в этих файлах нет, поэтому копировать их не страшно: они не расходятся, им нечему расходиться.
Следующий логичный шаг — Nx-генератор: команда, которая создаёт оболочку по шаблону и параметрам (nx g region --name=... --api=...). Тогда даже эти 5 минут уходят в одну команду, и ничего нельзя забыть.
Что я сделал бы иначе
- Границы и теги — с первого дня, а не после первой ошибки NG0919. Разметить пять библиотек дешевле, чем потом разбирать кольцо из тридцати.
- Расхождения копий записывать в таблицу, а не решать в голове. «Особенность заказчика» или «баг» — решение, которое потом захочется найти.
- Одна версия зависимостей на весь репозиторий — это не ограничение, а главная выгода монорепо. Об этом в посте про обновление Angular: angular-6-to-22.
Итог
Монорепозиторий не делает код лучше сам по себе. Он делает так, что изменение делается один раз, а граф зависимостей становится явным — и его можно проверять. Остальное (сборка только затронутого, единый CI) строится поверх этого. Про CI — в посте ci-nx-affected.