Monorepo — Nx, Turbo, pnpm workspaces
TL;DR — Trois outils, trois philosophies : pnpm workspaces = juste le linking (léger, suffit pour 90% des cas). Turborepo = pnpm workspaces + cache de build distant + pipelines simples. Nx = framework opinionné avec generators, affected commands, graph dépendances, plugins Nest. Choisis Nx si tu veux du tooling lourd (lib generators, project graph), Turbo si tu veux juste paralléliser les builds, pnpm workspaces si tu veux le minimum vital. Et n'oublie jamais : un monorepo bien fait est un actif, mal fait c'est un cauchemar de boundaries.
🧠 Mental model — ASCII diagram + analogy
monorepo/
├── apps/
│ ├── orders-api (Nest)
│ ├── billing-api (Nest)
│ └── admin-web (Next)
├── libs/
│ ├── domain/orders (pure TS, no Nest deps)
│ ├── infra/db (TypeORM utils)
│ ├── shared/types (DTOs partagés API/Web)
│ └── shared/eslint (config)
└── tools/ (scripts, generators)
project graph:
admin-web ──► shared/types ──► domain/orders
orders-api ──► domain/orders ──► (nothing)
orders-api ──► infra/dbAnalogie : un monorepo = un immeuble avec plusieurs appartements (apps) qui partagent des étages techniques (libs). Nx = le syndic qui pose des règles (qui peut entrer chez qui), génère les plans des nouveaux appartements, et tient un cadastre (graph). Sans règles : "domain" importe "infra" → couplage circulaire → on ne sait plus refactorer.
Règle critique : enforce module boundaries. apps/* peut importer libs/*, mais libs/domain/* ne doit jamais importer libs/infra/*. ESLint rule.
🛠️ Code minimal
pnpm workspaces (baseline)
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'libs/**'// apps/orders-api/package.json
{
"name": "@acme/orders-api",
"dependencies": {
"@acme/domain-orders": "workspace:*",
"@nestjs/core": "^11.0.0"
}
}Turborepo pipeline
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": ["src/**", "tsconfig*.json", "package.json"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": { "outputs": [] },
"dev": { "cache": false, "persistent": true }
}
}turbo run build # parallèle, en respectant le graph
turbo run test --filter=...[main] # only changed since mainNx — workspace Nest
npx create-nx-workspace@latest acme --preset=nest --packageManager=pnpm
cd acme
nx g @nx/nest:library domain/orders --directory=libs/domain/orders
nx g @nx/nest:library infra/db --directory=libs/infra/db
nx g @nx/nest:application billing-api --directory=apps/billing-apiNx 18+ —
cacheest déclaratif, pastasksRunnerOptions. L'ancien bloctasksRunnerOptions.default.options.cacheableOperationsest legacy (encore lu, mais déprécié). Depuis Nx 17/18, on déclare le caching directement sur lestargetDefaultsavec"cache": true, et on connecte Nx Cloud vianx connect(qui ajoute une clénxCloudIddansnx.json, plus un token). Si tu voistasksRunnerOptionsdans un workspace, c'est qu'il n'a pas migré.
// nx.json — extrait (Nx 18-20, config moderne)
{
// Plugins = "inferred tasks" : Nx lit jest.config.ts / eslint.config.js
// et génère les targets test/lint automatiquement. Tu n'écris plus de
// "executor" à la main dans project.json — c'est le "Project Crystal".
"plugins": [
{ "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } },
{ "plugin": "@nx/jest/plugin", "options": { "targetName": "test" } },
{ "plugin": "@nx/webpack/plugin", "options": { "buildTargetName": "build" } }
],
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"],
"sharedGlobals": []
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"]
},
"test": { "cache": true, "inputs": ["default", "^production"] },
"lint": { "cache": true, "inputs": ["default"] }
},
// Nx Cloud : ajouté par `nx connect`. PAS de token en clair ici.
"nxCloudId": "abc123"
}
--buildablen'est (presque) plus nécessaire. Avec le@nx/js:tsc/@nx/webpackinféré et la TS project references (nx g @nx/js:setup-build), Nx 18+ gère le build incrémental sans le flag explicite. On le garde surtout pour des libs publishables (npm). Cf. la note buildable plus bas.
Module boundaries (ESLint)
Flat config par défaut depuis Nx 19 + ESLint 9. Les nouveaux workspaces génèrent
eslint.config.js(flat config), pas.eslintrc.json. La règle est la même, la syntaxe d'enrobage change. Voici les deux formes — utilise celle qui correspond à ton workspace.
// eslint.config.js (flat config — Nx 19+, ESLint 9) — RECOMMANDÉ
const nx = require('@nx/eslint-plugin');
module.exports = [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
{ sourceTag: 'type:domain', onlyDependOnLibsWithTags: ['type:domain'] },
{ sourceTag: 'type:infra', onlyDependOnLibsWithTags: ['type:domain', 'type:infra', 'type:shared'] },
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:domain', 'type:infra', 'type:shared'] },
],
},
],
},
},
];// .eslintrc.json (legacy eslintrc — workspaces Nx < 19) — équivalent
{
"overrides": [{
"files": ["*.ts"],
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:domain", "onlyDependOnLibsWithTags": ["type:domain"] },
{ "sourceTag": "type:infra", "onlyDependOnLibsWithTags": ["type:domain", "type:infra", "type:shared"] },
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:domain", "type:infra", "type:shared"] }
]
}]
}
}]
}// libs/domain/orders/project.json — un fichier PAR projet (jamais le workspace.json global, supprimé en Nx 16)
{ "tags": ["scope:orders", "type:domain"] }Comment un staff engineer raisonne sur les boundaries. La règle n'est pas un détail de lint — c'est ton architecture compilée en CI. Trois axes orthogonaux de tags :
| Axe | Exemple de tags | Ce qu'il contraint | Question répondue |
|---|---|---|---|
type: | domain, infra, app, feature, util | Les couches (Clean/Hexagonal) | "Qui peut dépendre de qui techniquement ?" |
scope: | orders, billing, accounts | Les bounded contexts (DDD) | "Quelle équipe / domaine métier ?" |
platform: | node, web, shared | La cible runtime | "Ce code tourne-t-il côté serveur ou navigateur ?" |
Une lib porte un tag de chaque axe (['type:domain', 'scope:orders', 'platform:node']). Le pouvoir : domain ne peut jamais importer infra (inversion de dépendance forcée → ton domaine reste pur et testable sans DB), et scope:orders ne peut pas importer scope:billing directement (la communication inter-contexts passe par des événements, pas des imports — exactement la frontière que tu voudras pour extraire un microservice plus tard). C'est l'architecture qui devient non-négociable : une PR qui la viole ne merge pas, sans débat en review.
Affected commands
nx affected -t build --base=origin/main
nx affected -t test --base=origin/main --parallel=4
nx graph # visualise le project graph en HTMLSur une PR qui touche libs/domain/orders, seuls les projets impactés (orders-api, billing-api si dépendant) sont buildés/testés.
Project structure for Nest apps + libs
Layout typique d'un monorepo Nest mature :
acme/
├── apps/
│ ├── orders-api/ # Nest HTTP API
│ ├── orders-worker/ # Nest worker (BullMQ)
│ └── billing-api/ # Another Nest service
├── libs/
│ ├── domain/
│ │ ├── orders/ # Pure TS — entities, value objects, domain services
│ │ └── billing/ # tag: type:domain, scope:billing
│ ├── infra/
│ │ ├── db-typeorm/ # TypeORM repositories, migrations
│ │ ├── http-clients/ # axios wrappers, retry, circuit breakers
│ │ └── messaging/ # BullMQ, Kafka producers/consumers
│ └── shared/
│ ├── dto/ # DTOs partagés API ↔ Web
│ ├── auth/ # JWT guards, decorators réutilisables
│ └── testing/ # builders, factories, testcontainer helpers
└── tools/
├── generators/ # Nx generators custom
└── scripts/ # CI helpersRègles d'or :
apps/*n'importe jamais un autreapps/*.domain/*n'importe jamaisinfra/*niapps/*.infra/*peut importerdomain/*mais pas l'inverse.shared/*est consommable par tout le monde.
🎯 Patterns courants
- Apps thin, libs fat —
apps/orders-api/src/main.tsne fait que bootstrap. Toute la logique vit danslibs/. Permet de réutiliser un domaine entre HTTP API, worker, CLI. - Buildable libs —
--buildable(Nx). Permet un build incrémental cacheable. Sans, tout est recompilé à chaque app build. - Path mapping TS —
@acme/domain-orders→libs/domain/orders/src/index.tsviatsconfig.base.json. Import court, refactor facile. - Generators custom —
nx g @nx/nest:resource users --project=orders-apiscaffold le module + controller + service + DTO + test. Avec un schematic maison, tu codifies vos conventions. - Tags + boundaries —
scope:,type:,platform:. Tu lis le code et tu sais immédiatement où ça appartient. - Cache remote (Nx Cloud / Turbo Remote Cache / self-hosted) — CI passe de 15 min à 3 min sur des PRs petites. Cher mais ROI rapide à > 5 devs.
🧮 Mental model — comment le cache et affected marchent vraiment
La plupart des devs traitent Nx comme une boîte noire magique. Un senior comprend la mécanique, parce que 99% des bugs de cache et de CI viennent d'une mauvaise compréhension de deux concepts : le hash d'inputs et le project graph.
Le hash d'inputs (= pourquoi un cache hit est correct ou pollué)
Pour chaque (projet, target), Nx calcule un hash déterministe à partir de :
hash = SHA( files(inputs) // contenu des fichiers listés dans namedInputs
+ env(inputs) // valeurs des env vars déclarées
+ hashes des deps // ^production : le hash des projets dont on dépend
+ version des plugins
+ args de la commande )Si le hash existe déjà dans le cache (local .nx/cache ou remote Nx Cloud), Nx ne réexécute pas — il restaure les outputs (dist/, le stdout, l'exit code). C'est tout. Conséquences directes que tu dois interner :
- Un input non déclaré = cache empoisonné. Si ton build lit
NODE_ENVmais queNODE_ENVn'est pas dansinputs.env, alors un builddevet un buildprodont le même hash → Nx te sert ledist/de l'autre. C'est le pitfall n°4. La fix :"inputs": ["production", { "env": "NODE_ENV" }]. ^production= les inputs des dépendances. Le^signifie "upstream". Quandorders-apidépend dedomain/orders, le hash debuild:orders-apiinclut le hash deproduction:domain/orders. Change un fichier dansdomain/orders→ son hash change → le hash deorders-apichange → cache miss en cascade. C'est exactement ce qu'on veut.- Exclure les specs de
production(!{projectRoot}/**/*.spec.ts) : sans ça, modifier un test dedomain/ordersinvalide le build de tous les consommateurs alors que le code de prod n'a pas bougé. Le namedInputproductionest la frontière "ce qui affecte ce qui consomme ce projet".
Le project graph (= pourquoi affected est rapide et fiable)
Nx construit un DAG de dépendances en analysant les imports (import { Booking } from '@coworking/bookings') + les dependsOn explicites. nx affected --base=origin/main fait :
1. git diff origin/main...HEAD → liste des fichiers changés
2. map fichiers → projets touchés → set "directly affected"
3. remonte le graph (reverse deps) → set "transitively affected"
4. n'exécute le target QUE sur ce setLe piège fatal en CI (pitfall n°5) : --base=origin/main exige que la ref origin/main soit fetchée. Avec actions/checkout@v4 en fetch-depth: 1 (défaut), origin/main n'existe pas → git diff échoue ou compare contre rien → Nx rebuild tout ou rien. Toujours fetch-depth: 0, et en CI utilise nx-set-shas (action officielle) qui calcule le bon base/head même sur push vers main.
Tableau de décision — où mettre la frontière d'une lib
| Signal | Garder dans l'app | Extraire en lib |
|---|---|---|
| Consommé par 1 seule app | ✓ | |
| Consommé par 2+ apps | ✓ | |
| Logique métier pure réutilisable | ✓ (type:domain) | |
| Couplé au framework (Nest decorators) | reste dans l'app ou type:infra | |
| Change à chaque sprint avec l'app | ✓ (le split ralentit) | |
| Frontière de bounded context stable | ✓ (futur microservice) |
Règle de staff : on extrait une lib pour une raison de couplage (réutilisation, frontière, build incrémental), jamais "pour ranger". 200 libs pour 3 apps, c'est de la dette cognitive (pitfall n°8).
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest CLI monorepo mode (
nest g app foo) existe depuis Nest 7, basé sur lesprojectsdunest-cli.json. Suffisant pour 2–3 apps, vite limité (pas de affected, pas de graph). - Nx + Nest :
@nx/nestplugin officiel depuis Nx 13. Avant :@nrwl/nest. Renommage en Nx 16 (@nrwl/*→@nx/*). - Nx 15 : task pipeline mature, cache local par défaut.
- Nx 16 : projects.json séparé recommandé, plus de
workspace.json. - Nx 17 :
inferred tasks— détecte automatiquement les targets via plugins (@nx/jest,@nx/eslint). Lesproject.jsondeviennent quasi vides (justetags). - Nx 18 : Project Crystal — les plugins infèrent les targets,
tasksRunnerOptionsdéprécié au profit de"cache": truesurtargetDefaults, Nx Cloud vianxCloudId. - Nx 19 : flat config ESLint (
eslint.config.js) par défaut, ESLint 9. - Nx 20 (fin 2024) : compatible Nest 11.
nx releasestabilisé (versioning + changelog + publish des libs publishables). TS solution / project references comme défaut pour les nouveaux workspaces. - Nx 21 (2025) : continuous tasks (
continuous: truepour les serveurs longue durée dans le pipeline), améliorations du graph et du daemon. nx migrate: le mécanisme clé.nx migrate latestécrit unmigrations.json, puisnx migrate --run-migrationsapplique les codemods (mise à jour de config ET de code). Toujours commit avant.- Turborepo 1.x → 2.x (2024) : breaking dans
turbo.json(pipeline→tasks,dependsOnsyntax inchangée). Turbo 2.x :withpour les tâches parallèles, meilleur boundaries support. - pnpm : v8 → v9 → v10 (lockfile v9,
pnpm dlx,catalog:protocol pour centraliser les versions de deps danspnpm-workspace.yaml). Workspace protocol stable depuis v6.
⚠️ Pitfalls
- Imports croisés app↔app —
apps/Aimporteapps/B/src/foo. Cassures de boundaries silencieuses si pas d'ESLint rule. Tout doit passer par une lib. - Lib qui dépend de
@nestjs/commonsans raison — la lib devient framework-bound. Préfère des libs domaine pures TS, et des libs infra Nest-aware. - Versions de deps divergentes entre apps → bundle bloat + incohérences.
pnpmavecpnpm-workspace.yamlpartagé + Renovate qui synchronise. - Cache pollué — un build qui dépend d'une env var non listée dans
inputs→ cache hit à tort. Toujours déclarer les inputs implicites (env: ["NODE_ENV"]). - Affected mal scopé —
--base=origin/mainrequiert un fetch préalable en CI. Sinonaffectedne voit rien et tu rebuilds tout (ou rien, pire). - Tests Jest cross-projects — un seul
jest.configglobal s'écroule à 20 projets. Utilisejest projectsou Jest par projet (Nx fait ça). - Circular deps detection oubliée —
madge --circularen CI. Une fois en place, plus jamais de cycle. - Trop de libs — 200 libs pour 3 apps = overhead cognitif. Règle de pouce : split en lib quand 2+ apps consomment, ou quand un module ferme cohérent dépasse ~30 fichiers.
🧪 Testing
nx affected -t test: seuls les projets impactés. CI 5–10x plus rapide.- Tests d'intégration cross-libs : un projet
e2eséparé qui boot toutes les apps via testcontainers. - Boundary tests : Lint en CI = test de l'architecture.
# pipeline CI typique
pnpm install --frozen-lockfile
nx affected -t lint,test,build --parallel=4 --base=origin/main
nx affected -t e2e --base=origin/main🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique avec plusieurs applications partagées
Qui : LegalTech qui propose trois produits — portail client, espace collaborateur, back-office admin — qui partagent un domaine Dossier, Document, Facture. Problème : trois repos GitHub séparés, trois pipelines, trois versions divergentes des entités Dossier. Un bug fixé dans le portail mettait 2 semaines à arriver dans l'admin. La douleur a déclenché la consolidation en monorepo Nx.
# Layout après migration
acme-legal/
├── apps/
│ ├── portal-client/ # API Nest pour le portail
│ ├── workspace-api/ # API Nest pour les collaborateurs
│ └── admin-api/ # API Nest admin
├── libs/
│ ├── domain/
│ │ ├── dossiers/ # type:domain, scope:legal — entities, business rules
│ │ ├── documents/
│ │ └── invoicing/
│ ├── infra/
│ │ ├── db-typeorm/ # type:infra
│ │ └── storage-s3/
│ └── shared/
│ ├── auth/ # JWT guards, decorators
│ └── dto/ # DTOs partagés portal/workspace
# Project tags + boundaries enforced
{
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:domain", "type:infra", "type:shared"] },
{ "sourceTag": "type:domain", "onlyDependOnLibsWithTags": ["type:domain"] }
]
}]
}Gains : un fix dans libs/domain/dossiers est déployé dans les 3 apps au prochain merge. CI passe de 18 min à 4 min via nx affected. Les onboardings de devs sont 3× plus rapides (un seul repo à cloner).
Scénario 2 — FinTech migrant monolithe → microservices
Qui : néobanque PME démarrée en monolithe Nest. À 2 ans, 80K LOC, 25 devs, trop gros pour aller vite. Décision : découper progressivement en microservices, mais sans Big Bang. Problème : un split direct en 8 repos = chaos. Pattern : monorepo Nx avec apps multiples, libs partagées, et chaque app peut potentiellement être extraite plus tard.
fintech/
├── apps/
│ ├── api-gateway/ # routing + auth
│ ├── accounts-service/ # comptes & soldes (extrait du mono)
│ ├── payments-service/ # virements SEPA (extrait du mono)
│ ├── kyc-service/ # onboarding
│ └── notifications-worker/ # BullMQ worker
├── libs/
│ ├── domain/
│ │ ├── accounts/ # tag: type:domain, scope:accounts
│ │ ├── payments/ # tag: type:domain, scope:payments
│ │ └── kyc/
│ ├── infra/
│ │ ├── kafka/ # producers + consumers réutilisables
│ │ └── db-postgres/
│ └── shared/
│ ├── auth-passport/
│ └── observability/// nx.json — strict boundaries between scopes
{
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "scope:accounts", "onlyDependOnLibsWithTags": ["scope:accounts", "scope:shared"] },
{ "sourceTag": "scope:payments", "onlyDependOnLibsWithTags": ["scope:payments", "scope:shared"] },
{ "sourceTag": "scope:kyc", "onlyDependOnLibsWithTags": ["scope:kyc", "scope:shared"] }
]
}]
}Gains : chaque équipe (accounts, payments, kyc) bosse sur ses libs sans bloquer les autres. Un service peut être extrait en repo séparé à tout moment — il a déjà ses frontières. Les dépendances entre scopes passent obligatoirement par des événements (Kafka), pas par des imports — forcé par l'ESLint rule.
Scénario 3 — E-commerce backend + admin + API publique
Qui : marketplace e-commerce, 12 devs, 3 produits à maintenir : storefront API (high-traffic), admin SPA + son API, API publique pour les sellers. Problème : duplication massive entre les 3 APIs (auth, validation, accès produits). Et Turbo Remote Cache rentabilisé dès la 1ère semaine.
ecommerce/
├── apps/
│ ├── storefront-api/ # public traffic, optimized Fastify
│ ├── admin-api/ # internal admin
│ ├── admin-web/ # Next.js (consumes admin-api)
│ ├── seller-api/ # public B2B API for sellers
│ └── orders-worker/ # BullMQ worker
├── libs/
│ ├── domain/
│ │ ├── catalog/ # produits, variants, prix
│ │ ├── orders/ # commandes, statuses
│ │ └── customers/
│ ├── infra/
│ │ ├── db-typeorm/
│ │ └── search-elastic/
│ └── shared/
│ ├── auth/
│ ├── dto/
│ └── types/ # consumed by admin-web too
└── turbo.json// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"inputs": ["src/**", "tsconfig*.json", "package.json"]
},
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"lint": { "outputs": [] },
"type-check": { "dependsOn": ["^build"], "outputs": [] }
},
"remoteCache": { "signature": true }
}Gains : Turbo Remote Cache avec Vercel — un build qui prenait 12 min en CI passe à 90s sur un PR qui ne touche que apps/admin-web. Les types partagés (libs/shared/types) garantissent que le SPA admin et l'API admin parlent la même langue. Pas de drift.
🛠️ Exemple end-to-end
Mise en situation : tu construis un monorepo Nx pour une plateforme de réservation de salles de coworking. Trois applications (API publique, admin API, worker), trois domaines (spaces, bookings, billing), des libs partagées et un pipeline CI qui ne build/test que ce qui a changé.
# Bootstrap
npx create-nx-workspace@latest coworking --preset=nest --packageManager=pnpm
cd coworking
# Generate apps
nx g @nx/nest:application booking-api --directory=apps/booking-api --tags=type:app,scope:booking
nx g @nx/nest:application admin-api --directory=apps/admin-api --tags=type:app,scope:admin
nx g @nx/nest:application reminder-worker --directory=apps/reminder-worker --tags=type:app,scope:reminder
# Generate domain libs (pure TS, no Nest deps)
nx g @nx/js:library spaces --directory=libs/domain/spaces --tags=type:domain,scope:spaces --buildable
nx g @nx/js:library bookings --directory=libs/domain/bookings --tags=type:domain,scope:bookings --buildable
nx g @nx/js:library billing --directory=libs/domain/billing --tags=type:domain,scope:billing --buildable
# Generate infra libs (Nest-aware)
nx g @nx/nest:library db --directory=libs/infra/db --tags=type:infra --buildable
nx g @nx/nest:library messaging --directory=libs/infra/messaging --tags=type:infra --buildable
# Shared
nx g @nx/nest:library auth --directory=libs/shared/auth --tags=type:shared --buildable
nx g @nx/nest:library dto --directory=libs/shared/dto --tags=type:shared --buildable// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/jest.config.ts",
"!{projectRoot}/test/**/*"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/.eslintrc.json"]
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{options.outputPath}"]
},
"test": {
"cache": true,
"inputs": ["default", "^production"]
},
"lint": { "cache": true, "inputs": ["default"] }
},
// Nx Cloud : ajouté par `nx connect` (Nx 18+). Pas de `tasksRunnerOptions`
// ni de token en clair — le token vit dans l'env CI (NX_CLOUD_ACCESS_TOKEN).
"nxCloudId": "abc123"
}// .eslintrc.json — module boundaries
{
"overrides": [{
"files": ["*.ts"],
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:domain", "type:infra", "type:shared"] },
{ "sourceTag": "type:infra", "onlyDependOnLibsWithTags": ["type:domain", "type:shared"] },
{ "sourceTag": "type:domain", "onlyDependOnLibsWithTags": ["type:domain"] },
{ "sourceTag": "type:shared", "onlyDependOnLibsWithTags": ["type:shared"] },
{ "sourceTag": "scope:booking", "onlyDependOnLibsWithTags": ["scope:booking", "scope:spaces", "scope:billing", "type:shared", "type:infra"] },
{ "sourceTag": "scope:admin", "onlyDependOnLibsWithTags": ["*"] }
]
}]
}
}]
}// libs/domain/bookings/src/lib/booking.entity.ts — pure TS, no decorators
export class Booking {
constructor(
readonly id: string,
readonly spaceId: string,
readonly userId: string,
readonly start: Date,
readonly end: Date,
private status: BookingStatus,
) {}
cancel(now: Date): void {
if (this.start.getTime() - now.getTime() < 3_600_000) {
throw new Error('cannot_cancel_within_1h');
}
this.status = BookingStatus.CANCELLED;
}
}// apps/booking-api/src/bookings/bookings.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateBookingDto } from '@coworking/dto'; // libs/shared/dto
import { BookingsService } from './bookings.service';
@Controller('bookings')
export class BookingsController {
constructor(private readonly bookings: BookingsService) {}
@Post()
create(@Body() dto: CreateBookingDto) {
return this.bookings.create(dto);
}
}# .github/workflows/ci.yml
name: ci
on: { pull_request: {}, push: { branches: [main] } }
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm exec nx-cloud start-ci-run --distribute-on="5 linux-medium-js"
- run: pnpm exec nx affected -t lint,test,build,type-check --parallel=4 --base=origin/main
- run: pnpm exec nx affected -t e2e --parallel=2 --base=origin/main
env: { NX_CLOUD_TOKEN: ${{ secrets.NX_CLOUD_TOKEN }} }# Daily commands
nx serve booking-api # dev
nx test booking-api --watch # tests watch
nx affected -t test --base=main # only what changed
nx graph # visualize the graph
nx g @nx/js:library reports --directory=libs/domain/reports --tags=type:domain,scope:reportsEffets concrets : un PR qui touche uniquement libs/domain/spaces rebuilds + retest spaces + les 2 apps qui en dépendent (booking-api, admin-api). Le worker reminder-worker n'est pas touché. CI passe de 14 min (avant Nx) à 3 min sur PRs moyennes. Module boundaries empêchent domain/bookings d'importer infra/db (sinon le domaine devient framework-bound). Le graph Nx visualise les dépendances et aide à voir quand le code drift vers un couplage non désiré.
🤖 Stack-fit — organiser le code IA/agent dans un monorepo Nest
Ta stack sert et consomme des agents IA à travers plusieurs services (API, worker, gateway). Le monorepo est exactement le bon endroit pour ça : la logique LLM (client, prompts, tool-use loop, types) est un bounded context partagé qui ne doit exister qu'une fois, pas dupliqué dans chaque app. C'est le cas d'usage canonique d'une lib type:infra.
Layout : une lib infra/llm, pas un new Anthropic() éparpillé
acme/
├── apps/
│ ├── chat-api/ # SSE streaming endpoint (Nest)
│ ├── agent-worker/ # BullMQ — jobs agentiques longue durée
│ └── mcp-server/ # expose les tools internes en MCP
├── libs/
│ ├── domain/
│ │ └── agent/ # type:domain — types de tool-call, état de conversation (PURE TS)
│ ├── infra/
│ │ └── llm/ # type:infra — AnthropicModule (DI), client streaming, retries
│ └── shared/
│ └── dto/ # AskDto, ToolCallTrace partagés API ↔ WebLe point clé d'architecture : libs/domain/agent (les types ToolCall, Message, la machine à états de la conversation) est pure TS, sans dépendance Anthropic ni Nest. Le SDK et la DI vivent dans libs/infra/llm. Pourquoi : ton domaine agent reste testable sans réseau, et si tu changes de provider, seul infra/llm bouge. La boundary ESLint l'interdit déjà (type:domain ne peut importer ni infra ni un package framework).
Le client LLM est injecté via forRootAsync — jamais instancié dans un champ
// libs/infra/llm/src/lib/llm.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC_CLIENT');
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
return {
module: LlmModule,
global: true,
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 4, // le SDK gère le backoff sur 429/5xx — ne le réimplémente pas
}),
},
LlmService,
],
exports: [ANTHROPIC, LlmService],
};
}
}Pourquoi
forRootAsyncet pasnew Anthropic()dans un service ? Testabilité (tu mocks le providerANTHROPIC), single source of config, et la clé API ne traîne pas hardcodée. C'est la libinfra/llmqui possède cette logique — toutes les apps l'importent. Modèles courants : flagshipclaude-opus-4-8, équilibréclaude-sonnet-4-6, rapide/écoclaude-haiku-4-5.
Streaming SSE depuis chat-api, avec annulation au disconnect
// apps/chat-api/src/chat/chat.controller.ts
import { Controller, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
import { LlmService } from '@acme/llm';
@Controller('chat')
export class ChatController {
constructor(private readonly llm: LlmService) {}
@Post('stream')
async stream(@Body() dto: AskDto, @Res() res: Response, @Req() req: Request) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.flushHeaders();
// Si le client ferme l'onglet, on coupe l'appel LLM (et on arrête de payer).
const ac = new AbortController();
req.on('close', () => ac.abort());
const stream = this.llm.streamText(dto.prompt, { signal: ac.signal });
for await (const token of stream) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
res.end();
}
}Jobs agentiques via BullMQ — idempotence et coût
Un agent long (multi-tour de tool-use) ne vit pas dans une requête HTTP : il vit dans agent-worker, lib infra/messaging. Les règles non négociables :
- Idempotence keyée sur un
generationId:jobId: generationIdcôté BullMQ. Un retry de job ne relance pas une génération déjà facturée. Avant d'appeler le LLM, check si un output partiel existe pour cegenerationId. - Retry cost-aware : ne retry que les erreurs transitoires (429, 5xx, timeout réseau) — pas une erreur de validation de tool input (qui re-coûtera et re-échouera).
attempts: 4, backoff: { type: 'exponential', delay: 2000 }, mais avec un guard qui n'augmente pas le compteur sur erreur déterministe. - Partial-output handling : persiste les tokens/tool-results au fur et à mesure (Redis/DB) pour qu'un crash worker reprenne sans tout regénérer.
- Cost-guard au bord : un interceptor compte les tokens (
usagedu SDK) par tenant et coupe au-dessus d'un budget. Le rate-limit et l'idempotency-key sont au gateway, pas dans chaque service.
// libs/infra/messaging/src/lib/agent.processor.ts — squelette
@Processor('agent')
export class AgentProcessor extends WorkerHost {
async process(job: Job<{ generationId: string; prompt: string }>) {
const existing = await this.store.getPartial(job.data.generationId);
if (existing?.done) return existing; // idempotent : déjà fait
// ... boucle tool-use, persist partiel à chaque tour, abort si annulé
}
}Exposer un endpoint MCP
apps/mcp-server expose tes tools internes (recherche, accès DB) en MCP pour qu'un client agent (Claude Desktop, un autre service) les consomme. Le serveur MCP réutilise les mêmes libs domain/* que tes APIs — zéro duplication de logique métier. C'est le payoff ultime du monorepo : un tool MCP et un endpoint REST partagent le même domain/orders.
🔁 Quand utiliser / éviter
- pnpm workspaces seul : 2–5 packages, équipe de 1–5. Simple, zéro lock-in.
- Turborepo : 5–20 packages, focus sur build speed, peu de besoin de codegen.
- Nx : ≥ 5 packages, codegen utile, multi-langs (Nest + Next + ?). Investissement initial qui paie à scale.
- Nest CLI mode monorepo : pour 2–3 apps simples uniquement.
- Évite Lerna en 2025 — déprécié, Nx l'a absorbé.
- Évite Bazel pour du Node — overkill sauf très grosse équipe polyglot.
- Évite de mixer Nx + Turbo — choisir un seul orchestrateur de tâches. Double maintenance pour aucun gain.
Comparaison rapide
| Critère | pnpm workspaces | Turborepo | Nx |
|---|---|---|---|
| Linking packages | ✓ | ✓ (via pnpm) | ✓ |
| Task pipeline | ✗ | ✓ (simple) | ✓ (avancé) |
| Project graph | ✗ | partiel | ✓ (visualisé) |
| Affected commands | ✗ | ✓ | ✓ |
| Code generators | ✗ | ✗ | ✓ |
| Remote cache | ✗ | ✓ (Vercel) | ✓ (Nx Cloud) |
| Multi-langage | ✓ | ✓ | ✓ |
| Module boundaries | via eslint hand | ✗ | ✓ (intégré) |
| Courbe d'apprentissage | basse | moyenne | élevée |
| Lock-in | nul | faible | moyen |
Common Nx pitfalls
- Migrations Nx pas exécutées —
nx migrategénère un fichier mais oublie de l'exécuter (nx migrate --run-migrations). Le bump pseudo-installé crashe au build. - Tags non posés sur les nouvelles libs — les boundaries ne sont pas enforcées sur les libs non taggées. Vérifier avec un test maison.
- Cache hit faussé par fichier non listé dans
inputs— un changement danstsconfig.base.jsonpasse inaperçu, le cache hit retourne un build obsolète. - Nx Cloud token leak —
NX_CLOUD_ACCESS_TOKENen clair dans CI logs → write access au cache distant. Use read-only token en CI public, write seulement en main. - Trop de generators custom — un generator par concept = inertie. Garde-les simples, refactor le code direct est souvent plus rapide.
buildablelibs everywhere — chaque lib génère sondist/cacheable. Pratique mais lourd à maintenir. Compromis : buildable seulement pour ce qui est publié ou réutilisé entre apps majeures.
🏋️ Exercices
Progression : monter un workspace → le rendre production-grade → le casser puis le réparer. Fais-les dans un vrai workspace Nx, pas sur papier.
1. Bootstrap + boundaries (implement)
Objectif : monter un workspace Nx avec 2 apps Nest (orders-api, billing-api), 2 libs domain (pures TS) et 1 lib infra/db, et faire échouer un import interdit en CI.
Indice/Solution : create-nx-workspace --preset=nest, génère les libs avec --tags=type:domain / type:infra, pose la règle @nx/enforce-module-boundaries (flat config). Écris volontairement un import de infra/db dans domain/orders, lance nx lint domain-orders → tu dois voir l'erreur de boundary. Tant que tu ne l'as pas vue rouge, la règle n'est pas réellement active.
2. Cache correct (implement → make it production-grade)
Objectif : prouver que ton cache est correct sous variation d'environnement.
Indice/Solution : ajoute un build qui lit process.env.NODE_ENV. Lance NODE_ENV=dev nx build orders-api puis NODE_ENV=prod nx build orders-api. S'il y a un cache HIT au 2e (mauvais), c'est que NODE_ENV n'est pas dans tes inputs. Ajoute { "env": "NODE_ENV" } aux inputs et reprouve : maintenant cache MISS. Vérifie avec nx build orders-api --verbose que le hash change.
3. affected en CI réaliste (make it production-grade)
Objectif : un pipeline GitHub Actions qui ne build/teste que l'affecté, et qui ne se trompe pas de base.
Indice/Solution : fetch-depth: 0, action nrwl/nx-set-shas, nx affected -t lint,test,build --base=$NX_BASE --head=$NX_HEAD. Test : une PR qui touche seulement libs/domain/billing ne doit PAS rebuild orders-api (vérifie dans les logs que orders-api est "skipped"). Si tout rebuild, ton base est faux (pas fetché).
4. Casse le graph, puis répare (break it then fix it)
Objectif : introduire une dépendance circulaire et la détecter automatiquement avant qu'elle merge.
Indice/Solution : fais que domain/orders importe domain/billing et domain/billing importe domain/orders. nx graph montre le cycle ; nx lint peut le rater selon la config. Ajoute madge --circular --extensions ts libs/ en CI (ou la règle import/no-cycle). Le job CI doit devenir rouge. Puis casse le cycle en extrayant le type partagé dans shared/dto.
5. Migration Nx sans casser (production-grade)
Objectif : bumper Nx d'une version majeure sur une branche et garder le build vert.
Indice/Solution : nx migrate latest, commit le package.json + migrations.json, pnpm install, puis nx migrate --run-migrations (l'oubli de cette 2e étape = pitfall n°1, le build crashe). Lance nx affected -t build,test pour vérifier. Lis le diff des codemods : c'est là que tu apprends ce qui a changé dans Nx.
6. Lib LLM partagée + cancel end-to-end (stack-fit, hard)
Objectif : une lib infra/llm avec client Anthropic DI'd (forRootAsync), consommée par chat-api (SSE) et agent-worker (BullMQ), avec annulation propre.
Indice/Solution : le client est un provider ANTHROPIC injecté, pas un new dans un champ. Côté chat-api, req.on('close') → AbortController.abort() passé au stream SDK. Côté worker, jobId: generationId pour l'idempotence + persistance d'output partiel. Test de rupture : coupe le client SSE en plein stream et vérifie dans les logs que l'appel Anthropic est bien aborted (sinon tu paies des tokens pour rien). Boundary à valider : chat-api importe @acme/llm mais domain/agent ne peut PAS — sinon le domaine devient framework-bound.
🎤 En entretien
Q : Quand recommandes-tu Nx plutôt que pnpm workspaces ou Turborepo ? R : pnpm workspaces suffit jusqu'à ~5 packages (juste du linking). Turborepo ajoute le task pipeline + remote cache si je veux surtout paralléliser les builds sans codegen. Nx quand j'ai besoin de generators, de boundaries enforcées, d'un project graph et de multi-langage à scale — l'investissement initial paie au-delà de ~5 packages et 5+ devs. Je ne mixe jamais Nx et Turbo : un seul orchestrateur.
Q : Pourquoi un cache distant peut servir un build obsolète, et comment tu l'évites ? R : Le cache est keyé sur un hash d'inputs. Si un input réel (env var, fichier de config global, version d'un outil) n'est pas déclaré dans namedInputs/inputs, deux builds différents ont le même hash → cache hit incorrect. La fix : déclarer tous les inputs implicites (env, sharedGlobals comme tsconfig.base.json), et exclure les specs du namedInput production pour ne pas sur-invalider.
Q : Comment Nx sait qu'une PR n'affecte que 2 projets sur 40 ? R : Il construit un project graph à partir des imports + dependsOn, fait un git diff contre la base, mappe les fichiers changés vers leurs projets, puis remonte les reverse dependencies du graph. Le seul piège est que la base (origin/main) doit être fetchée en CI (fetch-depth: 0 + nx-set-shas), sinon le diff est vide et il rebuild tout ou rien.
Q : Comment un monorepo prépare-t-il une extraction future en microservices ? R : En matérialisant les bounded contexts via des tags scope: et en interdisant les imports cross-scope par boundary ESLint — la communication inter-contexts passe par des événements (Kafka/BullMQ), pas par des imports. Le jour où on extrait payments-service en repo séparé, ses frontières existent déjà ; il n'y a pas de couplage caché à démêler. Le monorepo agit comme un microservices-en-attente, pas comme un monolithe distribué.
🔗 Liens
- Nx docs
- Turborepo docs
- pnpm workspaces
- Monorepo.tools comparison
- Nx enforce module boundaries
- Article : "Why a monorepo?" — Dan Luu
- Article : "Misconceptions about monorepos" — Nx blog