Перенести бэкенд NestJS в монорепозиторий pnpm и Turborepo
Распространяйте контракты TypeScript между API с помощью рабочих пространств pnpm, графов задач Turborepo, Dockerfiles, оптимизированных для кэширования, и каталогов pnpm — без превращения обычных файлов в беспорядочную папку.
Источники: Why I Chose a Monorepo for My Backend (And What Actually Happened)
Примерно через девять месяцев развития бэкенда на NestJS оказалось недостаточно одного API, одного файла Dockerfile и одной системы обработки задач. Появился второй API, затем третий, плюс интерфейс администрирования на React. Оставался открытым вопрос: как разделить ответственности, не теряя общих типовых конструкций на TypeScript.
Был выбран подход с использованием рабочей среды pnpm в формате monorepo, организованной с помощью Turborepo: что сработало, что принесло трудности и что изменится при повторной попытке — только инженерные решения, без упоминания бренда продукта.
Проблема, которая всё начала
Первый сервис изначально представлял собой одно приложение NestJS, обрабатывающее аутентификацию, управление контентом, загрузку данных на Cloudflare R2 и поиск с использованием Meilisearch. Такая структура сохранялась до тех пор, пока поиск не потребовал собственного цикла развертывания и границ обработки ошибок, при этом он по-прежнему использовал одни и те же перечисления и вспомогательные функции, что и основной API.
Появились два варианта: скопировать общие компоненты в другой репозиторий и вручную поддерживать синхронизацию, или делиться кодом непосредственно внутри репозитория, позволяя каждому приложению самостоятельно компилироваться и выпускаться.
Дублирование перечислений между репозиториями кажется безвредным, пока одна сторона не обновляет их, а другая — нет. Одного незначительного несоответствия в перечислении типов контента было достаточно, чтобы отклонить идею совместного использования кода в нескольких репозиториях на таком масштабе.
Почему pnpm, почему Turborepo
Были сравнены три подхода: Nx, рабочие пространства Yarn с Lerna и рабочие пространства pnpm с Turborepo.
Nx был мощным инструментом, но казался вторым фреймворком: требовал длительного обучения и генерировал слишком большое количество структурных элементов по сравнению с четырьмя приложениями и несколькими общими пакетами. Визуализаторы графов и механизмы обработки затронутых команд были избыточны для решения поставленной задачи.
Рабочие пространства Yarn вместе с Lerna казались знакомыми в версии Yarn 1.x, однако ситуация с обслуживанием Lerna вызывала опасения, а слабая система решения проблем в Yarn уже приводила к появлению фантомных зависимостей — импортов, которые работали только потому, что локально происходило наличие транзитивного пакета, но затем отказывали в производственной среде.
pnpm превзошел конкурентов благодаря строгой изоляции: каждый импорт должен быть указан в package.json. Во время миграции он сразу же отмечал @nestjs/mapped-types как используемый, но не объявленный, чего Yarn позволял из-за своей структуры графа зависимостей. В сочетании с возможностью связывания рабочих пространств без неудобных ручных настроек именно эта строгость стала решающим фактором.
Turborepo координирует процессы сборки на более высоком уровне. Указание "dependsOn": ["^build"] обеспечивает компиляцию общих пакетов до того, как их используют другие проекты, хранит результаты в кэше и параллелизует независимые задачи. Большая часть правил находится в одном файле turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
Токен ^build означает «сначала собрать зависимости». Для остального Turborepo анализирует граф файлов package.json.
Такая минимальная конфигурация преднамеренна. Цель миграции заключалась в более быстрой обратной связи для небольшого графа сервисов, а не в создании полного набора инструментов для команды, отвечающей за платформу. Использование декларативного формата оркестрации снижало затраты на адаптацию при появлении третьего API: новые приложения копировали имена задач из файла turbo.json и использовали те же ключи кэша.
Что я на самом деле делясь
В начале существовали отдельные пакеты для типов и утилит. В течение нескольких месяцев они объединились в один пакет @myapp/common. На бумаге такое разделение казалось логичным, но на практике оказалось неудобным: каждому перечислению требовалась своя папка, а большинство файлов всё равно импортировали оба пакета.
Теперь структура совместного пакета выглядит так:
packages/common/src/
├── enums/
│ ├── subdirectory-path.enum.ts # R2 bucket paths
│ ├── content.enum.ts # Content field types
│ └── index.ts
├── types/
│ ├── pagination.types.ts
│ └── index.ts
└── index.ts
Сознательно делайте его небольшим. Делитесь только типами данных, используемыми в нескольких сервисах, перечислениями, к которым обращаются несколько приложений, и одним вспомогательным объектом UUID. Все остальное должно оставаться локальным для каждого приложения. Пакет представляет собой контракт, а не хранилище мусора.
Позже нашли свое применение еще три общих пакета:
Пакет config загружает секреты. Обе API запрашивают данные из менеджера секретов при старте, поэтому функция loadSecrets() реализуется только один раз и выполняется из каждого файла main.ts до начала других операций запуска.
Пакет telemetry обеспечивает внедрение механизмов отслеживания OpenTelemetry и структурированной записи логов в формате JSON, включая фильтрацию ненужных записей о проверках работоспособности. Благодаря такому подходу к наблюдаемости общий код помогает избежать различий между сервисами.
Пакет vitest-config предоставляет общий фабрикат для Vitest, что позволяет сохранять единообразие настройок тестов без необходимости копирования конфигураций.
Проблема Dockerfile
Сборка проектов в формате Monorepo с использованием Docker — это аспект, который часто упускается из учебных материалов. Контекст сборки — это корневая директория репозитория. У каждого приложения есть свой собственный Dockerfile, но ему необходимо иметь доступ к общим пакетам. Основным инструментом здесь является оптимизированное по порядку выполнения команды COPY для улучшения работы кэша.
Устоявшийся подход:
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.12.4 --activate
WORKDIR /app
# 1. Copy workspace manifests first (cache the install layer)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc turbo.json ./
COPY packages/common/package.json ./packages/common/
COPY packages/config/package.json ./packages/config/
COPY packages/telemetry/package.json ./packages/telemetry/
COPY apps/core/package.json ./apps/core/
RUN pnpm install --frozen-lockfile
# 2. Copy source and build in dependency order
COPY packages/common ./packages/common
COPY packages/config ./packages/config
COPY packages/telemetry ./packages/telemetry
COPY apps/core ./apps/core
RUN pnpm --filter @myapp/common build
RUN pnpm --filter @myapp/config build
RUN pnpm --filter @myapp/telemetry build
RUN pnpm --filter @myapp/core build:prod
# 3. Deploy step creates a self-contained directory
RUN pnpm deploy --filter @myapp/core --prod --legacy /deploy/core
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /deploy/core ./
CMD ["node", "dist/main"]
Сначала копируются все файлы package.json, затем выполняется установка пакетов, и только после этого копируются исходные коды. Docker затем повторно использует слой с установленными пакетами до тех пор, пока не изменятся зависимости, а не при каждой правке исходного кода.
Недостаток этого подхода: добавление нового общего пакета требует обновления строк с командой COPY во всех Dockerfile, которые им пользуются. Полезна контрольная список, поскольку отсутствие нужной строки приводит к неясным ошибкам сборки. В итоге команда pnpm deploy создает отдельную производственную структуру, в которой пакеты из рабочей среды заменяются на их реальные копии, поэтому готовый образ не требует ни pnpm, ни полной структуры Monorepo.
Управление версиями с использованием каталогов
Различия версий — это скрытая «налоговая нагрузка» монорепозиториев: NestJS 11.0 в одном приложении и 11.1 в другом приводят к тонким ошибкам в отдельных сервисах.
Каталоги pnpm определяют общие диапазоны зависимостей один раз в файле pnpm-workspace.yaml:
catalogs:
nestjs:
'@nestjs/common': ^11.1.28
'@nestjs/core': ^11.1.28
'@nestjs/platform-fastify': ^11.1.28
testing:
vitest: ^3.2.6
'@vitest/coverage-v8': ^2.1.0
Затем приложения делают ссылки на записи в каталоге:
{
"dependencies": {
"@nestjs/common": "catalog:nestjs"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}
Обновление NestJS во всех приложениях теперь требует лишь одной правки в рабочем пространстве и выполнения команды pnpm install. Каталог содержит только те зависимости, которые используются двумя или более приложениями; версия Mongoose, используемая одним сервисом, может оставаться локальной до тех пор, пока не станет общей для других сервисов.
Каталоги также влияют на процесс проверки обновлений. Обновление NestJS теперь отображается как изменения на уровне всего рабочего пространства, а не как отдельные изменения в файлах пакетов, что позволяет при проверке кода сосредоточиться на примечаниях к выпуску и изменениях, влияющих на работу приложения, а не на том, были ли все приложения обновлены одинаково.
Что я сделал неправильно
Разделение типов и утилит на отдельные компоненты было преждевременным решением с точки зрения архитектуры. Позднейшее объединение потребовало масштабных обновлений импортов, которых можно было избежать, если бы всё оставалось в одном пакете.
Система CI изначально была слишком сложной: для каждого приложения использовались отдельные рабочие процессы GitHub Actions с фильтрами путей, чтобы изменения в поисковой функции не влияли на сборку основного API. Изменения в общем пакете должны были распространяться на всех пользователей, а фильтры требовали больше времени на настройку, чем экономили. Оказалось проще использовать один рабочий процесс PR для сборки всего подряд, ускоренный кэшем Turborepo.
Что я сделал правильно
Строгая система разрешения выявила реальную незадекларированную зависимость во время миграции — именно такие проблемы обычно проявляются лишь спустя месяцы.
Количество минимальных общих пакетов оставалось на минимуме. Инфраструктура облегчает совместное использование кода; дисциплина не позволяет коду, специфичному для отдельных приложений, попадать в папку common. Несколько сотен строк настоящего общего кода уже являются преимуществом.
Болезненная настройка Dockerfile оказалась полезной: каждый сервис собирается отдельно, загружает только необходимые пакеты рабочей среды и создаёт небольшой образ для производства. К тому моменту, когда рядом с шестью общими пакетами появились третий API и панель управления администратором, добавление ещё одного сервиса в основном сводилось к повторению уже существующих шаблонов для Docker, CI и телеметрии.
Можно обойтись без монорепозитория, если планируется только один сервис без второго. Когда появляется необходимость во втором сервисе и типы уже копируются между репозиториями, переход на монорепозиторий обычно уже запоздал. Расходы на миграцию возникают сразу, а польза проявляется в первый раз, когда меняется общий enum и все потребители обновляются в рамках одного коммита.
Монорепозиторий не создаёт хорошую архитектуру. Он делает хорошие паттерны более доступными для повторения, а плохие — труднее скрыть.
С точки зрения эксплуатации самым значимым долгосрочным достижением стала предсказуемость при росте системы. Общие перечисления больше не менялись произвольно. Поля телеметрии согласовывались во всех API. Образы Docker оставались небольшими, поскольку команда pnpm deploy удаляла структуру рабочей среды. Оставшиеся проблемы — частые изменения в файлах Dockerfile и соблазн предоставлять чрезмерно много информации — удавалось контролировать с помощью чек-листов и границ пакетов, а не за счёт изменения структуры репозитория.
Если бы сегодня началась переработка системы, та же стек-технология по-прежнему оставалась бы стандартом для систем такого масштаба: pnpm для строгой установки компонентов, Turborepo для организации задач, каталоги для хранения общих версий и намеренно небольшой пакет common. Изменения касались бы использования одного общего пакета вместо двух и одного процесса CI вместо множества.