Un monorepo est un seul dépôt git qui contient plusieurs projets : par exemple la boutique, le panneau d’administration et les bibliothèques qu’ils partagent. Ce n’est pas un monolithe : chaque application est construite et déployée séparément. Seul le code vit ensemble, avec une seule version d’Angular et un seul historique de commits.
Le gain : un changement qui touche une bibliothèque et les deux applications qui l’utilisent arrive dans une seule PR, testée en bloc. Le coût : sans outillage, chaque PR reconstruit et reteste tout ; sans règles, chacun importe n’importe quoi. Cette page couvre les deux moitiés : le workspace avec la CLI Angular et ng-packagr, puis ce que Nx ajoute par-dessus — le graphe de projets, affected, le cache, et des frontières vérifiées au lint.
- Un monorepo réunit plusieurs applications et bibliothèques dans un seul dépôt, avec un seul jeu de versions de dépendances.
- Angular CLI gère nativement plusieurs projets dans un workspace (
ng generate library). Nx ajoute le graphe des dépendances, les frontières et le cache. - Une bibliothèque a une API publique : ce qui n’est pas exporté depuis son fichier d’entrée reste interne.
- « Affected » lance le build et les tests seulement pour ce qu’un changement a touché.
- La plupart du temps, un dossier suffit. Une bibliothèque se justifie quand elle est publiée ou utilisée par plusieurs applications.
« Dossier, bibliothèque ou paquet npm » est une décision d’architecture, pas de style. Elle détermine la vitesse à laquelle vous livrez un changement transversal et la facilité avec laquelle vous pouvez casser quelque chose sans le remarquer. La documentation d’Angular le dit sans détour : une bibliothèque vous oblige à découpler le code de la logique de l’application, mais elle coûte du temps de maintenance — et cela devient rentable quand plusieurs applications l’utilisent.
Monorepo, polyrepo, monolithe
Les trois modèles diffèrent surtout par le coût d’un changement transversal. Imaginons que le modèle Product reçoive un nouveau champ, utilisé par la bibliothèque d’UI et par les deux applications :
| Modèle | À quoi cela ressemble | Modifier « Product » | Le prix |
|---|---|---|---|
| Monolithe | Une application, un déploiement, des dossiers par domaine | 1 PR, trivial | Ne convient pas à deux applications avec des rythmes de livraison différents |
| Polyrepo + npm | Chaque bibliothèque dans son propre dépôt, publiée avec une version | 3 PR ou plus : bibliothèque → publication → montée de version dans chaque application | Les versions divergent ; un correctif n’arrive en production que lorsque chaque équipe « y pense » |
| Monorepo | Applications + bibliothèques dans le même dépôt, consommées depuis le workspace | 1 PR atomique, testée avec tous ses consommateurs | La CI doit être intelligente (affected, cache) et les frontières doivent être imposées |
Un workspace de la CLI Angular a un seul package.json à la racine, donc tous les projets utilisent la même version d’Angular, de RxJS ou du CDK — ce que Nx appelle la single version policy. Un ng update met à jour tout le dépôt d’un coup. Le prix, c’est la coordination : aucune équipe ne prend de retard, mais aucune ne peut prendre d’avance. Si les équipes doivent vraiment livrer indépendamment, avec des rythmes et des versions différents, l’étape suivante est celle des micro frontends (qui, ironie du sort, vivent souvent eux aussi dans un monorepo).
Workspace multi-projets avec la CLI Angular
ng new crée par défaut une application à la racine. Pour un monorepo, vous partez d’un workspace vide (--no-create-application), et chaque ng generate application ou ng generate library ajoute un projet sous projects/ et une entrée dans angular.json. Une bibliothèque avec scope (@acme/ui) atterrit dans projects/acme/ui, et le nom du projet est le nom du paquet.
# 1. Workspace vide : aucune application à la racine (createApplication: false)
ng new acme --no-create-application
cd acme
# 2. Les projets atterrissent dans projects/ (newProjectRoot)
ng generate application shop
ng generate application admin
ng generate library @acme/ui # → projects/acme/ui, préfixe par défaut "lib"
# 3. Chaque commande reçoit le nom du projet
ng serve shop
ng build admin
ng test @acme/ui
ng build @acme/ui --watch # rebuild incrémental pendant que vous travaillez sur la bibliothèqueLa différence importante se trouve dans angular.json : les applications utilisent le builder @angular/build:application (esbuild), les bibliothèques utilisent @angular/build:ng-packagr, qui produit un paquet au format Angular Package Format et compile toujours en AOT. Pour les bibliothèques, la configuration par défaut est production.
{
"newProjectRoot": "projects",
"projects": {
"shop": {
"projectType": "application",
"root": "projects/shop",
"sourceRoot": "projects/shop/src",
"architect": {
"build": { "builder": "@angular/build:application" },
"serve": { "builder": "@angular/build:dev-server" }
}
},
"@acme/ui": {
"projectType": "library",
"root": "projects/acme/ui",
"sourceRoot": "projects/acme/ui/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"defaultConfiguration": "production",
"configurations": {
"production": { "tsConfig": "projects/acme/ui/tsconfig.lib.prod.json" },
"development": { "tsConfig": "projects/acme/ui/tsconfig.lib.json" }
}
}
}
}
}
}Évitez le préfixe ng- (il est réservé au framework). Pour les paquets publics, la convention est ngx- ; pour les internes, un scope d’entreprise (@acme/…) est le plus clair et convient à un registre privé.
Anatomie d’une bibliothèque
Quatre fichiers définissent une bibliothèque. Chacun répond à une question précise :
ng-package.json— où va le build (dest) et quel fichier contient l’API publique (lib.entryFile).package.json— le nom, la version et les dépendances. Les@angular/*vont danspeerDependencies: s’ils étaient dansdependencies, l’application pourrait recevoir une seconde copie d’Angular et planter à l’exécution."sideEffects": falseindique au bundler qu’il peut supprimer tout ce que vous n’importez pas.public-api.ts— le contrat. Ce qui n’est pas exporté ici est privé : vous pouvez donc le refactoriser sans prévenir personne.tsconfig.lib.prod.json—compilationMode: "partial": le format Ivy partiel est stable d’une version d’Angular à l’autre, et la compilation se termine dans le build de l’application, avec sa propre version du compilateur. D’où la règle : l’application doit utiliser la même version d’Angular que celle avec laquelle la bibliothèque a été construite, ou une version plus récente.
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/acme/ui",
"lib": {
"entryFile": "src/public-api.ts"
}
}Points d’entrée secondaires
L’Angular Package Format produit surtout un module ES plat par point d’entrée. Un bundler ne peut pas découper un module en plusieurs chunks : le point d’entrée est donc la plus petite granularité du lazy loading. C’est pourquoi Angular Material a un point d’entrée par composant (@angular/material/button, …/tabs). La seconde raison : vous isolez les API particulières, comme @acme/ui/testing, pour qu’elles n’arrivent pas en production par accident.
projects/acme/ui/
├── ng-package.json → @acme/ui (point d’entrée principal)
├── src/public-api.ts
├── button/
│ ├── ng-package.json → @acme/ui/button (point d’entrée secondaire)
│ └── src/public-api.ts
└── testing/
├── ng-package.json → @acme/ui/testing (harnesses, mocks pour les tests)
└── src/public-api.ts
ng-packagr découvre tout seul chaque ng-package.json ; vous n’enregistrez rien ailleurs.Une bibliothèque à usage unique (un client HTTP, un ensemble d’utilitaires) fonctionne très bien avec un seul point d’entrée — et @angular/core n’en a qu’un pour le runtime.
Consommation, build et publication
Dans un même workspace, vous n’avez rien à publier, mais vous devez construire d’abord la bibliothèque. ng generate library ajoute à tsconfig.json un alias qui pointe vers dist/, pas vers les sources. La raison : les applications et les bibliothèques ont des builds différents (esbuild vs ng-packagr), et le même TypeScript peut donner un JavaScript différent. La documentation demande explicitement que les alias pointent vers la bibliothèque construite. Pendant que vous travaillez, ng build @acme/ui --watch reconstruit de façon incrémentale.
{
"compilerOptions": {
"paths": {
// ajouté automatiquement par ng generate library — pointe vers le BUILD, pas vers les sources
"@acme/ui": ["./dist/acme/ui"],
// ajouté par vous, pour les points d’entrée secondaires
"@acme/ui/*": ["./dist/acme/ui/*"]
}
}
}Publier signifie un build en configuration production (celle par défaut) et un npm publish depuis dist — vous publiez le paquet généré, pas le dossier source.
# Build de distribution : la configuration production (celle par défaut pour les bibliothèques) → partial-Ivy
ng build @acme/ui
# Vous publiez le CONTENU de dist, pas le dossier source
cd dist/acme/ui
npm publish # ou : npm publish --registry https://npm.company.com
# Le consommateur (un autre dépôt) l’installe comme n’importe quel autre paquet
npm install @acme/ui@^1.4.0| Consommateur | Comment il obtient le code | Versions |
|---|---|---|
| Application dans le même dépôt | Un alias dans tsconfig, toujours la dernière version de la branche | Inutile : tout le monde est sur HEAD |
| Autre dépôt / autre client | npm install @acme/ui depuis le registre | Semver strict, un changelog, des peerDependencies correctes |
Le générateur @nx/angular:lib place l’alias dans tsconfig.base.json en pointant vers les sources (libs/…/src/index.ts) : la bibliothèque est compilée avec l’application, sans étape de build séparée. Vous n’ajoutez son propre build (--buildable / --publishable) que pour les bibliothèques que vous publiez ou pour des builds incrémentaux. Ne mélangez pas les deux styles dans un même workspace sans savoir pourquoi.
Nx : le graphe et les frontières
Nx utilise les mêmes builders Angular, mais garde la configuration de chaque projet dans son propre project.json et construit un graphe de projets en analysant les imports. Tout le reste repose sur ce graphe : nx graph vous l’affiche, affected le parcourt à l’envers, et la règle de lint le vérifie à chaque import. Dans un workspace Angular CLI existant, vous commencez par npx nx@latest init.
# Workspace Angular CLI existant → Nx (conserve la structure)
npx nx@latest init
# …ou déplacez les projets dans apps/ et libs/
npx nx@latest init --integrated
# Des bibliothèques avec des tags dès le premier jour
nx g @nx/angular:lib libs/shop/feature-cart --tags=scope:shop,type:feature
nx g @nx/angular:lib libs/shared/ui --tags=scope:shared,type:ui
nx g @nx/angular:lib libs/shared/ui --publishable --importPath=@acme/ui # uniquement si vous la publiez
# Le graphe de projets, interactif dans le navigateur
nx graph
nx graph --focus shop-feature-cart
# N’exécuter que ce qui est affecté par les changements par rapport à main
nx affected -t lint test build
nx affected -t build --graph # voir le graphe de tâches avant de lancer
nx run-many -t test # tout, sans filtreTypes de bibliothèques
Le guide Nx propose quatre types — les mêmes rôles que dans smart vs dumb et dans la structure des dossiers, sauf qu’ici la frontière est une bibliothèque avec une API publique :
| Type | Ce qu’elle contient | Peut dépendre de |
|---|---|---|
feature | Pages et composants conteneurs pour un cas d’usage ; généralement en lazy | tout type |
ui | Composants de présentation ; les données arrivent par des inputs, aucun service injecté | ui, util |
data-access | Communication avec le backend et état (stores) | data-access, util |
util | Fonctions pures, souvent sans code de framework | util |
acme/
├── nx.json ← cache, targetDefaults, defaultBase
├── tsconfig.base.json ← "@acme/shop/feature-cart": ["libs/shop/feature-cart/src/index.ts"]
├── apps/
│ ├── shop/project.json tags: scope:shop, type:app
│ └── admin/project.json tags: scope:admin, type:app
└── libs/
├── shop/
│ ├── feature-catalog/ tags: scope:shop, type:feature (pages smart, lazy)
│ ├── feature-cart/ tags: scope:shop, type:feature
│ └── data-access/ tags: scope:shop, type:data-access (API + store)
├── admin/
│ ├── feature-users/ tags: scope:admin, type:feature
│ └── data-access/ tags: scope:admin, type:data-access
└── shared/
├── ui/ tags: scope:shared, type:ui (composants dumb)
├── data-access/ tags: scope:shared, type:data-access (auth, config HTTP)
└── util/ tags: scope:shared, type:util (fonctions pures)Des tags sur deux dimensions
Chaque projet reçoit des tags dans project.json. La combinaison qui marche en pratique est scope (le domaine : scope:shop, scope:admin, scope:shared) × type (la couche : type:feature, type:ui…). Le scope répond à « qui parle à qui », le type répond à « dans quel sens vont les dépendances ».
{
"name": "shop-feature-cart",
"sourceRoot": "libs/shop/feature-cart/src",
"projectType": "library",
"tags": ["scope:shop", "type:feature"]
}La règle @nx/enforce-module-boundaries
La règle de lint prend une liste de depConstraints. Pour un import, Nx retient toutes les contraintes dont le projet source possède le sourceTag, et les applique avec un ET logique : shop/feature-cart doit satisfaire à la fois la règle de scope et la règle de type. onlyDependOnLibsWithTags exige que la cible ait au moins un des tags. Un projet sans aucun tag correspondant n’a le droit de dépendre de rien.
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
// Dimension 1 : le domaine (qui peut parler à qui)
{ sourceTag: 'scope:shop', onlyDependOnLibsWithTags: ['scope:shop', 'scope:shared'] },
{ sourceTag: 'scope:admin', onlyDependOnLibsWithTags: ['scope:admin', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// Dimension 2 : le type (le sens dans lequel circulent les dépendances)
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
],
},
],
},
},
];En plus des tags, la règle a des vérifications fixes qui s’exécutent avant eux. Trois comptent au quotidien, dans cet ordre : les dépendances circulaires, l’import d’une application (interdit) et l’import statique d’une bibliothèque chargée en lazy (il tirerait le chunk dans le bundle principal). Vous pouvez tous les voir dans la démo :
scope:shopscope:sharedscope:adminlazy (loadChildren)
La même règle accepte aussi des formes plus fines :
// Autres formes utiles de contraintes (même règle, mêmes depConstraints) :
[
// "ui" ne doit pas atteindre data-access, même TRANSITIVEMENT (vérifie toute la chaîne)
{ sourceTag: 'type:ui', notDependOnLibsWithTags: ['type:data-access'] },
// la règle ne s’applique qu’aux projets qui ont LES DEUX tags
{ allSourceTags: ['scope:shop', 'type:ui'], onlyDependOnLibsWithTags: ['scope:shared'] },
// paquets npm interdits pour un type de projet
{ sourceTag: 'type:util', bannedExternalImports: ['@angular/*'] },
// glob et regex dans les tags
{ sourceTag: 'scope:*', onlyDependOnLibsWithTags: ['/^scope:(shared|core)$/'] },
]Attention à la différence : notDependOnLibsWithTags vérifie toute la chaîne des dépendances de la cible, pas seulement la dépendance directe. C’est le bon outil pour « l’UI n’atteint jamais data-access, même par des détours ». Pour les frontières entre dossiers dans une seule application, sans Nx, voir Boundaries.
Affected et cache
nx affected -t test part des fichiers modifiés entre --base (par défaut defaultBase, c’est-à-dire main) et --head, les associe à des projets, puis remonte le graphe le long des arêtes inverses : tout ce qui dépend, directement ou transitivement, d’un projet modifié est affecté. Dans le workspace de la démo, un changement dans shop/data-access affecte 4 projets (la bibliothèque, les deux features de la boutique et l’application boutique) ; un changement dans shared/util les affecte tous les 10.
Vient ensuite le cache : avant d’exécuter une tâche, Nx calcule un hash de ses entrées — les sources du projet, celles de ses dépendances (selon inputs), la configuration, la commande. Si le hash a déjà été vu, Nx rejoue le résultat (la sortie du terminal et les fichiers) au lieu de l’exécuter. Avec un cache distant, une tâche exécutée une fois en CI ne s’exécute plus sur le portable de personne.
modifié ✎affectéexécuté ✓depuis le cache
Cliquez sur un projet du graphe : vous modifiez l’un de ses fichiers.
- Le terminal de la CI s’affiche ici.
{
"defaultBase": "main",
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": { "cache": true }
}
}Si chaque application dépend d’une énorme bibliothèque shared, tout changement dedans affecte tout le dépôt, et affected n’économise plus rien. Des bibliothèques plus petites, découpées par domaine et par type, donnent des graphes plus étroits et davantage de cache hits.
Le cache suppose des tâches sans effets de bord : les mêmes entrées donnent le même résultat. Un test e2e qui interroge un vrai backend n’est pas cacheable — son résultat dépend de quelque chose que Nx ne voit pas.
Dossier ou bibliothèque ?
Le guide Nx sur la taille des projets résume bien l’arbitrage. Vous découpez en bibliothèques quand les tests vous ralentissent (affected et le cache commencent à sauter des morceaux), quand le code a un autre propriétaire (une équipe, un fichier CODEOWNERS), ou quand vous voulez des frontières vérifiées. Vous ne découpez pas ce qui change toujours ensemble — la frontière coûte en navigation et en configuration sans rien apporter. Et tant que la conception est encore en exploration, un seul projet est moins coûteux ; vous extrayez la bibliothèque quand l’API s’est stabilisée.
Un dossier dans la feature (ou dans shared/) avec un index.ts comme API publique vous donne 80 % des bénéfices sans configuration. Extrayez la bibliothèque quand un second consommateur apparaît.
src/app/features/cart/ui/ + règle de lint sur les importsPour l’option « dossier », les règles de Shared vs feature et un index.ts comme API publique vous donnent l’essentiel des bénéfices, sans configuration supplémentaire.
Quand OUI, quand NON
- Deux applications ou plus partagent du code de domaine ou un design system interne.
- Changements transversaux fréquents (modèles d’API, auth, thème) que vous voulez livrer de façon atomique, en une seule PR.
- Plusieurs équipes dans le même code : les tags et la règle de lint rendent les frontières explicites.
- Une CI lente :
affected+ cache réduisent le temps proportionnellement à ce que vous avez touché. - Un design system consommé aussi hors du dépôt : une bibliothèque publiable construite avec ng-packagr.
- Une application, une équipe : des dossiers par domaine (Structure des dossiers) et une règle
no-restricted-importsvous donnent les mêmes frontières. - Des équipes qui doivent livrer de façon totalement indépendante, même avec des versions d’Angular différentes : polyrepo ou micro frontends.
- Une bibliothèque par composant : de la configuration sans bénéfice. Une bibliothèque par domaine et par type, pas par fichier.
- Publier sur npm du code utilisé uniquement dans le dépôt : les versions ajoutent du cérémonial sans valeur.
Pièges fréquents
"@acme/ui": ["projects/acme/ui/src/public-api.ts"] semble pratique (vous ne construisez plus la bibliothèque), mais l’application compile le code avec esbuild, alors que les consommateurs externes reçoivent ce que produit ng-packagr. Les bugs n’apparaissent que chez eux. Solution : un alias vers dist et ng build @acme/ui --watch — ou passez à Nx, conçu pour compiler depuis les sources.
La bibliothèque apporte sa propre copie d’Angular, et l’application se retrouve avec deux instances : la DI et la Change Detection cassent de façons difficiles à diagnostiquer. Solution : tout ce qui est @angular/* va dans peerDependencies.
- Imports profonds (
@acme/ui/src/lib/button/button) qui contournentpublic-api.ts: le premier refactoring interne casse les consommateurs. Solution : exportez depuis l’API publique ou créez un point d’entrée secondaire ; interdisez ce motif avecno-restricted-imports. - Imports relatifs entre points d’entrée d’une même bibliothèque : ng-packagr construit chaque point d’entrée séparément et le build échoue. Solution : importez par le nom du paquet (
@acme/ui). - Des projets sans tags, « réparés » avec
sourceTag: '*'→'*': la règle ne vérifie plus rien. Solution :--tagsà la génération, et les tags vérifiés en revue. - Une bibliothèque partagée fourre-tout dont tout dépend : affected = tout le dépôt à chaque PR. Solution : découpez-la par domaine et par type.
- Mettre en cache des tâches non déterministes (e2e contre un vrai backend, tests qui lisent l’heure) : Nx rejoue un résultat qui n’est plus vrai. Solution : pas de
cache: truesur ces targets, ou des inputs complets. - Un import statique au-dessus d’un
loadChildren: le chunk lazy disparaît dans le bundle initial. La règle Nx l’attrape ; sans Nx, vérifiez le bundle (voir Performance).
Aide-mémoire
ANGULAR CLI (multi-projets)
ng new acme --no-create-application workspace vide
ng g application shop | ng g library @acme/ui
ng build @acme/ui [--watch] les bibliothèques sont construites AVANT d’être consommées
tsconfig paths → ./dist/acme/ui (+ "@acme/ui/*" pour les points d’entrée secondaires)
cd dist/acme/ui && npm publish seul ce qui quitte le dépôt est publié
BIBLIOTHÈQUE
ng-package.json dest + lib.entryFile (secondaire : uniquement "lib")
public-api.ts l’API publique — le reste est privé
package.json peerDependencies pour @angular/*, "sideEffects": false
compilationMode "partial" pour npm
NX
nx graph le graphe de projets
nx affected -t lint test build uniquement les projets affectés (dépendants transitifs inclus)
"cache": true in targetDefaults hash des inputs → rejeu à la deuxième exécution
tags + @nx/enforce-module-boundaries → frontières imposées au lint
types : feature → ui, data-access, util · ui → ui, util · data-access → data-access, util · util → util