Où s’insère Turborepo dans un monorepo NestJS et quand l’éviter
Comment Turborepo, les workspaces de pnpm et NestJS répartissent le travail dans un monorepo TypeScript : packages partagés, graphes de tâches, mise en cache, risques de couplage et quand l’éviter.
Sources: Turborepo + NestJS: The Monorepo Architecture That Makes Large TypeScript Systems Manageable
package.json, d’un seul dossier node_modules et d’une seule chaîne de traitement CI. À mesure que le produit se développe, les fonctionnalités liées à l’authentification, aux commandes, aux paiements, au stock et aux notifications sont séparées en services distincts. La question opérationnelle passe alors de « comment échelonner ce service ? » à « comment gérer sept applications TypeScript sans devoir copier sept fois les mêmes configurations, types et processus ? ». Cet article explique ce que contribuent respectivement un monorepo, pnpm et Turborepo, comment le graphe des tâches et le cache permettent d’économiser du temps, ce qui peut être partagé en toute sécurité, et quand les coûts de mise en place dépassent les avantages obtenus.
Tenez toujours un principe fondamental à l’esprit : Turborepo ne crée pas de microservices, ne remplace pas NestJS et ne rend pas une architecture scalable par lui-même. Il résout un problème plus restreint : coordonner les tâches de type construction entre de nombreux paquets au sein d’un seul répertoire.
D’un service à plusieurs répertoires
Le répertoire d’une plateforme en croissance peut finir par contenir un dossier par service :
auth-service/
order-service/
payment-service/
inventory-service/
notification-service/
shipment-service/
api-gateway/
Diviser les services facilite l’échelle et la responsabilisation, mais chaque service intègre désormais ses propres dépendances, configuration, processus de construction, tests, règles de linting et pipeline.
Le coût de coordination d’un polyrepo
Imaginez un système e-commerce typique où une passerelle gère les services d’authentification, de commandes et de produits ; le service des commandes appelle les services de paiement et d’inventaire, tandis que le service de paiement déclenche des notifications :
API Gateway
|
┌─────────────────┼─────────────────┐
↓ ↓ ↓
Auth Order Product
|
┌────────┴────────┐
↓ ↓
Payment Inventory
|
↓
Notification
Une première approche courante consiste à attribuer à chaque service son propre répertoire, c’est l’approche 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
Ce n’est pas une architecture illégitime. Les problèmes apparaissent lorsque l’on examine chaque répertoire et que l’on constate que le même squelette est reproduit partout :
package.json
tsconfig.json
eslint.config.js
Dockerfile
CI configuration
README.md
node_modules
Et, pire encore, les mêmes types de code sont nécessaires pour plusieurs services en même temps :
DTOs
TypeScript types
logging utilities
configuration
authentication helpers
database utilities
Kafka/RabbitMQ abstractions
testing utilities
Désormais, toute modification de contenu partagé devient une opération de synchronisation. Lorsqu’un type partagé change, quels répertoires doivent être mis à jour ? Lorsqu’une dépendance est mise à niveau, quels services ont besoin d’être mis à jour également ? Lorsqu’une règle de lint ou un flux de travail CI change, combien de copies doivent être modifiées pour rester en phase ? Le nombre de services n’est pas le véritable problème : c’est la coordination.
Un monorepo est une stratégie ; Turborepo est un outil
L’alternative consiste à conserver toutes les applications et le code partagé dans un seul répertoire, avec les applications sous apps/, les bibliothèques internes sous packages/, et la configuration de l’espace de travail ainsi que des tâches en racine :
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
Cette organisation correspond à un monorepo : une décision concernant l’emplacement du code. Turborepo est un outil qui coordonne le travail à l’intérieur de celui-ci. On peut avoir l’un sans l’autre.
Que fait réellement Turborepo
Turborepo est un système de construction pour les monorepos JavaScript et TypeScript. Il orchestre en même temps les tâches présentes dans de nombreux packages, telles que :
build
test
lint
type-check
dev
Sans lui, compiler plusieurs services signifie de devoir les visiter un par un manuellement. D’abord le service d’authentification :
cd apps/auth-service
pnpm build
puis les autres à tour de rôle (le fragment de code exécute deux commandes sur une même ligne ; chaque cd et pnpm build doit se trouver sur sa propre ligne) :
cd ../order-service
pnpm buildcd ../payment-service
pnpm build
Avec Turborepo, vous déclarez les tâches une seule fois et exécutez une commande unique depuis la racine :
turbo build
Il détermine ensuite quelles tâches sont nécessaires, lesquelles peuvent s’exécuter en parallèle, et quels résultats peuvent être récupérés depuis le cache au lieu d’être recomputés.
Hiérarchisation des applications NestJS et des packages partagés
Une version orientée production du répertoire donne à chaque application Nest son propre src/ et package.json, de sorte que chaque application constitue un véritable package d’espace de travail :
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
Conceptuellement, il existe deux niveaux : les applications, qui sont des services NestJS assurant des fonctionnalités métier, et les packages, qui contiennent du code réutilisable.
Monorepo
|
┌─────────┴─────────┐
↓ ↓
Apps Packages
| |
NestJS Shared Code
Les applications mettent en œuvre des fonctionnalités, les packages fournissent des blocs de construction partagés, et Turborepo coordonne les tâches entre les deux.
Les packages internes en tant que source unique de vérité
Le principal avantage quotidien d’un monorepo réside dans les packages internes. Un package de types, par exemple, peut contenir les modèles sur lesquels plusieurs services s’accordent :
packages/
└── types/
├── src/
│ ├── user.ts
│ ├── order.ts
│ ├── payment.ts
│ └── pagination.ts
└── package.json
Chaque service Nest peut alors l’importer en utilisant son nom de package, résolu via l’espace de travail plutôt que via un registre :
import { OrderStatus } from '@repo/types';
au lieu de coller le même enum dans chaque service en espérant que les copies ne divergeront jamais :
enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
CANCELLED = 'cancelled',
}
Lorsqu’un enum reçoit une nouvelle valeur, le compilateur signale tous les endroits affectés dans la même modification.
Partager des contrats n’est pas pareil que partager la propriété
Le partage est simple dans un monorepo, ce qui explique justement pourquoi il nécessite de la discipline. La logique métier du service des commandes appartient au service des commandes :
order-service
↓
order-domain
Elle ne devrait pas migrer discrètement vers un package :
packages/
└── order-domain/
que d’autres services importent ensuite :
payment-service
inventory-service
notification-service
Lorsque le code de paiement, d’inventaire et de notification dépend des mécanismes internes du domaine des commandes, toute modification des règles de commande peut endommager trois déploiements indépendants. Règle générale : partagez délibérément l’infrastructure et les contrats, mais ne partagez jamais par défaut la propriété métier. Les candidats appropriés pour des packages partagés se présentent comme suit :
@repo/types
@repo/logger
@repo/config
@repo/testing
@repo/messaging
La logique métier devrait normalement rester dans le service qui en est responsable. Pour une analyse plus approfondie de cet équilibre, consultez la duplication versus le couplage : décider quand du code partagé doit exister.
Le graphe de dépendances décide de ce qui s’exécute
Turborepo lit les relations de dépendance déclarées dans le package.json de chaque espace de travail. Dans cet exemple, les deux services dépendent du package types, et le service order dépend également d’un package de base de données :
@repo/types
/ \
/ \
↓ ↓
auth-service order-service
|
↓
@repo/database
Puisque Turborepo connaît ce graphe, si vous modifiez un fichier à l’intérieur du service order :
apps/order-service/src/orders/order.service.ts
il n’y a aucune raison de recompiler un service non lié comme :
notification-service
Avec le filtrage (par exemple turbo build --filter=...), vous pouvez limiter une exécution aux packages modifiés et à leurs dépendants.
Cachage : éviter de refaire du travail déjà effectué
Lors de la première exécution d’une compilation :
turbo build
toute tâche requise est effectivement exécutée :
auth-service → BUILD
order-service → BUILD
payment-service → BUILD
inventory-service → BUILD
Turborepo identifie les entrées de chaque tâche (fichiers sources, dépendances, configuration, variables d’environnement pertinentes) et stocke les sorties ainsi que les journaux. Exécutez à nouveau la même commande sans aucun changement pertinent :
turbo build
et le résultat est le suivant :
auth-service → CACHE HIT
order-service → CACHE HIT
payment-service → CACHE HIT
inventory-service → CACHE HIT
Les sorties ont été restaurées depuis le cache. Pour que cela fonctionne, Turborepo doit savoir quels fichiers une tâche génère, ce que définit l’option outputs dans turbo.json (pour une compilation Nest, il s’agit généralement de dist/**). Une tâche dont les sorties ne sont pas déclarées peut signaler un accès au cache tout en vous laissant sans les artefacts de compilation.
Pourquoi les pipelines CI en bénéficient le plus
Un pipeline naïf répète tout le travail à chaque mise à jour :
Git Push
↓
Install dependencies
↓
Build everything
↓
Test everything
↓
Lint everything
↓
Deploy
Avec Turborepo dans le pipeline, chaque tâche est d’abord vérifiée par rapport au graphe et à la cache, et seules les tâches dont les entrées ont changé sont exécutées :
Git Push
↓
CI
↓
Turborepo
↓
┌─────────┴─────────┐
↓ ↓
Dependency Task Graph
Analysis ↓
Cache Check
↓
┌─────────┴─────────┐
↓ ↓
Cached Execute
Tasks Tasks
Dans les grands dépôts, cela élimine une grande partie du travail redondant lié aux tests continus.
Cache local versus cache distant
Il existe deux niveaux de mise en cache :
- Cache local. Votre propre machine réutilise les résultats des tâches qu’elle a déjà exécutées, ce qui accélère le développement quotidien.
- Cache distant. Un cache partagé permet aux développeurs et aux machines de tests continus de réutiliser les résultats les uns des autres.
L’installation distante se présente comme ceci :
Developer A
│
↓
Remote Cache
↑
│
Developer B
↑
│
CI
Si un développeur a déjà construit un service et que le CI rencontre par la suite les mêmes entrées de tâche, il télécharge le résultat au lieu de reconstruire. Traitez le cache distant comme faisant partie de votre chaîne d’approvisionnement : contrôlez qui peut y écrire, car un artefact corrompu serait servi à tout le monde.
Exécution en parallèle de tâches indépendantes
Certaines services ne dépendent pas des résultats de construction les uns des autres :
auth-service
order-service
product-service
Exécution séquentielle, chaque tâche attend la précédente :
auth → finish
↓
order → finish
↓
product
Turborepo peut au contraire les exécuter en même temps, selon les capacités de CPU disponibles :
┌── auth ────┐
│ │
turbo ──┼── order ───┼──→ Done
│ │
└── product ─┘
Description des relations entre les tâches
Cette même orchestration s’applique à tous les types de tâches, et pas seulement aux constructions :
turbo build
turbo test
turbo lint
turbo typecheck
Les relations sont déclarées dans turbo.json. Dans la configuration ci-dessous, "^build" signifie "compiler d’abord les dépendances de ce package" ;, de sorte que les packages partagés sont compilés avant les services qui les importent. La tâche test dépend de build sans accent aigu, ce qui fait référence à la compilation du même package. lint et typecheck n’ont aucune dépendance et peuvent être exécutés immédiatement en parallèle.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"typecheck": {}
}
}
Vous décrivez comment les tâches dépendent les unes des autres, et Turborepo en déduit un plan d’exécution. Dans un projet réel, vous ajouteriez également des outputs aux tâches pouvant être mises en cache, comme décrit ci-dessus, et vous vous assureriez que chaque nom de tâche correspond à un script dans les fichiers package.json des packages du workspace.
pnpm et Turborepo ont des fonctions différentes
Cette combinaison apparaît si souvent qu’elle peut donner l’impression d’être un seul outil :
Turborepo + pnpm
Il s’agit en réalité de couches distinctes. pnpm gère les paquets et les espaces de travail : il installe les dépendances, relie les paquets internes et impose une structure stricte pour node_modules. Turborepo, quant à lui, orchestre les tâches et stocke leurs résultats au sein de cet espace de travail.
pnpm
│
Workspace Management
│
↓
┌─────────────┐
│ Monorepo │
└──────┬──────┘
│
↓
Turborepo
│
┌───────┼────────┐
↓ ↓ ↓
Build Test Lint
Pour en savoir plus sur pnpm lui-même, consultez npm vs pnpm : stockage, vitesse et compromis dans la pratique.
Pourquoi ne pas simplement utiliser le mode monorepo intégré de Nest ?
The Nest CLI dispose de son propre mode monorepo comprenant des applications et des bibliothèques, ce qui rend l’ajout de Turborepo superflu. La différence réside dans la portée : l’espace de travail de Nest ne comprend que des projets Nest, tandis que Turborepo fonctionne avec n’importe quels paquets JavaScript ou TypeScript présents dans l’espace de travail. Dès que le répertoire contient plus que des services Nest, comme par exemple des interfaces utilisateur et des workers Node classiques :
apps/
├── web/ # Next.js
├── admin/ # Next.js
├── api-gateway/ # NestJS
├── order-service/ # NestJS
└── worker/ # Node.js
ainsi que des paquets partagés pour l’UI, les types et la configuration :
packages/
├── ui/
├── types/
├── config/
└── eslint-config/
on ne gère plus un projet Nest, mais plutôt une plateforme TypeScript, c’est là que Turborepo devient intéressant.
Turborepo, Nx ou le Nest CLI
Il n’existe pas de choix universellement correct. Pour une orientation générale : le mode monorepo de Nest est l’option la plus simple lorsque tout est géré par Nest. Turborepo représente une couche relativement légère de gestion des tâches et de mise en cache par-dessus les espaces de travail du gestionnaire de paquets. Nx propose également un graphe de projets et une fonctionnalité de mise en cache, ainsi qu’un ensemble plus important de plugins, de générateurs de code et de conventions, que certaines équipes apprécient tandis que d’autres le jugent trop lourd. Choisissez en fonction de vos besoins réels, et non en fonction de l’outil qui reçoit le plus d’attention sur les réseaux sociaux.
Lorsque Turborepo est un excès
Supposons que tout le système soit constitué d’une seule API Nest soutenue par PostgreSQL et Redis :
NestJS API
↓
PostgreSQL
↓
Redis
Avec une petite équipe et un seul déploiement possible :
1 application
5 developers
1 deployment
Turborepo est probablement inutile. Un simple répertoire Nest est plus simple, et introduire tout cela :
Turborepo
pnpm workspace
multiple packages
complex task graph
Cela augmente la charge cognitive sans en tirer de bénéfice concret. Les outils trouvent leur place lorsque le problème justifie leur utilisation.
Lorsqu’ils commencent à être utiles
La situation change lorsque plusieurs de ces conditions sont remplies en même temps :
Multiple applications
+
Shared packages
+
Shared tooling
+
Repeated CI tasks
+
Large TypeScript codebase
Une plateforme avec des interfaces frontend en Next.js, des API Nest et des travailleurs en arrière-plan qui utilisent tous des packages partagés constitue le cas typique :
Platform
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Next.js NestJS Workers
Web APIs Jobs
│ │ │
└──────────────┼──────────────┘
↓
Shared Packages
Une structure mature avec des responsabilités claires
Une plateforme Nest développée peut ajouter une section distincte pour l’infrastructure, en plus des applications et des packages :
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
Chaque outil a une seule fonction. NestJS est le framework d’application :
NestJS
↓
Application framework
Les couches restantes sont réparties comme suit (dans l’extrait de code, plusieurs éléments sont présentés ensemble ; il s’agit de pnpm pour les dépendances et les espaces de travail, Turborepo pour l’orchestration des tâches et le cache, Docker pour la conteneurisation, Kafka ou RabbitMQ pour les messages asynchrones, ainsi que PostgreSQL ou Redis pour les données et le cache) :
pnpm
↓
Dependency & workspace managementTurborepo
↓
Task orchestration + cachingDocker
↓
ContainerizationKafka / RabbitMQ
↓
Asynchronous communicationPostgreSQL / Redis
↓
Data & caching
Turborepo n’est ni votre infrastructure, ni votre broker de messages, ni votre plateforme de déploiement. C’est plutôt la couche de construction et de gestion des tâches qui entoure le monorepo.
Un seul répertoire ne signifie pas un seul déploiement
Une inquiétude fréquente est que placer tout dans un seul répertoire compromette l’indépendance que les microservices devraient offrir. Ce n’est pas le cas. L’endroit où se trouve le code et la manière dont il est déployé sont des décisions distinctes. Un seul répertoire peut contenir de nombreux services :
One Git repository
│
├── auth-service
├── order-service
├── payment-service
└── notification-service
chacun étant encore livré en tant que conteneur indépendant :
auth-service → Container A
order-service → Container B
payment-service → Container C
notification → Container D
et s’adaptant selon ses propres règles :
order-service × 10
payment-service × 5
notification × 3
Un monorepo n’est pas un monolithe, et un polyrepo ne vous donne pas automatiquement des microservices. Ce sont des dimensions indépendantes. Ce dont vous avez besoin, c’est d’un CI qui déploie uniquement les services dont le code ou les dépendances ont changé, ce qui est possible grâce au graphe de dépendances.
Le risque caché : un package commun qui englobe tout
Le partage facile a des effets à double tranchant. Il commence souvent de manière inoffensive avec un seul package :
packages/common
qui collecte progressivement des outils d’aide pour chaque domaine :
packages/common
├── AuthUtils
├── OrderUtils
├── PaymentUtils
├── InventoryUtils
├── NotificationUtils
├── DatabaseUtils
└── EverythingUtils
jusqu’à ce que chaque service en dépende. À ce stade, vous avez créé un monolithe distribué à l’intérieur d’un monorepo : une modification d’un outil d’aide force une reconstruction, un nouvel essai et un nouveau déploiement de tout. Les dossiers de niveau supérieur semblent toujours bien organisés :
apps/
packages/
Mais l’architecture sous-jacente est fortement couplée. Respectez strictement cette règle : partagez l’infrastructure, les contrats et des outils véritablement génériques ; conservez la logique métier à l’intérieur du service qui en est responsable.
Un modèle mental en couches
Il est utile d’imaginer le système comme des couches, avec les domaines métier en haut, les contrats et l’infrastructure partagés en dessous, et les outils de monorepo en base :
Business Domains
│
┌───────────────┼───────────────┐
↓ ↓ ↓
Order Payment Inventory
│ │ │
└───────────────┼───────────────┘
↓
Shared Contracts
│
↓
Shared Infrastructure
│
↓
Monorepo Tooling
│
↓
Turborepo
Turborepo se trouve près de la base. Il doit soutenir votre architecture, et non la définir.
Décider s’il faut l’adopter
Résolvez ces quatre questions dans l’ordre.
Avez-vous plus d’une application ?
Si ce n’est pas le cas, la réponse est simple :
Keep it simple.
Si c’est le cas, passez à l’étape suivante.
Les applications partagent-elles des outils ou du code importants ?
Si ce n’est pas le cas :
Polyrepo may be perfectly reasonable.
Si c’est le cas :
Monorepo becomes attractive.
Les temps de compilation, de test et d’analyse deviennent-ils coûteux ?
Si oui :
Turborepo's orchestration and caching become valuable.
Démarrez-vous Next.js, NestJS, des workers et des packages TypeScript partagés en même temps ?
Si oui :
Turborepo becomes a strong candidate.
La prévisibilité est le véritable avantage
La vitesse n’est qu’un effet secondaire. La véritable valeur réside dans le fait qu’un grand dépôt devient prévisible. À mesure que les applications se multiplient, quelqu’un doit répondre, pour chaque modification :
What changed?
et à une série de questions complémentaires (le code les affiche sur une seule ligne) : ce qui doit être recompilé, ce qui doit être rétesté, ce qui peut s’exécuter en parallèle, ce qui peut être récupéré depuis le cache, et quels packages dépendent de cette modification :
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?
Sans outils, les ingénieurs répondent à ces questions manuellement ; avec un système de construction de monorepo, la plupart des réponses proviennent du graphe.
Points clés
Turborepo ne corrige pas des limites de service insuffisantes, une conception de base de données faible, un couplage trop étroit, de mauvais contrats API, une responsabilité domaine floue ou des microservices mal conçus. Ce qu’il fait, c’est permettre de gérer un dépôt TypeScript en constante expansion. Toute la stack s’inscrit dans une même vision :
Business Architecture
│
↓
NestJS Apps
│
↓
Shared Packages
│
↓
pnpm
│
↓
Turborepo
│
┌──────────┼──────────┐
↓ ↓ ↓
Build Test Lint
│ │ │
└──────────┼──────────┘
↓
CI/CD
- Un monorepo décide où se trouve le code ; pnpm gère l’espace de travail ; Turborepo orchestre et cache les tâches ; NestJS construit les services. Gardez ces rôles séparés.
- Partagez les abstractions de types, de configuration, d’encodage des logs et de messagerie ; conservez la logique domaine avec son service correspondant pour éviter un monolithe distribué.
- Déclarez les relations entre tâches avec
dependsOnet les sorties dansturbo.jsonafin que le cacheage soit à la fois efficace et précis. - La structure du dépôt et le déploiement sont indépendants : chaque service peut toujours être déployé et échelonné séparément.