Skip to content

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/db

Analogie : 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)

yaml
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'libs/**'
json
// apps/orders-api/package.json
{
  "name": "@acme/orders-api",
  "dependencies": {
    "@acme/domain-orders": "workspace:*",
    "@nestjs/core": "^11.0.0"
  }
}

Turborepo pipeline

json
// 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 }
  }
}
bash
turbo run build       # parallèle, en respectant le graph
turbo run test --filter=...[main]  # only changed since main

Nx — workspace Nest

bash
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-api

Nx 18+ — cache est déclaratif, pas tasksRunnerOptions. L'ancien bloc tasksRunnerOptions.default.options.cacheableOperations est legacy (encore lu, mais déprécié). Depuis Nx 17/18, on déclare le caching directement sur les targetDefaults avec "cache": true, et on connecte Nx Cloud via nx connect (qui ajoute une clé nxCloudId dans nx.json, plus un token). Si tu vois tasksRunnerOptions dans un workspace, c'est qu'il n'a pas migré.

jsonc
// 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"
}

--buildable n'est (presque) plus nécessaire. Avec le @nx/js:tsc/@nx/webpack infé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.

js
// 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'] },
          ],
        },
      ],
    },
  },
];
json
// .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"] }
        ]
      }]
    }
  }]
}
json
// 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 :

AxeExemple de tagsCe qu'il contraintQuestion répondue
type:domain, infra, app, feature, utilLes couches (Clean/Hexagonal)"Qui peut dépendre de qui techniquement ?"
scope:orders, billing, accountsLes bounded contexts (DDD)"Quelle équipe / domaine métier ?"
platform:node, web, sharedLa 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

bash
nx affected -t build --base=origin/main
nx affected -t test --base=origin/main --parallel=4
nx graph              # visualise le project graph en HTML

Sur 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 helpers

Règles d'or :

  • apps/* n'importe jamais un autre apps/*.
  • domain/* n'importe jamais infra/* ni apps/*.
  • infra/* peut importer domain/* mais pas l'inverse.
  • shared/* est consommable par tout le monde.

🎯 Patterns courants

  1. Apps thin, libs fatapps/orders-api/src/main.ts ne fait que bootstrap. Toute la logique vit dans libs/. Permet de réutiliser un domaine entre HTTP API, worker, CLI.
  2. Buildable libs--buildable (Nx). Permet un build incrémental cacheable. Sans, tout est recompilé à chaque app build.
  3. Path mapping TS@acme/domain-orderslibs/domain/orders/src/index.ts via tsconfig.base.json. Import court, refactor facile.
  4. Generators customnx g @nx/nest:resource users --project=orders-api scaffold le module + controller + service + DTO + test. Avec un schematic maison, tu codifies vos conventions.
  5. Tags + boundariesscope:, type:, platform:. Tu lis le code et tu sais immédiatement où ça appartient.
  6. 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_ENV mais que NODE_ENV n'est pas dans inputs.env, alors un build dev et un build prod ont le même hash → Nx te sert le dist/ 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". Quand orders-api dépend de domain/orders, le hash de build:orders-api inclut le hash de production:domain/orders. Change un fichier dans domain/orders → son hash change → le hash de orders-api change → cache miss en cascade. C'est exactement ce qu'on veut.
  • Exclure les specs de production (!{projectRoot}/**/*.spec.ts) : sans ça, modifier un test de domain/orders invalide le build de tous les consommateurs alors que le code de prod n'a pas bougé. Le namedInput production est 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 set

Le 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

SignalGarder dans l'appExtraire 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 les projects du nest-cli.json. Suffisant pour 2–3 apps, vite limité (pas de affected, pas de graph).
  • Nx + Nest : @nx/nest plugin 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). Les project.json deviennent quasi vides (juste tags).
  • Nx 18 : Project Crystal — les plugins infèrent les targets, tasksRunnerOptions déprécié au profit de "cache": true sur targetDefaults, Nx Cloud via nxCloudId.
  • Nx 19 : flat config ESLint (eslint.config.js) par défaut, ESLint 9.
  • Nx 20 (fin 2024) : compatible Nest 11. nx release stabilisé (versioning + changelog + publish des libs publishables). TS solution / project references comme défaut pour les nouveaux workspaces.
  • Nx 21 (2025) : continuous tasks (continuous: true pour 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 un migrations.json, puis nx migrate --run-migrations applique les codemods (mise à jour de config ET de code). Toujours commit avant.
  • Turborepo 1.x → 2.x (2024) : breaking dans turbo.json (pipelinetasks, dependsOn syntax inchangée). Turbo 2.x : with pour les tâches parallèles, meilleur boundaries support.
  • pnpm : v8 → v9 → v10 (lockfile v9, pnpm dlx, catalog: protocol pour centraliser les versions de deps dans pnpm-workspace.yaml). Workspace protocol stable depuis v6.

⚠️ Pitfalls

  1. Imports croisés app↔appapps/A importe apps/B/src/foo. Cassures de boundaries silencieuses si pas d'ESLint rule. Tout doit passer par une lib.
  2. Lib qui dépend de @nestjs/common sans raison — la lib devient framework-bound. Préfère des libs domaine pures TS, et des libs infra Nest-aware.
  3. Versions de deps divergentes entre apps → bundle bloat + incohérences. pnpm avec pnpm-workspace.yaml partagé + Renovate qui synchronise.
  4. 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"]).
  5. Affected mal scopé--base=origin/main requiert un fetch préalable en CI. Sinon affected ne voit rien et tu rebuilds tout (ou rien, pire).
  6. Tests Jest cross-projects — un seul jest.config global s'écroule à 20 projets. Utilise jest projects ou Jest par projet (Nx fait ça).
  7. Circular deps detection oubliéemadge --circular en CI. Une fois en place, plus jamais de cycle.
  8. 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 e2e séparé qui boot toutes les apps via testcontainers.
  • Boundary tests : Lint en CI = test de l'architecture.
bash
# 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.

bash
# 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/
ts
// 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
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é.

bash
# 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
json
// 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"
}
json
// .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": ["*"] }
        ]
      }]
    }
  }]
}
ts
// 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;
  }
}
ts
// 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);
  }
}
yaml
# .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 }} }
bash
# 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:reports

Effets 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 ↔ Web

Le 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

ts
// 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 forRootAsync et pas new Anthropic() dans un service ? Testabilité (tu mocks le provider ANTHROPIC), single source of config, et la clé API ne traîne pas hardcodée. C'est la lib infra/llm qui possède cette logique — toutes les apps l'importent. Modèles courants : flagship claude-opus-4-8, équilibré claude-sonnet-4-6, rapide/éco claude-haiku-4-5.

Streaming SSE depuis chat-api, avec annulation au disconnect

ts
// 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: generationId cô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 ce generationId.
  • 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 (usage du SDK) par tenant et coupe au-dessus d'un budget. Le rate-limit et l'idempotency-key sont au gateway, pas dans chaque service.
ts
// 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èrepnpm workspacesTurborepoNx
Linking packages✓ (via pnpm)
Task pipeline✓ (simple)✓ (avancé)
Project graphpartiel✓ (visualisé)
Affected commands
Code generators
Remote cache✓ (Vercel)✓ (Nx Cloud)
Multi-langage
Module boundariesvia eslint hand✓ (intégré)
Courbe d'apprentissagebassemoyenneélevée
Lock-innulfaiblemoyen

Common Nx pitfalls

  1. Migrations Nx pas exécutéesnx migrate génère un fichier mais oublie de l'exécuter (nx migrate --run-migrations). Le bump pseudo-installé crashe au build.
  2. 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.
  3. Cache hit faussé par fichier non listé dans inputs — un changement dans tsconfig.base.json passe inaperçu, le cache hit retourne un build obsolète.
  4. Nx Cloud token leakNX_CLOUD_ACCESS_TOKEN en clair dans CI logs → write access au cache distant. Use read-only token en CI public, write seulement en main.
  5. Trop de generators custom — un generator par concept = inertie. Garde-les simples, refactor le code direct est souvent plus rapide.
  6. buildable libs everywhere — chaque lib génère son dist/ 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

Bibliothèque tech perso — Achref