Прозрачное управление зависимостями в Monorepo с помощью pnpm и Turbo в 2025 году

Monorepo давно перестал быть экзотикой: многие зрелые компании переносят код десятков сервисов, библиотек и утилит под одну крышу. Это упрощает переиспользование компонентов, синхронизацию изменений, тестирование и CI. Однако масштабирование Monorepo — особенно когда число пакетов переваливает за несколько сотен — требует строгой стратегии управления зависимостями.

В 2025 году большинство современных frontend-инфраструктур переходят на связку pnpm и turborepo (сокр. Turbo). pnpm обеспечивает детерминированное, изолированное и максимально производительное управление пакетами, в то время как Turbo даёт кэшированное, параллельное выполнение задач и оптимальную инвалидацию на основе зависимости между пакетами.

Однако даже при использовании этих мощных инструментов можно легко наступить на грабли: случайное повышение зависимости в неподходящем месте, неправильная настройка workspace protocol, alias-конфликты, устаревшие линки, конфликт версий при сборке. Поговорим, как на практике выстроить монорепозиторий с разумной и предсказуемой моделью зависимостей, используя фичи pnpm ≥8.10 и Turbo ≥2.

Архитектура: Scalable Monorepo с слоями и правилами импорта

Подход с flat-партицией становится крайне проблематичным при большом количестве пакетов. Вместо этого имеет смысл стратифицировать Monorepo на понятные уровни. Минимальная базовая стратификация:

  • packages/core/** — фундаментальные утилиты, независимые от бизнес-логики
  • packages/ui/** — дизайн-система, переиспользуемые компоненты
  • packages/feature/** — независимые функции/модули (например, auth, checkout)
  • apps/** — конечные приложения (SPA, BFF, SSR, mobile bridge и т.п.)

Основное правило: зависимость разрешается только вниз по иерархии (core ← ui ← feature ← apps). Обратные ссылки запрещаются линтером на этапе precommit/husky/hint.

Это даёт стабильность: core не знает ничего об apps, ui не потянет случайно фичу, а фича не сломает общую утилиту.

pnpm workspaces: workspace protocol и точечные зависимости

pnpm использует workspace: префикс для обозначения внутренних зависимостей пакетов внутри монорепозитория. Например:

json
{
  "name": "checkout",
  "version": "1.0.0",
  "dependencies": {
    "@acme/ui": "workspace:^",
    "@acme/validators": "workspace:~"
  }
}

Важно, что workspace:* — это не просто alias. По умолчанию pnpm будет резолвить зависимости строго по версионному семантическому диапазону. Использование workspace:^ оптимально: оно указывает, что несовместимые мажорные версии недопустимы.

В 2025 году многие команды полностью отказываются от * и latest, переходя к строгому контролю:

  • Обновление общей зависимости делается вручную через pnpm -r update @acme/ui с указанием версии
  • Любое повышение зависимости фиксируется в PR с описанием мотивации

Это минимизирует ситуации снежного кома, когда одна зависимость обновляется, и три десятка пакетов собираются без тестов, но уже со сломанным API.

Автоматическая проверка несовместимых зависимостей

Добавим в pnpmfile.js хук фильтрации нежелательных зависимостей:

js
module.exports = {
  hooks: {
    readPackage(package) {
      const invalid = Object.entries(package.dependencies ?? {})
        .filter(([name, version]) => version === '*' || version.startsWith('workspace:*'));
        
      if (invalid.length > 0) {
        throw new Error(
          `Package ${package.name} has invalid dependency versions:\n` +
          invalid.map(([name, ver]) => `  - ${name}: ${ver}`).join('\n')
        );
      }

      return package;
    }
  }
};

Теперь попытка добавить workspace:* или * приведёт к ошибке при установке зависимостей.

Turbo: декларативное кеширование и валидация задач

Turbo использует граф зависимостей между пакетами и задачами (pipeline) и позволяет точно понять, какие пакеты надо пересобрать или протестировать при изменении любого файла.

Пример turbo.json:

json
{
  "pipeline": {
    "build": {
      "outputs": ["dist/**", "build/**"],
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {}
  }
}

Когда изменяется файл в packages/validators/parse.ts, Turbo пересобирает:

  • сам validators (build)
  • все зависимости, которые зависят от него (например, checkout)
  • запускает только актуальные test, lint

Turbo работает эффективно только при корректной указанной зависимости между задачами и строгом output tracking. Лишнее wildcard-описание выходов (outputs: ["*"]) ведёт к разрушению кеша. Всегда указывайте явно артефакты задачи (например, Dist + Types или coverage).

Почему не Yarn Berry или npm Workspaces?

Yarn 4 остаётся конкурентом для pnpm, особенно с его constraints.pro и Zero-Installs. Но к 2025 году pnpm выигрывает по следующим причинам:

  • Изоляция node_modules через symlink-стратегию без .zip
  • Более быстрое выполнение install даже в проектах с 1000+ пакетами
  • Простота настройки и интеграции с Turbo (Yarn требует custom executor)
  • Прямой node_modules-layout без необходимости .yarnrc.yml и Plug'n'Play

npm workspaces в свою очередь остаются слишком примитивными, не поддерживают правила workspace semantic ranges, а глобальное управление devDependencies у них грубо говоря отсутствует.

CI: Локальный кеш + удалённый stateful execution

Turbo поддерживает удалённый кеш артефактов через Vercel Remote Caching API. К середине 2025 его начали внедрять в enterprise-командах даже без использования Vercel CI. Это позволяет кешировать билдов, тестов и линтов даже между CI-runner'ами.

Пример настройки .env:

text
TURBO_TOKEN=your-secret
TURBO_TEAM=my-team-id

Команды в CI остаются прежними: turbo run build --filter=..., но теперь сборки кэшируются на уровне артефактов, а не шагов Workflows.

Важно: используйте --force для invalidation при подозрении на расхождение кеш-состояния.

Советы и выводы

  1. Вносите версионный контроль в каждый internal пакет: local version !== "*"
  2. Принудительно отключите workspace:*
  3. Внедрите пересекающую проверку и аналитический слой: кто зависит от чего? Для этого можно использовать инструменты depcruise, madge, nx graph
  4. Настройте репо как "readable-first": шапки README.md внутри каждого пакета, чёткое описание входов-выходов и связей
  5. Никогда не делайте devDependencies глобальными — devDependencies пакета должны быть только для его задач, иначе Turbo будет неправильно инвалидацировать кэш
  6. Версионируйте общие типы (@acme/types) по SemVer и не используйте patch-version dependency если у TypeScript есть breaking-changes

Pnpm + Turbo — это не просто пакетный менеджер и таск-раннер. Это слой предсказуемости, который должен быть детерминированным от команды к команде. Чем раньше вы выстроите договорённости и правила в Monorepo, тем проще будет масштабировать архитектуру к десяткам команд и сотням пакетов без потери контроля.