Aller au contenu

Note d'ingénierie

Le monorepo pnpm + Turborepo que j'utilise sur cinq produits

Par Alexandre Kaczor4 min de lecture
  • Monorepo
  • pnpm
  • Turborepo
  • TypeScript
Illustration de l'article « Le monorepo pnpm + Turborepo que j'utilise sur cinq produits »

Une structure de dépôt reprise sur cinq produits : la frontière entre applications et paquets, ce qui se partage vraiment, et les trois erreurs les plus chères.

Mes cinq produits partagent la même structure de dépôt. Ce n'est pas de l'uniformité pour l'uniformité : c'est ce qui me permet de passer de l'un à l'autre sans réapprendre où se trouvent les choses, et de porter un correctif d'un projet à l'autre en le reconnaissant du premier coup d'œil.

La structure

apps/
  web/        # site public — Next.js, App Router
  admin/      # back-office — Next.js
  api/        # API — NestJS + Prisma
packages/
  shared/     # contrats partagés : types, schémas de validation
  config/     # eslint, tsconfig, prettier

La règle de partition tient en une phrase : apps/ contient ce qui se déploie, packages/ contient ce qui se consomme. Une application n'importe jamais une autre application. Tout ce qui doit circuler entre elles passe par un paquet.

Ce qui mérite vraiment d'être partagé

La tentation du monorepo est de tout mutualiser. C'est la meilleure façon de fabriquer un couplage que plus personne n'ose défaire. En pratique, seules deux catégories le méritent.

Les contrats. Les types qui décrivent ce que l'API renvoie et ce que le front consomme. C'est le partage le plus rentable du monorepo : renommer un champ côté API casse la compilation du front immédiatement, au lieu de casser la production trois semaines plus tard. C'est un test d'intégration gratuit, exécuté à chaque frappe.

La configuration d'outillage. ESLint, TypeScript, Prettier. Sans mutualisation, les règles divergent, et la divergence est invisible jusqu'au jour où un fichier déplacé d'une application à l'autre déclenche quarante erreurs.

Ce qui ne mérite pas d'être partagé, en revanche : les composants d'interface, tant qu'il n'y en a pas au moins trois consommateurs réels. Un paquet ui créé pour deux applications produit surtout des composants à rallonge, criblés de props conditionnelles pour absorber les différences entre deux cas d'usage qui n'avaient aucune raison d'être identiques.

Le point qui n'est pas négociable : le pipeline de tâches

Sans Turborepo, build à la racine se contente de lancer les builds les uns après les autres, dans l'ordre alphabétique — et le front se construit avant le paquet de types dont il dépend.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {},
    "dev": { "cache": false, "persistent": true }
  }
}

Trois détails y font tout le travail :

  • dependsOn: ["^build"] — le curseur signifie « les dépendances d'abord ». C'est ce qui garantit l'ordre correct sans qu'on l'écrive nulle part.
  • outputs — sans cette déclaration, le cache ne peut rien restaurer, puisqu'il ne sait pas quoi conserver. Une tâche sans outputs annule le principal bénéfice de l'outil.
  • cache: false sur dev — mettre en cache un serveur de développement n'a aucun sens, et persistent indique à Turborepo qu'il ne doit pas attendre la fin d'une tâche qui ne se termine jamais.

Trois erreurs qui coûtent cher

1. Croire que pnpm et npm sont interchangeables

pnpm ne remonte pas les dépendances transitives à la racine de node_modules. C'est une qualité — un paquet ne peut plus importer ce qu'il n'a pas déclaré — mais elle transforme en erreur de compilation ce qui « marchait » ailleurs. Le remède n'est pas de désactiver l'isolation : c'est de déclarer la dépendance manquante, qui était réellement manquante.

2. Oublier workspace:*

"dependencies": { "@monprojet/shared": "workspace:*" }

Sans ce protocole, l'installation ira chercher un paquet du même nom sur le registre public. Au mieux il n'existe pas et l'installation échoue ; au pire il existe, appartient à quelqu'un d'autre, et s'installe sans un mot.

3. Construire les images Docker sans tenir compte du monorepo

C'est le point qui surprend le plus au moment du déploiement. Le Dockerfile d'une application ne peut pas se contenter de son propre dossier : il lui faut le pnpm-lock.yaml de la racine et les paquets dont elle dépend. Le contexte de build est donc la racine du dépôt, pas le dossier de l'application — et sans .dockerignore sérieux, on envoie au démon Docker l'intégralité des node_modules de tout le monorepo à chaque construction.

Ce que ça apporte réellement

Le bénéfice le plus tangible n'est pas la performance de build. C'est qu'un changement d'interface se propage à la compilation, pas en production.

Le second, moins mesurable et pourtant plus décisif quand on exploite plusieurs produits seul : la charge mentale. Cinq dépôts avec cinq structures différentes, ce sont cinq contextes à recharger. Cinq dépôts avec la même structure, c'en est un seul.