Accueil / Articles

Déplacer un backend NestJS vers un monorepo pnpm et Turborepo

Partagez des contrats TypeScript entre les API grâce aux espaces de travail pnpm, aux graphes de tâches Turborepo, à des Dockerfiles optimisés pour le cache et aux catalogues pnpm — sans transformer les fichiers courants en un endroit désordonné.

Éditeur : REACTAPP.TOP7 min de lecture1511 mots

Sources: Why I Chose a Monorepo for My Backend (And What Actually Happened)

Environ neuf mois après le développement progressif d’un backend NestJS, une seule API, un seul Dockerfile et un seul pipeline se sont révélés insuffisants. Une deuxième API est apparue, suivie d’une troisième, ainsi que d’une interface admin en React. La question restante était de savoir comment répartir les responsabilités sans perdre les contrats TypeScript communs.

La solution retenue a été un monorepo géré par pnpm workspace, orchestré avec Turborepo : ce qui a fonctionné, ce qui a posé des problèmes, et ce qui changerait lors d’une deuxième tentative — uniquement des choix techniques, sans référence à la marque du produit.

Le problème qui a tout déclenché

Le premier service était initialement une seule application NestJS gérant l’authentification, la gestion de contenu, les téléchargements vers Cloudflare R2 et les recherches basées sur Meilisearch. Cette structure est restée en place jusqu’à ce que la fonction de recherche nécessite son propre rythme de déploiement et ses propres limites en cas d’échec, tout en continuant à partager des enums et des outils avec l’API principale.

Deux options se sont présentées : copier les éléments partagés dans un autre répertoire et les synchroniser manuellement, ou partager le code directement tout en permettant à chaque application d’être compilée et déployée indépendamment.

Les énumérations dupliquées entre répertoires semblent inoffensives tant que l’une des parties met à jour ses éléments et pas l’autre. Une simple incohérence dans une énumération de type de contenu a suffi à rendre impossible le partage entre plusieurs répertoires à cette échelle.

Pourquoi pnpm, pourquoi Turborepo

Trois solutions ont été comparées : Nx, les espaces de travail Yarn avec Lerna, et les espaces de travail pnpm avec Turborepo.

Nx était performant, mais ressemblait à un deuxième framework : courbe d’apprentissage raide et structure générée trop lourde par rapport aux besoins de quatre applications et de quelques packages partagés. Les outils de visualisation des graphes ainsi que les mécanismes de détection des commandes affectées étaient plus complexes que nécessaire.

Les espaces de travail Yarn ainsi que Lerna semblaient familiers sous Yarn 1.x, mais la situation en matière de maintenance de Lerna posait des problèmes, et la gestion peu stricte de Yarn avait déjà permis l’apparition de dépendances fantômes — des imports qui fonctionnaient uniquement parce qu’un paquet transitoire se trouvait par hasard localement, avant de cesser de fonctionner en production.

pnpm l’a emporté grâce à son isolement strict : chaque import doit être déclaré dans package.json. Lors de la migration, il a immédiatement signalé @nestjs/mapped-types comme étant utilisé mais non déclaré, ce que Yarn avait permis grâce à son analyse du graphe de dépendances. Associé au lien entre espaces de travail sans nécessiter de liens manuels complexes, ce niveau de stricteur a été décisif.

Turborepo orchestre les builds par-dessus tout cela. En déclarant "dependsOn": ["^build"], il fait en sorte que les paquets partagés soient compilés avant leurs utilisateurs, cache les résultats et parallélise les tâches indépendantes. La plupart des règles sont définies dans un seul turbo.json :

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

Le token ^build signifie « construire d’abord les dépendances ». Turborepo lit ensuite le graphe de package.json pour le reste.

Cette configuration minimaliste est intentionnelle. L’objectif de la migration était d’obtenir un retour plus rapide pour un petit graphe de services, et non de fournir un ensemble complet d’outils pour gérer toutes les tâches affectées par une équipe de plateforme. Le fait de garder l’orchestration déclarative a rendu l’intégration moins coûteuse lorsque la troisième API est arrivée : les nouvelles applications copiaient les noms de tâches de turbo.json et utilisaient les mêmes clés de cache.

Ce que je partage réellement

Débutalement, il existait des packages séparés pour les types et les outils utilitaires. En quelques mois, ils ont été fusionnés en un seul package @myapp/common. Cette séparation semblait logique sur le papier, mais était gênante dans la pratique : chaque énumération avait besoin d’un emplacement dédié, et la plupart des fichiers importaient de toute façon les deux packages.

L’arborescence partagée ressemble maintenant à ceci :

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

Préférez des structures petites délibérément. Partagez uniquement les types inter-services, les enums utilisés par plusieurs applications, ainsi qu’un seul outil UUID. Tout le reste doit rester local à chaque application. Le package constitue un contrat, et non un tiroir à ordures.

Trois autres packages partagés ont ensuite trouvé leur place :

Un package config charge les secrets. Les deux API se connectent au gestionnaire de secrets au démarrage ; par conséquent, la fonction loadSecrets() est définie une seule fois et exécutée depuis chaque main.ts avant les autres opérations de démarrage.

Un package telemetry gère le suivi via OpenTelemetry ainsi que l’enregistrement structuré en JSON, y compris le filtrage des données bruitées provenant des vérifications de santé. L’observabilité transversale permet aux codes partagés d’éliminer les écarts réels entre services.

Un package vitest-config exporte une factory Vitest partagée, ce qui assure une configuration cohérente des tests sans avoir à copier manuellement les paramètres.

Le problème Dockerfile

Les builds Docker de monorepo sont un aspect que les tutoriels négligent souvent. Le contexte de build correspond à la racine du répertoire. Chaque application dispose de son propre Dockerfile, mais doit pouvoir accéder à des packages partagés. L’ordre des instructions COPY adapté au cache constitue l’élément clé.

Le schéma qui s’est imposé :

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"]

D’abord copier chaque fichier package.json, puis l’installer, et enfin copier les sources. Docker réutilise ensuite la couche d’installation tant que les dépendances ne changent pas, et non à chaque modification des sources.

L’inconvénient : ajouter un package partagé signifie devoir mettre à jour les lignes COPY dans chaque Dockerfile qui l’utilise. Une liste de contrôle est utile, car l’omission d’une ligne provoque des échecs de build difficiles à diagnostiquer. Finalement, pnpm deploy crée un arbre de production autonome où les packages du workspace sont remplacés par leurs copies réelles, de sorte que l’image finale n’a besoin ni de pnpm ni de la structure complète du monorepo.

Gestion des versions avec des catalogues

Le dérive des versions représente la taxe silencieuse des monorepos : NestJS 11.0 dans une application et 11.1 dans une autre entraîne des bugs subtils au niveau d’un service donné.

Les catalogues pnpm définissent les plages partagées une seule fois dans 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

Les applications font ensuite référence à l’entrée du catalogue :

{
  "dependencies": {
    "@nestjs/common": "catalog:nestjs"
  },
  "devDependencies": {
    "vitest": "catalog:testing"
  }
}

Mettre à jour NestJS partout ne nécessite plus qu’une modification dans l’espace de travail ainsi que l’exécution de pnpm install. Le catalogue ne contient que les dépendances partagées par deux applications ou plus ; une version de Mongoose utilisée par un seul service peut rester locale tant qu’elle ne dépasse pas les limites définies.

Les catalogues modifient également la manière dont les mises à jour sont examinées. Une mise à niveau de NestJS devient alors une différence au niveau de l’espace de travail plutôt qu’un ensemble dispersé de fichiers de package, ce qui permet aux revues de code de se concentrer sur les notes de version et les changements perturbateurs plutôt que sur le fait que chaque application ait été mise à jour de manière cohérente.

Ce que j’ai mal compris

Séparer les types et les outils utilitaires constituait une architecture prématurée. Les fusionner plus tard a entraîné des mises à jour d’import massives que un seul package aurait pu éviter.

Débutalement, le système CI était trop complexe : des workflows GitHub Actions distincts par application avec des filtres de chemin afin que les modifications ne touchent pas l’API principale. Les changements apportés aux packages partagés devaient être diffusés à tous les utilisateurs, et ces filtres entraînaient plus d’itérations qu’ils n’en économisaient. Un seul workflow de PR qui compile tout, accéléré par le cache Turborepo, s’est avéré plus simple.

Ce que j’ai bien fait

Le mécanisme de résolution stricte a permis de détecter une dépendance non déclarée réelle lors de la migration — le genre de problème qui apparaîtrait normalement des mois plus tard.

Les packages partagés restent minimalistes. L’infrastructure facilite le partage ; la discipline empêche que du code spécifique à une application ne se retrouve dans le dossier common. Quelques centaines de lignes de code réellement partagé constituent même une fonctionnalité positive.

L’installation complexe du Dockerfile a porté ses fruits : chaque service s’build seul, ne télécharge que les packages nécessaires à son espace de travail et génère une image de production compacte. Lorsqu’une troisième API ainsi qu’un tableau de bord administratif se sont ajoutés à six packages partagés, intégrer un nouveau service consistait principalement en une réutilisation des templates existants pour Docker, CI et telemétrie.

Évitez le monorepo pour un service unique si aucun autre service n’est prévu. Lorsqu’un deuxième service devient inévitable et que des types sont déjà copiés entre les repos, le passage au monorepo est généralement tardif. Le coût de la migration est initial ; les avantages apparaissent dès que l’un des enums partagés change et que tous les consommateurs le mettent à jour en une seule mise à jour.

Un monorepo ne crée pas une bonne architecture ; il rend les bonnes pratiques plus faciles à reproduire et les mauvaises plus difficiles à cacher.

D’un point de vue opérationnel, la plus grande victoire durable a été la prévisibilité pendant la phase de croissance. Les enums partagés ont cessé de varier. Les champs de télémétrie étaient cohérents d’une API à l’autre. Les images Docker sont restées légères car pnpm deploy éliminait les structures de travail temporaires. Les coûts restants — la modification fréquente des lignes COPY dans le Dockerfile et la tentation de partager trop d’informations — étaient gérables grâce à des listes de contrôle et à des limites bien définies pour les packages, plutôt qu’en modifiant à nouveau la topologie du répertoire.

Si une refonte devait commencer aujourd’hui, la même stack resterait la solution par défaut pour un système de cette taille : pnpm pour des installations strictes, Turborepo pour la gestion des graphes de tâches, des catalogues pour les versions partagées, ainsi qu’un package common délibérément minimal. La différence consisterait à partir d’un seul package partagé au lieu de deux, et d’un seul flux de travail CI plutôt que de plusieurs.