AccueilClientsExpertisesBlogOpen SourceContact

27 novembre 2024

Comment mettre en place un monorepo avec Turborepo et Yarn ?

13 minutes de lecture

Comment mettre en place un monorepo avec Turborepo et Yarn ?
🇺🇸 This post is also available in english

Dans l'écosystème JavaScript, il est courant de travailler avec plusieurs packages pour un projet, ou même plusieurs projets gravitant autour de plusieurs packages. C'est une situation que l'on retrouve régulièrement dans les projets open source, ou dans les entreprises ayant des équipes travaillant sur des projets différents. Cela peut vite devenir compliqué à gérer, surtout si vous avez plusieurs projets qui partagent des dépendances. Dans cet article, nous allons voir comment faciliter la gestion de vos packages avec un monorepo Turbo et les workspaces Yarn.

Histoire des monorepos

Monorepo

Monolithes

Historiquement, les cycles de vie des projets JavaScript étaient gérés en monolithes, avec un dépôt unique pour l'ensemble du projet. Cette approche a montré ses limites, notamment en termes de maintenabilité, de performances et de collaboration.

Google affirmait, il y a quelques années maintenant, utiliser des monolithes. Why Google Stores Billions of Lines of Code in a Single Repository

Multi-repos

S'en est suivi, tournant 2010, avec l'apparition de npm : les multi-repos. Une approche qui consiste à séparer chaque package dans un dépôt dédié. Cela a permis de résoudre certains problèmes d'échelle, mais en a créé d'autres, notamment en termes de gestion des dépendances et de cohérence des versions. Chaque package devait attendre que ses dépendances soient publiées avant de pouvoir les utiliser. Poussée par le succès des architectures micro-services, cette approche est encore beaucoup utilisée aujourd'hui.

Monorepos

Dans les années 2015, les package managers ont commencé à intégrer nativement des fonctionnalités de workspaces, permettant de gérer plusieurs packages dans un même dépôt. C'est le cas de yarn et de npm entre autres, ce qui a permis de démocratiser les monorepos. C'est une approche très populaire auprès des mainteneurs de projets open-source, car elle permet de gérer plusieurs packages dans un même dépôt, tout en gardant une cohérence des versions et des dépendances. Le souci qui peut apparaître avec cette approche, c'est la lenteur des installations et des builds, surtout à grande échelle. On remarque rapidement des similitudes avec les deux approches précédentes, le côté centralisé des monolithes et l'autonomie des packages des multi-repos.

À partir de 2018, nous voyons apparaître des outils comme Nx ou encore Turbo, qui permettent de gérer les monorepos de manière plus efficace. Ces outils permettent de mettre en cache les dépendances partagées et l'orchestration de scripts (build, test, etc), d'améliorer les performances et de faciliter la gestion des monorepos. Nous reviendrons plus en détail sur Turbo dans la suite de cet article.

Pourquoi un monorepo ?

Vous l'aurez compris, chaque approche a ses avantages et ses inconvénients. Le choix d'un monorepo dépendra de votre contexte, de vos besoins et de vos contraintes. Voici quelques avantages à utiliser un monorepo :

  • Collaboration améliorée : les développeurs peuvent partager du code entre les packages et travailler simultanément sur plusieurs packages.
  • Gestion centralisée des dépendances : cela assure une cohérence dans les versions et facilite la gestion des dépendances partagées.
  • Maintenance simplifiée : la maintenance des packages, la mise à jour des dépendances et la gestion des versions sont plus efficaces.
  • Déploiement indépendant : chaque package peut être déployé séparément.

Mettre en place un monorepo avec Turbo et Yarn

Maintenant que nous avons vu les avantages d'un monorepo, nous allons voir comment en mettre un en place avec Turbo et Yarn. Au fur et à mesure de l'article, nous approfondirons les concepts clés de chaque outil et nous verrons comment les utiliser ensemble. L'idée est de pouvoir avoir une compréhension globale de l'architecture d'un monorepo et de pouvoir l'adapter à votre contexte.

Pour l'exemple nous mettrons en place un monorepo contenant une application React, une application Next.js, un package de composants React et une CLI (simple console.log). La structure de notre projet est la suivante :

monorepo
├── apps
│   ├── app-react # @premieroctet/app-react
│   │   └── package.json
│   └── app-nextjs # @premieroctet/app-nextjs
│       └── package.json
├── packages
│   ├── components # @premieroctet/components
│   │   └── package.json
│   └── cli # @premieroctet/cli
│       └── package.json
├── package.json
└── yarn.lock

Le package manager Yarn

Dans la suite de cet article, nous utiliserons Yarn 2+ pour gérer les dépendances de notre monorepo. Pour vos monorepos, je vous recommande d'utiliser Yarn 2+ car il y a eu beaucoup d'améliorations par rapport à Yarn 1.x notamment autour des workspaces.

Initialisation du monorepo

Nous allons donc commencer par initialiser notre monorepo avec Yarn. Pour cela, nous allons créer un nouveau dossier et initialiser un nouveau projet Node.

mkdir monorepo
cd monorepo
yarn set version berry # Use last version of Yarn
yarn init -w

L'option -w permet d'initialiser un workspace dans le fichier package.json. Elle permet également de définir le dossier packages comme dossier par défaut pour les packages.

Dans le fichier package.json créé, nous allons ajouter les workspaces pour définir les dossiers apps et packages comme workspaces.

package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "packageManager": "yarn@4.5.1"
}

Les workspaces

On crée donc les dossiers apps et packages (ce dernier étant déjà créé par Yarn) pour y stocker nos applications et nos packages. Pour les applications React et Next.js, nous allons utiliser respectivement les commandes create-react-app et create-next-app pour les initialiser.

cd monorepo/apps
yarn create react-app app-react
yarn create next-app app-next

Pour les packages components et cli, nous allons créer un dossier pour chaque package et initialiser un nouveau projet Node.

cd monorepo/packages
mkdir components
mkdir cli
cd components
yarn init -y
cd ../cli
yarn init -y

Voilà toutes nos applications et packages sont initialisés. Nous allons, avant de poursuivre, nommer ces projets en les préfixant avec un nom de workspace (ici avec @premieroctet) dans le fichier package.json pour les distinguer de leurs noms dans l'architecture de fichiers et mieux les identifier.

Maintenant que nous avons renommé nos projets, nous allons faire un premier yarn install à la racine de notre projet pour installer les dépendances de tous les packages. On constate que Yarn crée des liens symboliques des projets qui font partie du workspaces. Et que le node_modules est à la racine du monorepo.

Yarn workspaces node_modules à la racine

Pour réduire le nombre de dépendances installées, Yarn 2+ utilise un cache global pour stocker les dépendances partagées entre les packages. Cela permet de réduire le temps d'installation des dépendances et de réduire l'espace disque utilisé.

Gestion des conflits de dépendances

Vu qu'il n'y a qu'un node_modules à la racine, comment gérer les conflits de dépendances entre les packages ?

Si l'on reprend notre exemple, imaginons que l'application @premieroctet/app-react utilise react@18 et que l'application @premieroctet/app-next utilise react@19. Alors Yarn va installer react@19 à la racine du monorepo et créer un node_modules dans l'application @premieroctet/app-react avec react@18. Cela permet de gérer les conflits de dépendances entre les packages. Dans ce cas-là, c'est l'ordre topologique qui détermine la priorité des dépendances. Cependant, si un troisième package utilise react@18, alors Yarn va installer react@18 à la racine du monorepo et créer un node_modules dans le package @premieroctet/app-next avec react@19 vu qu'il est "minoritaire".

Utiliser les composants dans les applications

Maintenant que nous avons initialisé nos applications et nos packages, nous allons voir comment les utiliser dans nos applications.

Commençons par ajouter des composants dans notre package @premieroctet/components. Pour cela, nous allons créer un composant Button dans le fichier packages/components/src/Button.js.

Button.jsx
import React from 'react'

const Button = ({ children }) => {
  return <button>{children}</button>
}

export default Button

Puis définir un point d'entrée dans le fichier packages/components/src/index.js.

index.js
export { default as Button } from './Button'

Initialiser typescript dans le package @premieroctet/components afin de pouvoir utiliser les types dans les applications.

cd monorepo/packages/components
yarn add -D typescript
npx tsc --init

Ajouter les options du compilateur dans le fichier packages/components/tsconfig.json.

tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "outDir": "dist",
    ...
  }
}

Et enfin définir le point d'entrée dans le fichier packages/components/package.json et ajouter un script pour générer le dossier dist.

package.json
{
  "name": "@premieroctet/components",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "dev": "tsc -w",
    "build": "tsc"
  }
  ...
}
cd monorepo/packages/components
yarn build

Nous pouvons désormais ajouter les dépendances de nos packages dans nos applications.

cd monorepo/apps/app-react
yarn add @premieroctet/components

Dans le fichier apps/app-react/package.json on peut voir que Yarn a ajouté une dépendance à notre package @premieroctet/components.

package.json
  "@premieroctet/components": "workspace:^"

Le caractère générique permet de résoudre la version du package lors du yarn npm publish ou yarn pack. Vous pouvez en utiliser d'autres en fonction de vos besoins, voir la documentation.

On peut désormais importer les composants de notre package @premieroctet/components dans notre application @premieroctet/app-react. Pour l'instant @premieroctet/components ne possède pas de peerDependencies, si c'était le cas il faudrait que l'application @premieroctet/app-react les ajoute dans son package.json ou quelle possède déjà les dépendances qui matchent les peerDependencies de @premieroctet/components.

On peut aussi utiliser les scripts de notre package @premieroctet/cli dans nos applications. Pour cela, nous allons ajouter une commande cli dans le fichier packages/cli/src/index.js.

index.js
#!/usr/bin/env node

console.log('Hello from @premieroctet/cli')

Puis définir le point d'entrée dans le fichier packages/cli/package.json et ajouter un script pour lancer la commande cli.

package.json
{
  "name": "@premieroctet/cli",
  "version": "1.0.0",
  "bin": {
    "cli": "dist/index.js"
  },
  "scripts": {
    "dev": "tsc -w",
    "build": "tsc"
  }
  ...
}
cd monorepo/packages/cli
yarn build

On peut désormais utiliser la commande cli de notre package @premieroctet/cli dans nos applications.

cd monorepo/apps/app-react
yarn @premieroctet/cli cli

Et même ajouter des scripts dans le package.json de nos applications pour lancer les scripts de nos packages.

cd monorepo/apps/app-react
yarn add -D @premieroctet/cli
package.json
{
  "scripts": {
    "cli": "cli"
  }
  ...
}

Les commandes yarn workspaces

Avec la commande yarn workspaces, on peut exécuter des commandes dans tous les workspaces ou dans un workspace spécifique.

yarn workspaces foreach --all run build
yarn workspace @premieroctet/components run build

L'option -pt permet de lancer les commandes après que ses dépendances aient elles-mêmes lancé la commande définie.

Vous pouvez retrouver toutes les commandes disponibles dans la documentation de Yarn 2+.

Les limites des workspaces yarn

À ce stade de notre monorepo, nous avons utilisé seulement les workspaces de Yarn pour gérer nos packages. Cependant, il existe des limites à cette approche, la plus importante étant la performance des commandes comme yarn build ou yarn test qui sont exécutées pour chaque package. Cela peut vite devenir un problème si vous avez beaucoup de packages ou si les commandes sont longues à exécuter.

Une seconde limite est l'orchestration des commandes entre les packages. Par exemple, si vous avez une commande qui dépend d'une autre commande dans un autre package, il faudra gérer manuellement l'ordre d'exécution des commandes.

Nous allons donc voir comment Turbo peut nous aider à résoudre ces problèmes.

Turborepo

Installation

Commençons par installer Turbo dans notre monorepo à la racine du workspace. Et créer un fichier de configuration turbo.json pour définir les tâches.

cd monorepo
yarn add -D turbo
turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {}
}

Pour plus d'informations sur l'installation de Turbo, vous pouvez consulter la documentation.

Orchestration de tâches

Dans notre exemple nous pouvons considérer les tâches suivantes :

  • build : pour builder tous les packages - tous les projets
  • dev: pour lancer les applications en mode développement - app-react et app-next
  • deploy: pour déployer les applications - app-react et app-next
  • cli: pour lancer le script du package @premieroctet/cli - app-react
  • publish: pour la publication des packages - component et cli

L'idée est de pouvoir lancer les commandes sans avoir à se déplacer dans chaque projet et de gérer l'ordre d'exécution des commandes.

Dans le fichier de configuration turbo.json, nous allons définir les tâches pour chaque commande.

turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "deploy": {
      "dependsOn": ["^build"]
    },
    "@premieroctet/app-react#cli": {
      "dependsOn": ["@premieroctet/cli#build"]
    },
    "publish": {
      "dependsOn": ["^build"]
    }
  }
}
  • dependsOn permet de définir les dépendances entre les tâches, ici on définit que la tâche build doit être exécutée après les builds des dépendances.
  • outputs permet de définir les fichiers générés par la tâche, ces fichiers seront utilisés pour déterminer si la tâche doit être exécutée ou si le cache peut être utilisé.
  • cache permet de désactiver le cache pour la tâche.
  • persistent permet de garder la tâche en cours d'exécution.

Dans le cas de dépendances cycliques, il ne sera pas possible d'utiliser le dependsOn pour définir l'ordre d'exécution des tâches.

Pour les tâches deploy et publish, qui ne sont pas définis dans l'ensemble des packages, Turbo n'exécutera que les tâches définies.

Dans le cas de la tâche cli, on peut préciser le package sur lequel on veut exécuter la tâche. On définit aussi la dépendance avec la tâche build du package @premieroctet/cli.

Pour lancer les tâches, on utilise la commande turbo suivi du nom de la tâche.

yarn turbo run build

Il est possible de préciser les packages sur lesquels on veut exécuter les tâches en utilisant le flag --filter ou en préfixant le nom de la tâche avec le nom du package (e.g. @premieroctet/app-react#dev). Très pratique lorsqu'on veut réduire le nombre de tâches à exécuter dans les intégrations continues ou lorsqu'on veut tester une tâche sur un package spécifique. Voir la documentation.

Pour le confort des développeurs, on peut ajouter des scripts dans le package.json pour lancer les tâches Turbo.

package.json
{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "deploy": "turbo run deploy",
    "cli": "turbo run @premieroctet/app-react#cli",
    "publish": "turbo run publish"
  }
  ...
}

Le cache de Turbo

Pour optimiser les temps d'exécution, Turbo s'appuie sur un système de cache pour ne pas reproduire les tâches déjà exécutées lorsque les outputs n'ont pas changé. Cela permet de gagner en performances lors des builds et des tests. Le cache est stocké dans le dossier .turbo à la racine du workspace.

Il est possible dans le cadre de gros monorepos de stocker le cache sur un serveur dédié pour réduire le temps de partage du cache entre les développeurs. Voir Remote Caching.

Conseils pour un monorepo réussi

Vous l'aurez compris, la gestion d'un monorepo peut vite devenir complexe si elle n'est pas bien maîtrisée. Voici quelques points à prendre en compte pour éviter les erreurs et faciliter la gestion de votre monorepo :

  • Éviter les dépendances cycliques : cela peut vite devenir un casse-tête à gérer, surtout si vous avez beaucoup de packages, et même bloquer vos tâches Turbo.

  • ⚠️ Définir explicitement les dépendances : il est important de définir les dépendances de vos packages dans le package.json pour éviter les conflits de dépendances. Principalement si vous utilisez les monorepos pour publier des packages, comme yarn workspaces centralise les dépendances, il est possible que des dépendances (explicitement définies dans un package-a) soient présentes dans le node_modules et par conséquent utilisables dans un autre package (package-b) sans être définies dans le package.json de package-b. Vous n'aurez pas d'erreurs de compilation dans votre workspace, cependant si une personne utilise package-b dans un autre projet, il y aura des erreurs de compilation car les dépendances seront manquantes.

  • Aligner les versions des dépendances : pour réduire la taille de vos node_modules et éviter les conflits de dépendances, il est préférable dans la mesure du possible d'aligner les versions des dépendances entre vos packages.

  • Utiliser les peerDependencies : si vous avez des dépendances partagées entre vos packages, il est préférable de les définir dans les peerDependencies pour éviter les conflits de dépendances. Dans notre exemple, une bonne pratique serait de définir react et react-dom dans les peerDependencies de @premieroctet/components. On pourra ainsi avoir une seule version de react et react-dom pour toutes nos applications et un warning pendant l'installation si l'application ne possède pas les dépendances nécessaires. Attention cependant, les peerDependencies ne sont pas installées automatiquement, si votre workspace contient déjà une version de vos peerDependencies c'est celle-ci qui sera utilisée.

  • BONUS - Cloner une partie du monorepo : si vous avez un gros monorepo versionné et que vous voulez travailler sur une partie du monorepo, vous pouvez utiliser la commande git clone --depth=1 <url> pour cloner seulement le dernier commit de votre monorepo. Si vous voulez approfondir cette technique, je vous invite à lire l'article : Get up to speed with partial clone and shallow clone.

Conclusion

Vous l'aurez compris la fusion des monorepos Turbo et des workspaces Yarn est une solution très puissante pour gérer vos projets. Cela permet de garder une cohérence des versions et des dépendances, de faciliter la maintenance et le déploiement de vos packages. Cependant, il est important de bien maîtriser les outils que vous utilisez et de suivre les bonnes pratiques pour éviter les erreurs et faciliter la gestion de votre monorepo.

En conclusion si vous avez des projets qui partagent des dépendances, ou si vous avez des équipes qui travaillent sur des projets différents, je vous recommande d'utiliser un monorepo avec Turbo et les workspaces Yarn. Cela vous permettra de gagner en performances, en maintenabilité et en améliorera la collaboration.

👋

18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub
X (Twitter)
Flux RSS

Naviguez à vue