Яке місце займає Turborepo у monorepo NestJS та коли його краще пропустити
Як Turborepo, pnpm workspaces та NestJS розподіляють роботу в монорепо на TypeScript: спільні пакети, графи завдань, кешування, ризики взаємозв’язку та коли його можна пропустити.
Джерела: 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 та спільних пакетів
Версія репозиторію, орієнтована на продакшн, надає кожному додатку Nest власні папки 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 у кожен сервіс та сподіватися, що копії ніколи не відрізнятимуться:
enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
CANCELLED = 'cancelled',
}
Коли enum отримує нове значення, компілятор позначає всі місця, які це впливає, у одній зміні.
Обмін контрактами — це не те саме, що обмін власністю
У монорепозиторії обмін є простим, саме тому він потребує дисципліни. Логіка домену сервісу замовлень належить саме цьому сервісу:
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 може виконувати їх одночасно, з обмеженням за наявністю CPU:
┌── 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 має власний режим monorepo з додатками та бібліотеками, тому наявність Turborepo потребує певної причини. Різниця полягає у обсязі функціоналу: робоче середовище Nest розуміє лише проекти Nest, тоді як Turborepo працює з будь-якими пакетами JavaScript чи TypeScript у цьому середовищі. Як тільки репозиторій містить більше, ніж лише сервіси Nest — наприклад, фронтенди та звичайні працівники Node:
apps/
├── web/ # Next.js
├── admin/ # Next.js
├── api-gateway/ # NestJS
├── order-service/ # NestJS
└── worker/ # Node.js
а також спільні пакети UI, типів та конфігурації:
packages/
├── ui/
├── types/
├── config/
└── eslint-config/
ви більше не керуєте проектом Nest. Ви керуєте платформою TypeScript, саме тут Turborepo стає привабливим варіантом.
Turborepo, Nx чи Nest CLI
Не існує універсально правильного вибору. Як загальне керівництво: режим monorepo в Nest — це найпростіший варіант, коли все є частиною Nest. Turborepo — це відносно незначний шар для виконання завдань та кешування поверх робочих просторів вашого менеджера пакетів. Nx також забезпечує граф проектів та кешування, а також додає більший набір плагінів, генераторів коду та стандартів, які деякі команди цінують, а інші вважають зайвими. Вибирайте на основі ваших реальних потреб, а не того, який інструмент отримує найбільше уваги в соціальних мережах.
Коли Turborepo є зайвим
Припустімо, що вся система — це один Nest API з підтримкою 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 – це не ваша інфраструктура, не ваш брокер повідомлень та не платформа для розгортання. Це шар збірки та виконання завдань, який оточує monorepo.
Один репозиторій – не означає одне розгортання
Часто існує занепокоєння щодо того, що зберігання всього в одному репозиторії пожертвує незалежністю, яку мали забезпечувати мікросервіси. Це не так. Місце зберігання коду та спосіб його розгортання – це окремі рішення. Один репозиторій може містити багато сервісів:
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
- Monorepo визначає, де знаходиться код; pnpm керує робочим простором; Turborepo координує та кешує завдання; NestJS створює сервіси. Треба зберігати ці функції окремо.
- Спільно використовувати абстракції типів, конфігурацій, обробки логів та комунікації; логіку домену залишати у відповідному сервісі, щоб уникнути дистриб’юйованого моноліту.
- Описувати залежності між завданнями за допомогою
dependsOnта результати їх виконання у файліturbo.json, щоб кешування було ефективним та точним. - Структура репозиторію та процес розгортання є незалежними: кожен сервіс може розгортатися та масштабуватися окремо.