Монорепозитории и DX
К пятому микросервису в отдельных репозиториях ты начинаешь замечать закономерность: фикс типа User требует пулл-реквестов в четыре репозитория, четырех релизов библиотеки и трёх дней ожидания. Каждый сервис живёт в своём мире с собственным package.json, своей версией TypeScript, своим ESLint-конфигом, который «мы чуть правили под себя». Ревьюер спрашивает «а почему у нас в двух API разные форматы дат?» — ответа нет, потому что каждый форматировал как привык. Монорепозиторий (monorepo) — это ответ на эту боль: один репозиторий, много пакетов, общие конфиги и инструменты, которые видят всё дерево зависимостей целиком.
Но монорепа — не бесплатный ланч. Она переносит проблемы интеграции из релизного процесса в повседневную разработку: CI, который запускает 40 минут тестов ради правки README; git-лог, где найти историю конкретного сервиса — отдельный квест; инструменты, которые без настройки работают «по одному пакету» и ломаются в монорепе. Краткая версия учебника упоминала монорепы вскользь. Здесь разберём механику: как устроены workspaces, как pnpm резолвит зависимости через симлинки, что такое affected-вычисления и remote cache, и как собрать DX, при котором разработчик не замечает, что работает в монорепе.
Граница главы: оркестрация фронтенда и бэкенда в одном репозитории — это инфраструктурная тема. Как эти пакеты потом деплоятся по отдельности, рассмотрим в разделе про IaC и деплой, а межсервисное взаимодействие — в главе про outbox и messaging.
Когда монорепа оправдана, а когда вредна
Заголовок раздела «Когда монорепа оправдана, а когда вредна»Монорепозиторий — это стратегическое решение об организации кода, а не инструмент. Начни с критериев.
Монорепа оправдана, когда:
- Несколько пакетов развиваются в связке. Фронтенд + API + общие типы. Библиотека и её потребители внутри одной команды. Изменение в одном месте неизбежно тянет изменения в других — значит, атомарный коммит во все пакеты сразу убирает целый класс рассинхронов.
- Нужны общие артефакты. Типы API-контрактов, дизайн-токены, eslint/tsconfig-конфиги. В polyrepo они публикуются как npm-пакеты, и каждое обновление — это релиз; в монорепе — это обычный коммит.
- Команда одна и релизный цикл общий. Пять человек, деплой по пятницам — монорепа даст скорость без издержек.
Монорепа вредна, когда:
- Команды независимы и релизятся по-разному. Команда A релизит каждый день, команда B — раз в квартал. В монорепе быстрые получат тормоза медленных: CI, владение файлами, конфликты в общих конфигах. Для них polyrepo с публикуемыми пакетами — честнее.
- Репозиторий превращается в свалку. Критерий простой: если пакеты не имеют явных границ и импортируют друг друга хаотично, монорепа ускорит расползание спагетти, а не остановит его. Сначала границы, потом — масштаб.
- Нет ресурсов на инфраструктуру. Affected-тесты, кэши, фильтры — это неделя работы. Без них монорепа на 20+ пакетов замедлит всех.
pnpm workspaces: механика под капотом
Заголовок раздела «pnpm workspaces: механика под капотом»Workspaces — нативная механика Node.js (поддерживается npm, yarn, pnpm): корневой package.json объявляет поле workspaces, и менеджер пакетов понимает, что в дереве несколько пакетов, связанных между собой.
Структура pet-проекта после переезда:
my-app/├── package.json # корень: приватный, общие скрипты и devDeps├── pnpm-workspace.yaml # список пакетов├── apps/│ ├── web/ # фронтенд (Vite + React)│ └── api/ # Node.js API└── packages/ ├── types/ # общие типы API-контрактов └── config/ # общие eslint/tsconfigpackages: - "apps/*" - "packages/*"// корневой package.json — всегда "private": true,// иначе корень случайно опубликуют в npm{ "name": "my-app", "private": true, "scripts": { "dev": "pnpm -r run dev", "build": "pnpm -r run build", "test": "pnpm -r run test" }}Теперь про механику, которая отличает pnpm от npm/yarn. Классический npm hoisting вытаскивает все зависимости всех пакетов в общий корневой node_modules (flat). Это удобно, но порождает «фантомные зависимости»: пакет apps/api может импортировать lodash, никогда не объявляя его в своём package.json, — потому что lodash случайно оказался в корневом node_modules. Всё работает локально, а падает при публикации пакета отдельно. Классика багов «на CI не собирается».
pnpm делает иначе:
- Глобальный content-addressable store. Каждая версия каждого пакета скачивается один раз на машину (
~/.local/share/pnpm/store), а не в каждый проект. Это и скорость установки, и экономия диска: 100 проектов сtypescript@5.6хранят его один раз — жёсткими ссылками. - В каждом пакете — свой
node_modulesс симлинками.apps/api/node_modules/lodash— симлинк в корневой.pnpm/lodash@4.17.21/node_modules/lodash. Пакет видит только то, что объявил. Фантомный импорт — сразу ошибкаCannot find moduleещё на этапе разработки. - Внутренние пакеты — тоже симлинки.
packages/typesподключается кapps/apiкак симлинк, и изменения в нём видны мгновенно, безnpm linkи пересборок.
# pnpm резолвит workspace-пакеты по имени и версииpnpm --filter api add @my-app/types
# --filter — главный инструмент локальной разработки:pnpm --filter api test # тесты только APIpnpm --filter web... build # web и ВСЕ его зависимости внутри workspacepnpm --filter "...types" test # типы и все, кто их используетДва фильтра, которые нужно знать наизусть: pkg... (пакет + его зависимости — нужно для сборки) и ...pkg (пакет + его потребители — нужно для проверки, кого сломает изменение). Многоточие после или до имени — это направление по графу.
Внутри package.json зависимость от соседнего пакета пишется через workspace:-протокол:
{ "name": "@my-app/api", "dependencies": { "@my-app/types": "workspace:*" }}workspace:* означает «текущая версия из этого же workspace». При публикации пакетов в npm pnpm подставит реальную версию вместо протокола. Звёздочка — нормальный выбор для приложений, которые в npm не публикуются.
Границы пакетов: правила игры
Заголовок раздела «Границы пакетов: правила игры»Монорепа без границ — это один большой пакет с лишними package.json. Границы держатся на трёх китах:
- Каждый пакет объявляет только то, что использует. pnpm с его строгим
node_modulesделает это самодокументируемым: импорт незадекларированной зависимости просто не резолвится. exportsвместо глубоких импортов. Как и для публичных пакетов (см. главу про модули): внутренние пути закрыты, наружу смотрит только перечисленное.
// packages/types/package.json — контракт вместо структуры каталогов{ "name": "@my-app/types", "exports": { ".": "./src/index.ts", "./api": "./src/api/index.ts" }}- Запрет cross-imports мимо публичного API. Пакет
apps/webне должен импортироватьapps/api/src/internal/db.ts— только то, чтоapps/apiэкспортирует, если вообще экспортирует. Приложения (apps/*) обычно никому не экспортируют ничего, толькоpackages/*— библиотеки с публичным API.
Нарушения границ ловятся линтерами (eslint-plugin-boundaries, Nx module boundaries) или архитектурными тестами. Но главный инструмент — дисциплина code review: вопрос «зачем web импортирует из internals API?» должен звучать каждый раз.
Turborepo и Nx: affected, pipeline, remote cache
Заголовок раздела «Turborepo и Nx: affected, pipeline, remote cache»Когда пакетов много, вопрос «что нужно пересобрать и протестировать после этого коммита?» решается не перебором, а графом. У pnpm он уже есть — из workspace:-зависимостей. Turborepo и Nx строят на нём task runner.
Ключевые концепции Turborepo (модель Nx аналогична, термины чуть другие):
Pipeline — декларативное описание задач и их зависимостей в turbo.json:
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["^build"], "inputs": ["src/**", "test/**", "tsconfig.json"] }, "lint": {} }}"dependsOn": ["^build"] читается как «перед сборкой этого пакета собери все пакеты, от которых он зависит». Крышка ^ — это навигация по графу вверх. Без неё задача запускается только в самом пакете.
Affected — прогон только того, что затронул коммит. turbo run test --filter=...[HEAD^] вычисляет множество изменённых файлов, маппит их на пакеты и запускает тесты в этих пакетах плюс во всех их потребителях (тесты web имеет смысл гонять, если изменились типы, от которых он зависит). На CI это превращает 40 минут проверок в 4–6.
Remote cache — самая ценная фича. Перед запуском задачи turbo вычисляет хэш от входов: содержимое inputs (исходники, lockfile, конфиги), хэши зависимостей, версия Node, переменные окружения из globalEnv. Если хэш уже есть в кэше — задача не выполняется вообще: забираются готовые outputs (dist/**). Команда npx turbo build на свежем клоне ноутбука коллеги, который уже собирал проект, занимает секунды.
Единые версии зависимостей и политики
Заголовок раздела «Единые версии зависимостей и политики»Пятая копия TypeScript 5.3–5.7 в одном репозитории — источник «у меня не воспроизводится». Политика версий в монорепе держится на трёх инструментах:
.npmrcсsave-exact=true— зависимости пишутся без^, диапазоны исключены. Одна версия — один lockfile.pnpm.overridesв корневомpackage.json— принудительное выравнивание транзитивных зависимостей (классика: все пакеты на одной версииreact, даже если какой-то транзитивный тянет старую).- syncpack — линтер согласованности версий: падает CI, если две директории объявили разные версии одной зависимости.
// корневой package.json{ "pnpm": { "overrides": { "react": "18.3.1", "react-dom": "18.3.1" } }}Дубли пакетов в монорепе — не косметика: две копии React в бандле — это баг хуков в рантайме; две версии zod — это несовместимые схемы на границе пакетов.
Локальная разработка: DX
Заголовок раздела «Локальная разработка: DX»Developer Experience — это секунды, которые разработчик ждёт между сохранением файла и пониманием, всё ли ок. Минимальный набор:
- Одна команда на всё.
pnpm devподнимает API и web параллельно (pnpm -r run dev). Запускать три терминала — антипаттерн. - Watch-режимы вместо ручных пересборок. Общие пакеты (
packages/types) для потребителей собираются черезtsc --watchили отдаются как исходники с--moduleResolution bundler. Если правка типов требуетpnpm buildруками — DX сломан. - Фильтры как привычка.
pnpm --filter api testвместо полного прогона. Работаешь над пакетом — гоняй пакет; прогон всего — за CI. - Типы как единственный источник контракта. Фронтенд импортирует типы ответов из
@my-app/types, а не дублирует интерфейсы. Поменялся API — TypeScript подсветил все места на фронте в момент сохранения.
CI для монорепы: path filters, affected, кэши
Заголовок раздела «CI для монорепы: path filters, affected, кэши»Полный прогон всего репозитория на каждый пуш — первое, что убивает монорепу. Схема для GitHub Actions:
name: cion: [push]
jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # affected-вычислениям нужна история git
- uses: pnpm/action-setup@v4 - uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm
- run: pnpm install --frozen-lockfile
# affected-прогон: только затронутые коммитом пакеты + их потребители - run: npx turbo run lint test build --filter=...[origin/main]
# remote cache turborepo делает повторные прогоны почти мгновеннымиТри опоры этой схемы: --frozen-lockfile ловит рассинхрон package.json и lockfile сразу; --filter=...[origin/main] заменяет ручные path filters — граф уже знает, какие пакеты зависят от изменённых файлов, списки путей в yaml дублировать не нужно; кэш pnpm + remote cache turbo убирают повторную работу между ранами и между разработчиками.
Публикация пакетов и changesets
Заголовок раздела «Публикация пакетов и changesets»Если пакеты из packages/* уходят наружу (в npm или в приватный registry), нужен релизный процесс, который: вычисляет, какие пакеты изменились; поднимает им версии по semver; пишет changelog; публикует в правильном порядке по графу зависимостей. Changesets решает это через файлики-маркеры: при изменении пакета разработчик оставляет .changeset/great-dogs-run.md с типом бампа (patch/minor/major) и описанием. По накоплении маркеров команда changeset version поднимает версии, обновляет зависимости workspace:* между пакетами и генерирует CHANGELOG. Для приложений (apps/*), которые не публикуются, всё это не нужно — достаточно обычных коммитов.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Монорепа как повод слить всё в кучу. Без
exports, без запрета на cross-imports и с зависимостями «где привык» получается один пакет с десятьюpackage.json. Хуже, чем polyrepo: тормоза монорепы плюс хаос монолита. - Фантомные зависимости на npm. Переезд с npm на pnpm часто «ломает» сборку: раньше пакет импортировал то, что не объявлял, — npm hoisting это прощал. Это не поломка pnpm, это вскрытый баг. Лечится честным объявлением зависимостей.
- Неактуальный lockfile на CI.
pnpm installбез--frozen-lockfileмолча подтягивает свежие версии, и «зелёный» CI не соответствует тому, что разработчики ставят локально. Всегда--frozen-lockfileв пайплайне. - CI без affected-фильтра. Каждый пуш гоняет 40 минут тестов, разработчики начинают пушить реже и реже — обходными путями. Affected-прогон — не оптимизация, а условие жизни монорепы.
- Недетерминированные билды с remote cache. Запись даты сборки или версии из незадекларированной env-переменной в артефакт делает кэш ядовитым: половина разработчиков получает «свежую» сборку, половина — недельной давности.
- Дубли критичных зависимостей. Две версии React или zod в одном бандле — трудноотлаживаемые баги в рантайме. Ловится
pnpm why reactи жёсткимoverrides. - Ручные версии вместо changesets. «Поднимем версию при публикации руками» работает для двух пакетов и ломается на пяти: забыли бамп потребителя, changelog не ведётся, порядок публикации нарушен.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем монорепа отличается от polyrepo и когда какой подход выбрать? Монорепа — один репозиторий со связанными пакетами и общими артефактами; polyrepo — изолированные репозитории с публикацией пакетов. Монорепа выигрывает при связной разработке и общей команде; polyrepo — при независимых командах и релизных циклах.
- Как pnpm резолвит зависимости в workspace и чем это лучше npm? Через симлинки в content-addressable store: каждый пакет видит только объявленные зависимости, внутренние пакеты линкуются автоматически. npm hoisting допускает фантомные зависимости — импорт незадекларированных пакетов.
- Что такое
workspace:*и что происходит при публикации? Протокол ссылки на соседний пакет монорепы; при публикации pnpm/changesets подставляет реальную версию вместо протокола. - Как работает affected-прогон в Turborepo/Nx? По графу зависимостей из
workspace:-ссылок вычисляются изменённые пакеты; задачи запускаются в них и во всех их потребителях. Фильтр--filter=...[main]на CI заменяет ручные path filters. - Что входит в хэш задачи для remote cache? Содержимое входных файлов (inputs), хэши зависимостей, версия инструментария, заявленные env-переменные. Совпал хэш — задача не выполняется, берутся закэшированные outputs.
- Как в монорепе держать единые версии зависимостей?
save-exactв.npmrc,pnpm.overridesдля транзитивных зависимостей, syncpack как линтер согласованности на CI. - Что такое changesets и зачем он нужен? Маркерные файлы с типом бампа при каждом изменении; по накоплении — автоматический бамп версий по semver, обновление межпакетных зависимостей и генерация changelog в правильном порядке по графу.
Практика
Заголовок раздела «Практика»Берём pet-проект: фронтенд (web) и API (api) в одном репозитории или в двух — и превращаем в pnpm workspace.
- Скелет workspace. Создай структуру
apps/web,apps/api,packages/types,packages/config; напишиpnpm-workspace.yaml, корневойpackage.jsonс"private": true. Перенеси код приложений, установи зависимости черезpnpm install --frozen-lockfile. Критерий:pnpm -r run devподнимает оба приложения одной командой. - Shared types. Вынеси интерфейсы API-ответов в
packages/types, подключи к обоим приложениям черезworkspace:*. Удали дублирующие интерфейсы на фронте. Критерий: изменение типа вpackages/typesподсвечивается TypeScript’ом в обоих приложениях без ручной сборки. - Shared config. Вынеси
tsconfig.jsonи ESLint-конфиг вpackages/config, унаследуй конфиги приложений через"extends": "@my-app/config/tsconfig.json". Критерий: правило линтера меняется в одном месте и применяется ко всем пакетам. - Turborepo. Инициализируй
turbo.jsonс задачамиbuild,test,lint; подключи remote cache (бесплатный тариф Vercel подходит для учебных целей). Критерий: второй подрядnpx turbo run buildвыполняется за секунды и пишетcache hit. - Affected CI. Добавь GitHub Actions workflow с
turbo run lint test build --filter=...[origin/main]иcache: pnpm. Критерий: пуш, меняющий только README фронта, не запускает тесты API. - Ловушка фантомной зависимости. Намеренно импортируй в
apps/apiпакет, который не объявлен в егоpackage.json. Зафиксируй поведение на npm (работает) и на pnpm (падает). Объяви зависимость явно — баг исчез. Вынеси вывод в README репозитория.
Что почитать
Заголовок раздела «Что почитать»- pnpm Workspaces — официальная документация: workspace-протокол, фильтры, конфигурация.
- Turborepo: Core Concepts — pipeline, task graph, hashing и remote cache из первых рук.
- Nx: Mental Model — как Nx строит граф проекта и вычисляет affected.
- Turborepo: Filtering Packages — синтаксис фильтров
--filterпо графу зависимостей. - Changesets — версионирование и публикация пакетов монорепы.
- npm: Workspaces — как workspaces поддержаны в npm.