Gestion des dépendances dans un monorepo : visualisez avant qu'il ne soit trop tard
Les monorepos sont partout. Google, Meta, Microsoft, Uber, Airbnb — tous utilisent des monorepos à grande échelle. L'écosystème JavaScript les a adoptés aussi, avec des outils comme Nx, Turborepo et pnpm workspaces qui rendent plus facile que jamais la gestion de multiples packages dans un seul dépôt.
Les avantages sont bien documentés : partage de code sans publier de packages, commits atomiques à travers plusieurs projets, outillage unifié et CI, et gestion simplifiée des dépendances externes. Mais il y a un défi qui s'insinue dans chaque équipe monorepo : la gestion des dépendances internes.
Les dépendances externes (celles dans package.json) sont versionnées, visibles et gérées par votre gestionnaire de packages. Les dépendances internes — les relations d'import entre vos propres packages — sont invisibles, non versionnées et souvent non gérées. Jusqu'à ce qu'elles deviennent un problème.
Cet article parle de ce problème, et de ce qu'il faut faire pour y remédier.
Pourquoi les dépendances internes deviennent un bazar
Dans un petit monorepo avec 3 à 5 packages, les dépendances sont évidentes. Tout le monde sait que @app/ui dépend de @app/shared, et que @app/api dépend de @app/db. Vous pouvez garder le graphe de dépendances en tête.
À 15-20 packages, ce n'est plus possible. Et c'est là que les choses commencent à déraper.
L'enchevêtrement progressif
Considérons un monorepo qui démarre avec des frontières propres :
packages/
ui/ → dépend de : shared
api/ → dépend de : db, shared
db/ → dépend de : shared
shared/ → dépend de : rien
auth/ → dépend de : db, shared
email/ → dépend de : shared
Six mois plus tard, un développeur travaillant sur le package email a besoin d'une fonction utilitaire de auth. Au lieu de l'extraire dans shared, il ajoute une dépendance directe :
// packages/email/src/templates.ts
import { formatUserName } from '@app/auth/utils';
Cet import est légal — pnpm le résout, TypeScript compile, les tests passent. Mais il crée un couplage caché : email dépend maintenant de auth, qui dépend de db. Un changement dans db affecte potentiellement l'envoi d'emails.
Une semaine plus tard, quelqu'un dans auth a besoin d'un template d'email. Il importe depuis email :
// packages/auth/src/password-reset.ts
import { resetPasswordTemplate } from '@app/email/templates';
Maintenant vous avez une dépendance circulaire : auth → email → auth. Les deux packages sont enchevêtrés. Vous ne pouvez pas construire, tester ou déployer l'un sans l'autre. Les frontières de « package » sont une illusion.
Multipliez cela par 20 packages et 12 mois, et vous avez un graphe de dépendances que personne ne comprend.
Anti-patterns courants
En analysant des centaines de monorepos, certains anti-patterns apparaissent de façon récurrente :
Le God Package : Un package dont tout dépend. Il commence comme shared ou common et grandit pour contenir des centaines d'utilitaires, de types, de helpers et de constantes. Chaque changement dans ce package déclenche des rebuilds de tous les autres packages. Il devient le goulot d'étranglement — le seul package que personne ne veut toucher parce que le rayon d'explosion est énorme.
Les dépendances circulaires entre features : Des packages de fonctionnalités qui devraient être indépendants finissent par importer les uns des autres. billing importe de projects pour obtenir le nombre de projets. projects importe de billing pour vérifier les limites du plan. notifications importe des deux pour savoir quoi notifier. Le résultat est un cluster fortement couplé qui annule l'intérêt d'avoir des packages séparés.
Les chaînes de dépendances profondes : Le package A dépend de B, B dépend de C, C dépend de D, D dépend de E. Un changement dans E se propage à travers quatre niveaux. Les temps de build augmentent car le système de build ne peut pas paralléliser ces packages. Et le développeur qui modifie E n'a aucune idée qu'il affecte A.
Les dépendances fantômes : Le package A importe du package C, mais ne déclare qu'une dépendance vers le package B (qui se trouve dépendre de C). Cela fonctionne parce que pnpm ou Yarn hoist C vers un emplacement où A peut le trouver. Mais c'est fragile — un changement de version dans B pourrait retirer C du chemin de résolution de A.
Pourquoi les outils de build ne résolvent pas ce problème
Les outils de build pour monorepo sont excellents dans ce qu'ils font. Le cache de calcul de Nx, l'exécution parallèle de Turborepo et le protocole workspace de pnpm sont tous véritablement utiles. Mais ils opèrent au niveau des tâches et du build, pas au niveau de l'architecture.
Ce que les outils de build vous montrent
Nx fournit un project graph qui montre quels packages dépendent de quels autres :
npx nx graph
Cela montre les dépendances au niveau package. Si @app/api dépend de @app/db, vous voyez cette connexion. C'est utile pour comprendre l'ordre de build et les projets affectés.
Turborepo fournit un task graph similaire :
npx turbo run build --graph
Cela montre l'ordre d'exécution des tâches à travers les packages. Il répond à « qu'est-ce qui doit se construire avant quoi ? » — ce qui est une question d'orchestration de build.
Ce que les outils de build ne vous montrent pas
Aucun de ces outils ne vous montre les relations d'import réelles au sein des packages et entre eux, au niveau fichier ou module. Ils savent que le package A dépend du package B parce que package.json le dit. Mais ils ne savent pas :
- Quels fichiers spécifiques dans A importent de B
- Si la dépendance est une connexion fine (un import) ou épaisse (des dizaines d'imports à travers de nombreux fichiers)
- S'il y a des dépendances circulaires implicites au niveau fichier qui n'apparaissent pas au niveau package
- À quel point chaque package est cohésif en interne
- Si la structure interne d'un package respecte des patterns architecturaux
C'est le fossé entre la gestion des dépendances au niveau build et la gestion des dépendances au niveau architecture. Les outils de build répondent à « comment construire cela efficacement ? » Les outils d'architecture répondent à « cette structure est-elle saine ? »
Visualiser le vrai graphe de dépendances
Pour gérer efficacement les dépendances internes, vous devez les voir — pas au niveau package, mais au niveau module et fichier.
Ce qu'il faut chercher
Quand vous visualisez le graphe de dépendances de votre monorepo, cherchez ces signaux :
Les hotspots en étoile entrante : Les modules importés par un nombre disproportionné d'autres modules. Si un module a 40 connexions entrantes, c'est un aimant à couplage. Tout changement à ce module affecte 40 autres endroits.
Les clusters : Des groupes de modules qui sont fortement connectés entre eux mais faiblement connectés au reste. Ils pourraient être candidats pour un nouveau package, ou ils pourraient indiquer des frontières cachées au sein d'un package existant.
Les ponts inter-packages : Des fichiers qui sont la seule connexion entre deux packages. Ce sont des fichiers à haut risque — s'ils changent, toute la relation entre deux packages change. Ce sont aussi des cibles de refactoring : peut-être que ce fichier devrait vivre dans un package partagé.
Les modules orphelins : Des fichiers qui existent mais ne sont importés par rien. C'est du code mort, qui augmente la charge cognitive et les temps de build sans apporter de valeur.
Les violations de direction des dépendances : Dans une architecture en couches, les dépendances devraient couler vers le bas (de l'UI vers la logique métier vers les données). Les dépendances ascendantes indiquent des violations architecturales qui érodent la structure en couches.
Utiliser ReposLens pour la visualisation de monorepo
ReposLens génère un graphe de dépendances interactif à partir de n'importe quel dépôt GitHub. Pour les monorepos, cette visualisation est particulièrement précieuse car elle montre les relations d'import réelles à travers les frontières de packages — les connexions que les fichiers package.json ne capturent pas.
Vous connectez votre dépôt monorepo, et en quelques secondes vous voyez :
- Chaque module à travers tous les packages
- Les relations d'import entre eux
- Les cycles de dépendances circulaires mis en évidence
- Les scores de couplage par module
- La forme architecturale globale
Cela vous donne une vue terrain de la structure interne de votre monorepo. Vous pourriez découvrir que vos packages « indépendants » sont en fait profondément enchevêtrés, ou qu'un package que vous pensiez central est en fait à peine utilisé.
La fonctionnalité d'analyse des PRs est particulièrement utile dans les monorepos. Quand quelqu'un ouvre une PR qui ajoute un import d'un package vers un autre, ReposLens le signale — vous permettant d'évaluer si cette nouvelle dépendance est intentionnelle et appropriée, ou si c'est le début d'un enchevêtrement.
Bonnes pratiques pour la gestion des dépendances en monorepo
Une gestion saine des dépendances en monorepo consiste à établir des règles, les rendre visibles et les appliquer automatiquement.
Définir des frontières de packages claires
Chaque package dans votre monorepo devrait avoir un objectif clair et une interface définie. Demandez-vous :
- Que fait ce package ? (Une phrase)
- Qui sont ses consommateurs ? (Quels autres packages importent de lui)
- Qu'expose-t-il ? (Son API publique)
- De quoi dépend-il ? (Ses propres dépendances)
Documentez cela dans un README.md à la racine de chaque package. Pas besoin d'être élaboré — quelques lignes qui décrivent la frontière :
# @app/billing
Gère la gestion des abonnements, le traitement des paiements et l'application des limites de plan.
## Consommateurs
- `@app/api` (endpoints de facturation)
- `@app/dashboard` (UI de facturation)
## Dépendances
- `@app/db` (accès base de données)
- `@app/shared` (utilitaires)
## Ne dépend PAS de
- `@app/projects` (utiliser les événements pour la communication inter-modules)
- `@app/notifications` (utiliser les événements, pas les imports directs)
La section « Ne dépend PAS de » est la plus importante. Elle rend la frontière explicite.
Établir des règles de direction des dépendances
Toutes les dépendances ne se valent pas. Certaines directions sont saines ; d'autres sont problématiques.
Une règle courante pour les monorepos :
Packages de features → Bibliothèques partagées ✅ (autorisé)
Bibliothèques partagées → Packages de features ❌ (interdit)
Packages de features → Packages de features ⚠️ (doit être justifié)
Les packages de features (billing, projects, notifications) devraient dépendre des bibliothèques partagées (db, shared, ui), pas les uns des autres. Quand les features doivent communiquer, utilisez des événements ou une couche de service partagée au lieu d'imports directs.
Utiliser des barrel files pour contrôler les exports
Chaque package devrait avoir une API publique explicite définie par son point d'entrée :
// packages/billing/src/index.ts
// API publique — ce sont les seules choses que les autres packages devraient importer
export { BillingService } from './services/billing-service';
export { checkPlanLimit } from './utils/plan-limits';
export type { Plan, Subscription } from './types';
// Tout le reste est interne et ne devrait PAS être importé directement
Combinez cela avec les alias de chemins TypeScript ou les exports de package.json pour empêcher les imports directs de fichiers :
// packages/billing/package.json
{
"name": "@app/billing",
"exports": {
".": "./src/index.ts"
}
}
Maintenant, import { something } from '@app/billing/src/internal/service' est une erreur TypeScript. Seule l'API publique est accessible.
Conduire des audits de dépendances réguliers
Planifiez une revue mensuelle du graphe de dépendances de votre monorepo. Cherchez :
- Nouvelles dépendances inter-packages : En a-t-on ajouté ce mois-ci ? Étaient-elles intentionnelles ?
- Dépendances circulaires : De nouveaux cycles sont-ils apparus ?
- Croissance du god package :
sharedgrossit-il ? Faut-il extraire quelque chose ? - Dépendances inutilisées : Y a-t-il des packages dont rien n'importe ?
- Tendances de couplage : Le couplage global augmente-t-il ou diminue-t-il ?
Cette revue n'a pas besoin d'être longue — 30 minutes avec l'équipe à regarder le graphe de dépendances suffisent pour repérer les tendances avant qu'elles ne deviennent des problèmes.
Automatiser l'application des frontières
Le moyen le plus efficace de maintenir des dépendances saines est de les appliquer automatiquement sur chaque PR.
Avec les frontières de modules Nx :
// nx.json ou project.json
{
"tags": ["scope:feature", "type:billing"]
}
// .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:feature",
"onlyDependOnLibsWithTags": ["scope:shared"]
},
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
}
}
Avec dependency-cruiser :
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-feature-to-feature",
severity: "error",
comment: "Les packages de features ne doivent pas dépendre directement d'autres packages de features",
from: { path: "^packages/(billing|projects|notifications|auth)/" },
to: { path: "^packages/(billing|projects|notifications|auth)/", pathNot: "$1" }
},
{
name: "no-shared-to-feature",
severity: "error",
comment: "Les packages partagés ne doivent pas dépendre des packages de features",
from: { path: "^packages/(shared|ui|db)/" },
to: { path: "^packages/(billing|projects|notifications|auth)/" }
}
]
};
Exécutez ces vérifications en CI. Si une PR viole une règle de frontière, le build échoue.
Utiliser les événements pour la communication inter-features
Quand les packages de features doivent communiquer, évitez les imports directs. Utilisez un pattern événementiel à la place :
// packages/shared/src/events.ts
export type AppEvent =
| { type: 'project.created'; data: { projectId: string; userId: string } }
| { type: 'subscription.changed'; data: { userId: string; plan: string } }
| { type: 'user.registered'; data: { userId: string; email: string } };
export type EventHandler<T extends AppEvent['type']> = (
event: Extract<AppEvent, { type: T }>
) => Promise<void>;
// packages/billing/src/handlers.ts
import type { EventHandler } from '@app/shared/events';
export const onProjectCreated: EventHandler<'project.created'> = async (event) => {
// Vérifier si l'utilisateur a atteint la limite de projets de son plan
await checkProjectLimit(event.data.userId);
};
// packages/notifications/src/handlers.ts
export const onProjectCreated: EventHandler<'project.created'> = async (event) => {
// Envoyer une notification à l'équipe
await sendTeamNotification(event.data.projectId);
};
Maintenant billing et notifications réagissent tous deux à la création de projet sans dépendre du package projects. La dépendance pointe vers shared (pour les définitions de types d'événements), pas latéralement entre features.
Un chemin de migration pratique
Si votre monorepo a déjà des dépendances enchevêtrées, voici une approche pas à pas pour les démêler.
Étape 1 : Cartographier l'état actuel
Générez un graphe de dépendances de votre monorepo. Utilisez l'outil qui convient à votre stack — le project graph de Nx, la visualisation de dependency-cruiser, ou ReposLens. L'objectif est de voir chaque dépendance inter-packages.
Vous découvrirez probablement des connexions dont vous n'aviez pas connaissance. C'est normal — et c'est exactement pourquoi cette étape est nécessaire.
Étape 2 : Identifier les pires contrevenants
Cherchez :
- Les packages avec le plus de dépendances entrantes (candidats au découpage)
- Les cycles de dépendances circulaires (les corrections les plus urgentes)
- Les dépendances inter-features (devraient être remplacées par des événements ou des abstractions partagées)
Priorisez les dépendances circulaires — elles sont les plus dommageables car elles rendent les packages impossibles à comprendre ou refactorer de manière isolée.
Étape 3 : Corriger un cycle à la fois
N'essayez pas de tout refactorer d'un coup. Prenez la plus petite dépendance circulaire et cassez-la :
- Identifiez les imports spécifiques qui créent le cycle
- Déterminez dans quelle direction la dépendance devrait couler
- Extrayez le code partagé dans un package partagé, ou remplacez les imports directs par un pattern événementiel
- Vérifiez que le cycle est cassé en relançant la vérification de dépendances
- Commitez et répétez
Étape 4 : Ajouter l'application automatique
Une fois les pires violations corrigées, ajoutez des vérifications automatisées qui empêchent les nouvelles. C'est l'étape la plus importante — sans application, l'état propre se dégradera de nouveau vers l'état enchevêtré en quelques mois.
Étape 5 : Surveiller dans le temps
Suivez vos métriques de dépendances mensuellement :
- Nombre de dépendances circulaires (cible : zéro)
- Nombre d'imports inter-features (cible : zéro ou en diminution)
- Taille du plus gros package (cible : stable ou en diminution)
- Score de couplage global (cible : stable ou en diminution)
Si une métrique tend dans la mauvaise direction, investiguez avant que cela ne s'accumule.
Le jeu long
La gestion des dépendances en monorepo n'est pas un nettoyage ponctuel. C'est une discipline continue, comme maintenir la couverture de tests ou garder la documentation à jour. Les équipes qui réussissent avec les monorepos à grande échelle sont celles qui :
- Visualisent leur graphe de dépendances régulièrement — pas seulement quand quelque chose casse
- Définissent des frontières claires entre les packages et les documentent
- Appliquent ces frontières automatiquement sur chaque PR
- Revoient l'architecture périodiquement pour détecter la dérive tôt
Les outils existent pour rendre tout cela pratique. Nx et Turborepo gèrent l'orchestration du build. dependency-cruiser gère l'application des règles. ReposLens gère la visualisation et les vérifications d'architecture au niveau PR. La partie difficile n'est pas l'outillage — c'est la discipline de les utiliser de manière cohérente.
Les dépendances internes de votre monorepo sont soit un atout (frontières claires, builds rapides, refactoring facile) soit un passif (couplage enchevêtré, builds lents, paralysie de peur de toucher quoi que ce soit). La différence réside dans le fait de les gérer délibérément ou de les laisser évoluer par accident.
Choisissez délibérément. Visualisez vos dépendances aujourd'hui, avant qu'il ne soit trop tard.