Главная / Статьи

Какое место занимает Turborepo в monorepo NestJS и когда его стоит пропустить

Как Turborepo, pnpm workspaces и NestJS делят работу в монорепозитории на TypeScript: общие пакеты, графы задач, кэширование, риски взаимосвязи компонентов и когда стоит этого избегать.

Издатель: REACTAPP.TOP15 мин чтения3349 слов

Источники: Turborepo + NestJS: The Monorepo Architecture That Makes Large TypeScript Systems Manageable

Бэкенд NestJS обычно начинается как один API с одним файлом package.json, папкой node_modules и одним потоком CI. По мере роста продукта функции аутентификации, обработки заказов, оплат, управления запасами и отправки уведомлений разделяются на отдельные сервисы, и вопрос операционной эксплуатации меняется с «как масштабировать этот сервис?» на «как управлять семью приложениями на TypeScript, не копируя одну и ту же конфигурацию, типы и потоки семь раз?». В этой статье объясняется, что вносят вклад монорепозиторий, pnpm и Turborepo, как граф задач и кэш экономят время, что можно безопасно делиться, и когда настройка стоит дороже, чем приносит пользы.

Всё время имейте в виду один ключевой принцип: Turborepo не создаёт микросервисы, не заменяет NestJS и сам по себе не делает архитектуру масштабируемой. Он решает более узкую проблему — координацию процессов сборки в различных пакетах внутри одного репозитория.

От одного сервиса к нескольким репозиториям

В репозитории постоянно развивающейся платформы может оказаться по одной папке на каждый сервис:

auth-service/
order-service/
payment-service/
inventory-service/
notification-service/
shipment-service/
api-gateway/

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

Затраты на координацию в полирепозитории

Представьте типичную систему электронной коммерции, в которой шлюз обеспечивает доступ к сервисам аутентификации, заказов и товаров; сервис заказов вызывает функции оплаты и учёта запасов, а система оплаты отправляет уведомления:

                    API Gateway
                         |
       ┌─────────────────┼─────────────────┐
       ↓                 ↓                 ↓
     Auth              Order            Product
                         |
                ┌────────┴────────┐
                ↓                 ↓
             Payment          Inventory
                |
                ↓
           Notification

Распространённым первым шагом является предоставление каждому сервису собственного репозитория — подход polyrepo:

github.com/company/auth-service
github.com/company/order-service
github.com/company/product-service
github.com/company/payment-service
github.com/company/inventory-service
github.com/company/notification-service

Это законная архитектура. Проблемы возникают, когда вы заглядываете в каждый репозиторий и обнаруживаете повторение одних и тех же структурных элементов везде:

package.json
tsconfig.json
eslint.config.js
Dockerfile
CI configuration
README.md
node_modules

И что ещё хуже — одних и тех же типов кода, необходимых нескольким сервисам одновременно:

DTOs
TypeScript types
logging utilities
configuration
authentication helpers
database utilities
Kafka/RabbitMQ abstractions
testing utilities

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

Monorepo — это стратегия; Turborepo — это инструмент

Альтернатива — хранить все приложения и общий код в одном репозитории, причем приложения находятся в папке apps/, внутренние библиотеки — в папке packages/, а настройки рабочего пространства и задач — в корневой папке:

ecommerce-platform/
│
├── apps/
│   ├── api-gateway/
│   ├── auth-service/
│   ├── order-service/
│   ├── product-service/
│   ├── payment-service/
│   ├── inventory-service/
│   └── notification-service/
│
├── packages/
│   ├── types/
│   ├── logger/
│   ├── config/
│   ├── database/
│   └── messaging/
│
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

Такая структура представляет собой монорепозиторий: решение о том, где находится код. Turborepo — это инструмент, который координирует работу внутри него. Один может существовать без другого.

Что на самом деле делает Turborepo

Turborepo — это система сборки для монорепозиториев на JavaScript и TypeScript. Она координирует задачи, существующие в нескольких пакетах одновременно, такие как:

build
test
lint
type-check
dev

Без неё сборка нескольких сервисов означает необходимость вручную обращаться к каждому из них. Сначала сервис аутентификации:

cd apps/auth-service
pnpm build

затем остальные по очереди (в приведённом фрагменте кода два команды выполняются на одной строке; каждая команда cd и pnpm build должна находиться на отдельной строке):

cd ../order-service
pnpm buildcd ../payment-service
pnpm build

С помощью Turborepo вы объявляете задачи один раз и выполняете единственную команду из корневой директории:

turbo build

Затем система определяет, какие задачи необходимы, какие могут выполняться одновременно, и какие результаты можно восстановить из кэша вместо пересчёта.

Структурирование приложений NestJS и общих пакетов

Версия репозитория, ориентированная на производство, предоставляет каждому приложению NestJS собственные папки src/ и файл package.json, так что каждое приложение является полноценным пакетом рабочего пространства:

ecommerce-platform/
│
├── apps/
│   │
│   ├── api-gateway/
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── auth-service/
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── order-service/
│   │   ├── src/
│   │   └── package.json
│   │
│   └── payment-service/
│       ├── src/
│       └── package.json
│
├── packages/
│   │
│   ├── types/
│   ├── logger/
│   ├── config/
│   ├── database/
│   └── messaging/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

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

                Monorepo
                    |
          ┌─────────┴─────────┐
          ↓                   ↓
        Apps              Packages
          |                   |
       NestJS            Shared Code

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

Внутренние пакеты как единый источник истины

Самой значимой повседневной преимуществом монорепозитория являются внутренние пакеты. Например, пакет с типами может хранить модели, согласованные несколькими сервисами:

packages/
└── types/
    ├── src/
    │   ├── user.ts
    │   ├── order.ts
    │   ├── payment.ts
    │   └── pagination.ts
    └── package.json

Любой сервис Nest может затем импортировать его по имени пакета, которое определяется в рамках рабочей среды, а не через реестр:

import { OrderStatus } from '@repo/types';

вместо того чтобы вставлять один и тот же перечисление в каждый сервис и надеяться, что копии никогда не будут отличаться:

enum OrderStatus {
  PENDING = 'pending',
  PAID = 'paid',
  CANCELLED = 'cancelled',
}

Когда перечисление получает новое значение, компилятор отмечает все затронутые места в одном изменении.

Обмен контрактами — это не то же самое, что обмен правами собственности

В монорепозитории обмен данными происходит легко, и именно поэтому требуется дисциплина. Логика домена сервиса заказов принадлежит самому сервису заказов:

order-service
      ↓
order-domain

Она не должна тихо переходить в пакет:

packages/
└── order-domain/

который затем импортируют другие сервисы:

payment-service
inventory-service
notification-service

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

@repo/types
@repo/logger
@repo/config
@repo/testing
@repo/messaging

Бизнес-логика обычно должна находиться в сервисе, который ею управляет. Для более подробного рассмотрения этого компромисса см. дупликация против взаимосвязи: как определить, когда следует использовать совместный код.

Граф зависимостей определяет, что будет выполняться

Turborepo анализирует зависимости, указанные в файле package.json каждого рабочего пространства. В этом примере оба сервиса зависят от пакета types, а сервис order также зависит от пакета базы данных:

                  @repo/types
                  /       \
                 /         \
                ↓           ↓
        auth-service    order-service
                              |
                              ↓
                        @repo/database

Поскольку Turborepo знает эту структуру зависимостей, если вы измените файл внутри сервиса order:

apps/order-service/src/orders/order.service.ts

нет причин пересоздавать не связанный с этим сервис, например:

notification-service

С использованием фильтрации (например, turbo build --filter=...) можно ограничить выполнение только изменёнными пакетами и их зависимостями.

Кэширование: пропуск уже выполненных операций

При первом запуске сборки:

turbo build

выполняются все необходимые задачи:

auth-service        → BUILD
order-service       → BUILD
payment-service     → BUILD
inventory-service   → BUILD

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

turbo build

Результат будет выглядеть следующим образом:

auth-service        → CACHE HIT
order-service       → CACHE HIT
payment-service     → CACHE HIT
inventory-service   → CACHE HIT

Результаты выполнения задачи были восстановлены из кэша. Чтобы это сработало, Turborepo должен знать, какие файлы генерирует задача; это указывается в настройке outputs в файле turbo.json (для сборки проекта Nest обычно это dist/**). Задача с неопределёнными результатами может сообщить о доступе к кэшу, но при этом не предоставить результаты сборки.

Почему CI получает наибольшую пользу

Примитивная система обработки задач повторяет всю работу при каждом добавлении изменений:

Git Push
   ↓
Install dependencies
   ↓
Build everything
   ↓
Test everything
   ↓
Lint everything
   ↓
Deploy

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

                Git Push
                    ↓
                   CI
                    ↓
               Turborepo
                    ↓
          ┌─────────┴─────────┐
          ↓                   ↓
      Dependency          Task Graph
       Analysis               ↓
                          Cache Check
                              ↓
                    ┌─────────┴─────────┐
                    ↓                   ↓
                 Cached              Execute
                  Tasks                Tasks

В крупных репозиториях это устраняет большое количество избыточной работы CI.

Локальный кэш против удаленного кэша

Существует два уровня кэширования:

  • Локальный кэш. Ваш собственный компьютер повторно использует результаты уже выполненных задач, что ускоряет ежедневную разработку.
  • Удаленный кэш. Общий кэш позволяет разработчикам и машинам CI использовать результаты друг друга.

Устройство удаленного кэша выглядит следующим образом:

Developer A
     │
     ↓
Remote Cache
     ↑
     │
Developer B
     ↑
     │
    CI

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

Параллельное выполнение независимых задач

Некоторые сервисы не зависят от результатов сборки друг друга:

auth-service
order-service
product-service

Выполняются последовательно, каждый из них ждет завершения предыдущего:

auth → finish
       ↓
order → finish
         ↓
product

Turborepo может выполнять их одновременно, при этом выполнение ограничивается количеством доступных процессоров:

        ┌── auth ────┐
        │            │
turbo ──┼── order ───┼──→ Done
        │            │
        └── product ─┘

Описание взаимосвязей между задачами

Та же самая система оркестрации применяется ко всем типам задач, а не только к процессам сборки:

turbo build
turbo test
turbo lint
turbo typecheck

Связи между задачами объявляются в файле turbo.json. В приведённой ниже конфигурации "^build" означает «сначала собрать зависимости этого пакета», поэтому общие пакеты компилируются раньше сервисов, которые их импортируют. Задача test зависит от задачи build без символа каретки, что означает сборку того же пакета. Задачи lint и typecheck не имеют зависимостей и могут выполняться одновременно.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "typecheck": {}
  }
}

Вы описываете, как задачи зависят друг от друга, а Turborepo формирует план выполнения. В реальном проекте также следует добавлять поля outputs к задачам, которые можно кэшировать, как описано выше, и убедиться, что название каждой задачи совпадает с именем скрипта в файлах package.json пакетов рабочей среды.

pnpm и Turborepo выполняют разные функции

Эти инструменты так часто используются вместе, что могут казаться единым решением:

Turborepo + pnpm

На самом деле это отдельные компоненты. pnpm отвечает за управление пакетами и рабочими пространствами: установку зависимостей, связывание внутренних пакетов и соблюдение строгой структуры папки node_modules. Turborepo координирует задачи и хранит результаты их выполнения в рамках этого рабочего пространства.

pnpm
              │
      Workspace Management
              │
              ↓
        ┌─────────────┐
        │  Monorepo   │
        └──────┬──────┘
               │
               ↓
          Turborepo
               │
       ┌───────┼────────┐
       ↓       ↓        ↓
     Build    Test     Lint

Чтобы узнать больше о самом pnpm, прочитайте npm vs pnpm: хранилище, скорость и практические нюансы.

Почему не использовать встроенный режим монорепозитория Nest?

Nest CLI имеет собственный режим монорепозитория с приложениями и библиотеками, поэтому добавление Turborepo требует веской причины. Разница заключается в объеме работы: рабочее пространство Nest понимает только проекты Nest, тогда как Turborepo работает со всеми пакетами на JavaScript или TypeScript в рабочем пространстве. Как только репозиторий содержит не только сервисы Nest, например фронтенды и обычные рабочие процессы Node:

apps/
├── web/              # Next.js
├── admin/            # Next.js
├── api-gateway/      # NestJS
├── order-service/    # NestJS
└── worker/           # Node.js

плюс общие пакеты интерфейса, типов и конфигурации:

packages/
├── ui/
├── types/
├── config/
└── eslint-config/

вы уже не управляете проектом Nest — вы управляете платформой TypeScript, и именно здесь Turborepo становится привлекательным решением.

Turborepo, Nx или Nest CLI

Не существует универсально правильного выбора. В качестве общего руководства: режим monorepo от Nest — это самый простой вариант, когда всё использует Nest. Turborepo представляет собой относительно простой слой для выполнения задач и кэширования, добавленный поверх рабочих пространств вашего менеджера пакетов. Nx также предоставляет граф проектов и функции кэширования, а также большой набор плагинов, генераторов кода и стандартов, которые некоторым командам кажутся ценными, а другим — избыточными. Выбирайте в зависимости от ваших реальных потребностей, а не от того, какой инструмент привлекает наибольше внимания в социальных сетях.

Когда Turborepo — избыточность

Предположим, что вся система состоит из одного API Nest с использованием PostgreSQL и Redis:

NestJS API
   ↓
PostgreSQL
   ↓
Redis

при небольшой команде и одном развертываемом проекте:

1 application
5 developers
1 deployment

Turborepo, скорее всего, не нужен. Обычный репозиторий Nest проще, и внедрение всего этого:

Turborepo
pnpm workspace
multiple packages
complex task graph

Это увеличивает когнитивную нагрузку без явной выгоды. Инструменты оправдывают свое существование только тогда, когда проблема требует их использования.

Когда это начинает приносить пользу

Ситуация меняется, когда одновременно выполняются несколько из этих условий:

Multiple applications
        +
Shared packages
        +
Shared tooling
        +
Repeated CI tasks
        +
Large TypeScript codebase

Типичным примером является платформа с фронтендами на Next.js, API-сервисами Nest и фоновыми процессами, все из которых используют общие пакеты:

                    Platform
                       │
        ┌──────────────┼──────────────┐
        ↓              ↓              ↓
    Next.js         NestJS         Workers
      Web             APIs           Jobs
        │              │              │
        └──────────────┼──────────────┘
                       ↓
                  Shared Packages

Зрелая структура с четким разделением обязанностей

Зрелая платформа Nest может включать отдельную область для инфраструктуры наряду с приложениями и пакетами:

platform/
│
├── apps/
│   ├── api-gateway/
│   ├── auth-service/
│   ├── user-service/
│   ├── order-service/
│   ├── inventory-service/
│   ├── payment-service/
│   └── notification-service/
│
├── packages/
│   ├── config/
│   ├── logger/
│   ├── types/
│   ├── database/
│   ├── messaging/
│   └── testing/
│
├── infrastructure/
│   ├── docker/
│   └── deployment/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

У каждого инструмента есть своя задача. NestJS — это фреймворк для приложений:

NestJS
   ↓
Application framework

Остальные слои распределяются следующим образом (в приведённом фрагменте исходного кода несколько элементов показаны вместе; рассматривайте pnpm как инструмент для управления зависимостями и рабочими пространствами, Turborepo — для оркестрации задач и кэширования, Docker — для контейнеризации, Kafka или RabbitMQ — для асинхронной передачи сообщений, а PostgreSQL или Redis — для хранения данных и кэширования):

pnpm
   ↓
Dependency & workspace managementTurborepo
   ↓
Task orchestration + cachingDocker
   ↓
ContainerizationKafka / RabbitMQ
   ↓
Asynchronous communicationPostgreSQL / Redis
   ↓
Data & caching

Turborepo не является вашей инфраструктурой, посредником в передаче сообщений или платформой развертывания. Это слой сборки и выполнения задач, окружающий монорепозиторий.

Один репозиторий не означает одно развертывание

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

One Git repository
       │
       ├── auth-service
       ├── order-service
       ├── payment-service
       └── notification-service

при этом каждый из них по-прежнему располагается в отдельном контейнере:

auth-service        → Container A
order-service       → Container B
payment-service     → Container C
notification        → Container D

и масштабируется по собственным правилам:

order-service × 10
payment-service × 5
notification × 3

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

Скрытый риск: общий пакет, поглощающий всё

Удобство совместного использования имеет двойной эффект. Часто всё начинается безобидно с одного пакета:

packages/common

который постепенно собирает вспомогательные компоненты для каждой области:

packages/common
├── AuthUtils
├── OrderUtils
├── PaymentUtils
├── InventoryUtils
├── NotificationUtils
├── DatabaseUtils
└── EverythingUtils

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

apps/
packages/

Но архитектура, лежащая в основе, имеет высокую степень связности. Соблюдайте строгое правило: используйте общую инфраструктуру, контракты и действительно универсальные утилиты; логику бизнеса оставляйте внутри сервиса, который ею управляет.

Модель системы в слоях

Полезно представлять систему в виде слоев: сверху — бизнес-домены, ниже — общие контракты и инфраструктура, а в самом низу — инструменты monorepo:

              Business Domains
                       │
       ┌───────────────┼───────────────┐
       ↓               ↓               ↓
     Order          Payment         Inventory
       │               │               │
       └───────────────┼───────────────┘
                       ↓
                Shared Contracts
                       │
                       ↓
               Shared Infrastructure
                       │
                       ↓
                Monorepo Tooling
                       │
                       ↓
                  Turborepo

Turborepo находится ближе к низу. Он должен поддерживать вашу архитектуру, а не формировать её.

Решение о применении

Последовательно рассмотрите четыре вопроса.

У вас есть более одного приложения?

Если нет, ответ прост:

Keep it simple.

Если да, переходите дальше.

Используют ли приложения общие инструменты или код?

Если нет:

Polyrepo may be perfectly reasonable.

Если да:

Monorepo becomes attractive.

Становятся ли времена сборки, тестирования и проверки кода слишком длительными?

Если да:

Turborepo's orchestration and caching become valuable.

Вы запускаете Next.js, NestJS, воркеры и общие пакеты TypeScript одновременно?

Если да:

Turborepo becomes a strong candidate.

Надежность — вот настоящая выгода

Скорость — это побочный эффект. Настоящая ценность заключается в том, что большой репозиторий становится предсказуемым. По мере увеличения количества приложений кто-то должен отвечать на вопросы по каждой изменении:

What changed?

а также на набор дополнительных вопросов (в исходном коде они указаны на одной строке): что необходимо пересобрать, что нужно перепроверить, что можно запустить параллельно, что можно взять из кэша и какие пакеты зависят от изменения:

What needs to rebuild?What needs to be tested?What can run in parallel?What can come from cache?Which packages depend on this change?

Без специальных инструментов инженеры отвечают на эти вопросы вручную; с системой сборки монорепозитория большая часть ответов получается на основе графа зависимостей.

Основные выводы

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

                Business Architecture
                         │
                         ↓
                    NestJS Apps
                         │
                         ↓
                  Shared Packages
                         │
                         ↓
                      pnpm
                         │
                         ↓
                    Turborepo
                         │
              ┌──────────┼──────────┐
              ↓          ↓          ↓
            Build      Test       Lint
              │          │          │
              └──────────┼──────────┘
                         ↓
                       CI/CD
  • Монорепозиторий определяет, где находится код; pnpm управляет рабочей средой; Turborepo координирует задачи и хранит их в кэше; NestJS отвечает за сборку сервисов. Необходимо сохранять разделение этих функций.
  • Следует делиться абстракциями для типов, конфигурации, логирования и обмена сообщениями; логику домена следует оставлять в соответствующем сервисе, чтобы избежать появления распределенного монолита.
  • Необходимо указывать взаимосвязи между задачами с помощью параметра dependsOn и определять результаты выполнения в файле turbo.json, чтобы кэширование было эффективным и корректным.
  • Структура хранилища и процесс развертывания являются независимыми: каждый сервис может быть развернут и масштабирован отдельно.
  • Используйте Turborepo, когда координация нескольких приложений на TypeScript стоит дороже, чем внедрение этого инструмента. Для одного приложения Nest API это обычно не требуется.