← назад к разделу

Monorepo удобен, когда Next.js, NestJS и общие контракты развиваются вместе. Изменение API проходит одним коммитом: схема, backend, клиент и тесты остаются согласованными.

Но просто сложить проекты в один репозиторий недостаточно. Нужны границы пакетов, детерминированная установка и понятный запуск задач. Их дают pnpm workspaces и Turborepo.

Структура

apps/
  web/
    package.json
    next.config.js
  api/
    package.json
    src/
packages/
  contracts/
    package.json
    src/
  config/
    package.json
package.json
pnpm-workspace.yaml
pnpm-lock.yaml
turbo.json

apps/web - Next.js, apps/api - NestJS или Fastify, packages/contracts - общие DTO и схемы, packages/config - настройки TypeScript, ESLint и тестов.

Пакет не обязан быть библиотекой для публикации в npm. Это рабочая единица со своими зависимостями и скриптами.

pnpm workspaces

pnpm-workspace.yaml перечисляет расположение проектов:

packages:
  - "apps/*"
  - "packages/*"

Корневой package.json описывает только общие инструменты:

{
  "name": "marketplace",
  "private": true,
  "packageManager": "pnpm@10.15.0",
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^6.0.0"
  }
}

Зависимости приложения объявляют в его собственном package.json. Не складывайте всё в корень: иначе границы пакетов и причины обновлений теряются.

Связь приложений через workspace

apps/api/package.json:

{
  "name": "@marketplace/api",
  "private": true,
  "scripts": {
    "build": "tsc -b",
    "test": "jest"
  },
  "dependencies": {
    "@marketplace/contracts": "workspace:*",
    "@nestjs/common": "^11.0.0"
  }
}

apps/web/package.json получает ту же версию контрактов:

{
  "name": "@marketplace/web",
  "private": true,
  "scripts": {
    "build": "next build",
    "test": "vitest run"
  },
  "dependencies": {
    "@marketplace/contracts": "workspace:*",
    "next": "^16.0.0"
  }
}

workspace:* означает локальную ссылку внутри репозитория. При публикации пакета её нужно заменить обычной semver-версией, но для внутреннего приложения это не требуется.

Общие контракты

В packages/contracts хранят типы и схемы, которые одинаково понимают frontend и backend:

import { z } from "zod";

export const createProductSchema = z.object({
  name: z.string().trim().min(1).max(300),
  price: z.number().positive(),
});

export type CreateProductInput = z.infer<typeof createProductSchema>;

export interface Product {
  id: number;
  name: string;
  price: number;
}

Backend использует схему для валидации, frontend - для формы и типов ответа. Не помещайте сюда NestJS, React или доступ к базе: пакет должен оставаться нейтральным к платформе.

Изменение контракта всё равно требует совместимости. Для публичного API сначала добавляют новое поле как необязательное, затем обновляют потребителей, и только потом убирают старое.

Задачи Turborepo

turbo.json описывает зависимости задач и их результаты:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    }
  }
}

build сначала собирает зависимости пакета. Поэтому apps/web получает актуальный contracts/dist до собственной сборки. test и lint выполняются параллельно, если граф пакетов это позволяет.

Запуск всех задач:

pnpm build
pnpm test
pnpm lint

Запуск только нужного пакета:

pnpm turbo run build --filter=@marketplace/api
pnpm turbo run test --filter=@marketplace/contracts

Фильтр по зависимостям:

pnpm turbo run build --filter=@marketplace/web...

Не запускайте npm install или pnpm install отдельно внутри каждого приложения. Установка и lockfile должны быть общими для workspace.

Фильтр изменённых проектов

В CI не всегда нужно собирать всё репозиторие. Turborepo сравнивает изменения с базовой веткой:

pnpm turbo run build test --filter='...[origin/main]'

Такой фильтр выбирает изменённые пакеты и их зависимых. Полную проверку оставляют для релизной ветки или ночного прогона, потому что незатронутый пакет может скрывать интеграционную проблему.

Для корректного сравнения CI получает историю и базовую ветку целиком. Слишком мелкий fetch-depth ломает определение изменений.

Кеширование

Turborepo кеширует результат задачи по её входам: исходникам, зависимостям, конфигурации и переменным окружения. Если входы не изменились, пакет берётся из кеша вместо повторной сборки.

Локальный кеш находится в .turbo. Remote cache позволяет использовать результаты между разработчиками и CI-агентами. Для него задайте URL, команду и токен через переменные окружения CI, не в репозитории.

Ключевые правила:

  • кеш не заменяет lockfile;
  • секреты не записывают в логи и артефакты задач;
  • build перечисляет только реальные выходные файлы;
  • тестовый PostgreSQL, Redis и другие сервисы изолированы для каждого прогона;
  • кеш не должен скрывать изменение миграций или схемы контракта.

CI

Минимальный порядок:

corepack enable
pnpm install --frozen-lockfile
pnpm turbo run lint test build --filter='...[origin/main]'

--frozen-lockfile запрещает менять зависимости молча. Если package.json и pnpm-lock.yaml разошлись, установка падает и заставляет обновить lockfile явно.

Образ для конкретного приложения собирают после проверок. В него включают production-зависимости только нужного пакета и его workspace-зависимостей, а не весь monorepo без разбора.

Типичные ошибки

  • Все зависимости объявлены в корне, поэтому приложения получают лишние пакеты и конфликтующие версии.
  • turbo run build запускается без dependsOn, и frontend собирается со старыми контрактами.
  • CI работает с неполной историей, поэтому фильтр изменений выбирает неверный набор пакетов.
  • Тесты читают production-конфигурацию и секреты из общего .env.
  • Сборочный образ содержит весь monorepo и devDependencies.

Коротко

  • pnpm workspaces управляет приложениями и пакетами через один lockfile.
  • workspace:* связывает локальные зависимости без публикации в npm.
  • Общие контракты остаются независимыми от Next.js и NestJS.
  • dependsOn строит граф задач, а outputs определяет кешируемые результаты.
  • CI использует frozen lockfile, фильтры изменений и изолированные тестовые сервисы.

Что почитать дальше