Перейти к содержимому

Монорепозитории и 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+ пакетов замедлит всех.

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/tsconfig
pnpm-workspace.yaml
packages:
- "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 делает иначе:

  1. Глобальный content-addressable store. Каждая версия каждого пакета скачивается один раз на машину (~/.local/share/pnpm/store), а не в каждый проект. Это и скорость установки, и экономия диска: 100 проектов с typescript@5.6 хранят его один раз — жёсткими ссылками.
  2. В каждом пакете — свой node_modules с симлинками. apps/api/node_modules/lodash — симлинк в корневой .pnpm/lodash@4.17.21/node_modules/lodash. Пакет видит только то, что объявил. Фантомный импорт — сразу ошибка Cannot find module ещё на этапе разработки.
  3. Внутренние пакеты — тоже симлинки. packages/types подключается к apps/api как симлинк, и изменения в нём видны мгновенно, без npm link и пересборок.
Окно терминала
# pnpm резолвит workspace-пакеты по имени и версии
pnpm --filter api add @my-app/types
# --filter — главный инструмент локальной разработки:
pnpm --filter api test # тесты только API
pnpm --filter web... build # web и ВСЕ его зависимости внутри workspace
pnpm --filter "...types" test # типы и все, кто их использует

Два фильтра, которые нужно знать наизусть: pkg... (пакет + его зависимости — нужно для сборки) и ...pkg (пакет + его потребители — нужно для проверки, кого сломает изменение). Многоточие после или до имени — это направление по графу.

Внутри package.json зависимость от соседнего пакета пишется через workspace:-протокол:

{
"name": "@my-app/api",
"dependencies": {
"@my-app/types": "workspace:*"
}
}

workspace:* означает «текущая версия из этого же workspace». При публикации пакетов в npm pnpm подставит реальную версию вместо протокола. Звёздочка — нормальный выбор для приложений, которые в npm не публикуются.

Монорепа без границ — это один большой пакет с лишними package.json. Границы держатся на трёх китах:

  1. Каждый пакет объявляет только то, что использует. pnpm с его строгим node_modules делает это самодокументируемым: импорт незадекларированной зависимости просто не резолвится.
  2. exports вместо глубоких импортов. Как и для публичных пакетов (см. главу про модули): внутренние пути закрыты, наружу смотрит только перечисленное.
// packages/types/package.json — контракт вместо структуры каталогов
{
"name": "@my-app/types",
"exports": {
".": "./src/index.ts",
"./api": "./src/api/index.ts"
}
}
  1. Запрет 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?» должен звучать каждый раз.

Когда пакетов много, вопрос «что нужно пересобрать и протестировать после этого коммита?» решается не перебором, а графом. У 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 — это несовместимые схемы на границе пакетов.

Developer Experience — это секунды, которые разработчик ждёт между сохранением файла и пониманием, всё ли ок. Минимальный набор:

  1. Одна команда на всё. pnpm dev поднимает API и web параллельно (pnpm -r run dev). Запускать три терминала — антипаттерн.
  2. Watch-режимы вместо ручных пересборок. Общие пакеты (packages/types) для потребителей собираются через tsc --watch или отдаются как исходники с --moduleResolution bundler. Если правка типов требует pnpm build руками — DX сломан.
  3. Фильтры как привычка. pnpm --filter api test вместо полного прогона. Работаешь над пакетом — гоняй пакет; прогон всего — за CI.
  4. Типы как единственный источник контракта. Фронтенд импортирует типы ответов из @my-app/types, а не дублирует интерфейсы. Поменялся API — TypeScript подсветил все места на фронте в момент сохранения.

Полный прогон всего репозитория на каждый пуш — первое, что убивает монорепу. Схема для GitHub Actions:

.github/workflows/ci.yml
name: ci
on: [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 убирают повторную работу между ранами и между разработчиками.

Если пакеты из packages/* уходят наружу (в npm или в приватный registry), нужен релизный процесс, который: вычисляет, какие пакеты изменились; поднимает им версии по semver; пишет changelog; публикует в правильном порядке по графу зависимостей. Changesets решает это через файлики-маркеры: при изменении пакета разработчик оставляет .changeset/great-dogs-run.md с типом бампа (patch/minor/major) и описанием. По накоплении маркеров команда changeset version поднимает версии, обновляет зависимости workspace:* между пакетами и генерирует CHANGELOG. Для приложений (apps/*), которые не публикуются, всё это не нужно — достаточно обычных коммитов.

  1. Монорепа как повод слить всё в кучу. Без exports, без запрета на cross-imports и с зависимостями «где привык» получается один пакет с десятью package.json. Хуже, чем polyrepo: тормоза монорепы плюс хаос монолита.
  2. Фантомные зависимости на npm. Переезд с npm на pnpm часто «ломает» сборку: раньше пакет импортировал то, что не объявлял, — npm hoisting это прощал. Это не поломка pnpm, это вскрытый баг. Лечится честным объявлением зависимостей.
  3. Неактуальный lockfile на CI. pnpm install без --frozen-lockfile молча подтягивает свежие версии, и «зелёный» CI не соответствует тому, что разработчики ставят локально. Всегда --frozen-lockfile в пайплайне.
  4. CI без affected-фильтра. Каждый пуш гоняет 40 минут тестов, разработчики начинают пушить реже и реже — обходными путями. Affected-прогон — не оптимизация, а условие жизни монорепы.
  5. Недетерминированные билды с remote cache. Запись даты сборки или версии из незадекларированной env-переменной в артефакт делает кэш ядовитым: половина разработчиков получает «свежую» сборку, половина — недельной давности.
  6. Дубли критичных зависимостей. Две версии React или zod в одном бандле — трудноотлаживаемые баги в рантайме. Ловится pnpm why react и жёстким overrides.
  7. Ручные версии вместо changesets. «Поднимем версию при публикации руками» работает для двух пакетов и ломается на пяти: забыли бамп потребителя, changelog не ведётся, порядок публикации нарушен.
  1. Чем монорепа отличается от polyrepo и когда какой подход выбрать? Монорепа — один репозиторий со связанными пакетами и общими артефактами; polyrepo — изолированные репозитории с публикацией пакетов. Монорепа выигрывает при связной разработке и общей команде; polyrepo — при независимых командах и релизных циклах.
  2. Как pnpm резолвит зависимости в workspace и чем это лучше npm? Через симлинки в content-addressable store: каждый пакет видит только объявленные зависимости, внутренние пакеты линкуются автоматически. npm hoisting допускает фантомные зависимости — импорт незадекларированных пакетов.
  3. Что такое workspace:* и что происходит при публикации? Протокол ссылки на соседний пакет монорепы; при публикации pnpm/changesets подставляет реальную версию вместо протокола.
  4. Как работает affected-прогон в Turborepo/Nx? По графу зависимостей из workspace:-ссылок вычисляются изменённые пакеты; задачи запускаются в них и во всех их потребителях. Фильтр --filter=...[main] на CI заменяет ручные path filters.
  5. Что входит в хэш задачи для remote cache? Содержимое входных файлов (inputs), хэши зависимостей, версия инструментария, заявленные env-переменные. Совпал хэш — задача не выполняется, берутся закэшированные outputs.
  6. Как в монорепе держать единые версии зависимостей? save-exact в .npmrc, pnpm.overrides для транзитивных зависимостей, syncpack как линтер согласованности на CI.
  7. Что такое changesets и зачем он нужен? Маркерные файлы с типом бампа при каждом изменении; по накоплении — автоматический бамп версий по semver, обновление межпакетных зависимостей и генерация changelog в правильном порядке по графу.

Берём pet-проект: фронтенд (web) и API (api) в одном репозитории или в двух — и превращаем в pnpm workspace.

  1. Скелет workspace. Создай структуру apps/web, apps/api, packages/types, packages/config; напиши pnpm-workspace.yaml, корневой package.json с "private": true. Перенеси код приложений, установи зависимости через pnpm install --frozen-lockfile. Критерий: pnpm -r run dev поднимает оба приложения одной командой.
  2. Shared types. Вынеси интерфейсы API-ответов в packages/types, подключи к обоим приложениям через workspace:*. Удали дублирующие интерфейсы на фронте. Критерий: изменение типа в packages/types подсвечивается TypeScript’ом в обоих приложениях без ручной сборки.
  3. Shared config. Вынеси tsconfig.json и ESLint-конфиг в packages/config, унаследуй конфиги приложений через "extends": "@my-app/config/tsconfig.json". Критерий: правило линтера меняется в одном месте и применяется ко всем пакетам.
  4. Turborepo. Инициализируй turbo.json с задачами build, test, lint; подключи remote cache (бесплатный тариф Vercel подходит для учебных целей). Критерий: второй подряд npx turbo run build выполняется за секунды и пишет cache hit.
  5. Affected CI. Добавь GitHub Actions workflow с turbo run lint test build --filter=...[origin/main] и cache: pnpm. Критерий: пуш, меняющий только README фронта, не запускает тесты API.
  6. Ловушка фантомной зависимости. Намеренно импортируй в 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.