Einen NestJS-Backend in ein pnpm- und Turborepo-basiertes Monorepo verschieben
Teilen Sie TypeScript-Kontrakte über APIs mithilfe von pnpm Workspaces, Turborepo Task-Graphen, cache-freundlichen Dockerfiles sowie pnpm-Katalogen – ohne dass übliche Elemente zu einem Unordnungshafen werden.
Quellen: Why I Chose a Monorepo for My Backend (And What Actually Happened)
Der gewählte Ansatz bestand aus einem pnpm-Workspace-Monorepo, das mit Turborepo gesteuert wurde: Was funktionierte, was Probleme verursachte und was bei einem zweiten Versuch geändert werden würde – ausschließlich technische Entscheidungen, ohne Berücksichtigung von Produktmarken.
Der Auslöser für die Probleme
Der erste Service begann als eine einzige NestJS-Anwendung, die Authentifizierung, Content-Management, Uploads auf Cloudflare R2 sowie Suchfunktionen basierend auf Meilisearch abdeckte. Diese Struktur blieb bestehen, bis die Suchfunktion ihre eigene Bereitstellungslogik und Fehlerbehandlung benötigte, während weiterhin Enums und Hilfsfunktionen mit der Haupt-API geteilt wurden.
Zwei Optionen kamen in Frage: Die gemeinsam genutzten Komponenten in ein anderes Repository kopieren und sie manuell synchronisieren, oder den Code vor Ort teilen und dabei ermöglichen, dass jede Anwendung eigenständig gebaut und veröffentlicht wird.
Duplicate-Enums in verschiedenen Repositories scheinen harmlos, bis eine Seite aktualisiert wird und die andere nicht. Schon ein kleiner Fehler bei einem content-type-Enum reichte aus, um das Teilen über mehrere Repositories in diesem Umfang abzulehnen.
Warum pnpm, warum Turborepo
Drei Technologiestacks wurden verglichen: Nx, Yarn Workspaces mit Lerna sowie pnpm Workspaces mit Turborepo.
Nx war leistungsfähig, fühlte sich aber wie ein zweites Framework an: steile Lernkurve sowie übermäßig aufwendige generierte Strukturen im Vergleich zu den Anforderungen von vier Anwendungen und einigen gemeinsam genutzten Paketen. Graphische Visualisierungen sowie Mechanismen zur Erfassung betroffener Befehle waren mehr, als das Problem erforderte.
Yarn-Workspaces zusammen mit Lerna kamen auf Yarn 1.x bekannt vor, doch die Wartungssituation von Lerna war problematisch und Yarns lockere Handhabung hatte bereits zu sogenannten Phantom-Abhängigkeiten geführt – Importe, die nur funktionierten, weil zufällig ein transitives Paket lokal vorhanden war, in der Produktion jedoch fehlten.
pnpm setzte sich durch strenge Isolation durch: Jeder Import muss in package.json deklariert werden. Während der Migration markierte es sofort @nestjs/mapped-types als verwendet, aber nicht deklariert – etwas, was Yarn aufgrund des Abhängigkeitsgraphen zugelassen hatte. In Kombination mit der Verknüpfung von Workspaces ohne umständliche manuelle Verbindungen war diese Strenge entscheidend.
Turborepo orchestriert die Builds darüber hinaus. Die Deklaration von "dependsOn": ["^build"] sorgt dafür, dass gemeinsam genutzte Pakete vor den Verbrauchern kompiliert werden, speichert die Ausgaben im Cache und parallelisiert unabhängige Aufgaben. Der Großteil der Richtlinien befindet sich in einer einzigen Datei turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
Das Token ^build bedeutet „Erst die Build-Abhängigkeiten erstellen.“ Turborepo liest für den Rest das package.json-Graphen.
Diese schlichte Konfiguration ist beabsichtigt. Das Ziel der Migration war ein schnellerer Feedbackprozess für kleine Service-Graphen – nicht ein umfassendes Werkzeugset für alle betroffenen Aufgaben eines Plattformteams. Durch die deklarative Orchestrierung war die Einführung günstiger, als die dritte API hinzukam: Neue Anwendungen kopierten die Aufgabennamen aus turbo.json und nutzten dieselben Cache-Schlüssel.
Was ich tatsächlich teile
Zu Beginn gab es getrennte Pakete für Typen und Hilfsfunktionen. Innerhalb weniger Monate wurden sie zu einem einzigen Paket @myapp/common zusammengeführt. Die Trennung sah auf dem Papier ordentlich aus, war in der Praxis jedoch lästig: Jeder Enum benötigte einen eigenen Ort, und die meisten Dateien importierten ohnehin beide Pakete.
Der gemeinsame Baum sieht jetzt so aus:
packages/common/src/
├── enums/
│ ├── subdirectory-path.enum.ts # R2 bucket paths
│ ├── content.enum.ts # Content field types
│ └── index.ts
├── types/
│ ├── pagination.types.ts
│ └── index.ts
└── index.ts
Halten Sie es absichtlich klein. Teilen Sie nur cross-service-Typen, von mehreren Apps referenzierte Enums sowie einen einzigen UUID-Hilfsfunktionen. Alles andere bleibt app-lokal. Das Paket ist ein Vertrag, kein Schrank voller Unordnung.
Drei weitere gemeinsame Pakete fanden später ihren Platz:
Ein config-Paket lädt Geheimnisse. Beide APIs holen bei Start Daten aus dem Secrets Manager, weshalb loadSecrets() nur einmal vorhanden ist und vor allen anderen Startvorgängen aus jedem main.ts ausgeführt wird.
Ein telemetry-Paket umfasst OpenTelemetry-Tracing sowie strukturiertes JSON-Logging, einschließlich der Filterung störender Health-Check-Spannungen. Durch solche cross-cuttingen Überwachungsfunktionen wird eine echte Trennung zwischen den Services vermieden.
Ein vitest-config-Paket stellt eine gemeinsame Vitest-Fabrik bereit, sodass die Testeinstellungen konsistent bleiben, ohne dass Konfigurationen kopiert werden müssen.
Das Dockerfile-Problem
Die Docker-Builds in Monorepos werden in den Tutorials oft unterschätzt. Der Build-Kontext ist die Wurzel des Repositoriums. Jede Anwendung verfügt über ihre eigene Dockerfile, muss aber auf gemeinsame Pakete zugreifen können. Eine cachefreundliche Anordnung der COPY-Befehle ist dabei entscheidend.
Das sich durchgesetzt hat:
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.12.4 --activate
WORKDIR /app
# 1. Copy workspace manifests first (cache the install layer)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc turbo.json ./
COPY packages/common/package.json ./packages/common/
COPY packages/config/package.json ./packages/config/
COPY packages/telemetry/package.json ./packages/telemetry/
COPY apps/core/package.json ./apps/core/
RUN pnpm install --frozen-lockfile
# 2. Copy source and build in dependency order
COPY packages/common ./packages/common
COPY packages/config ./packages/config
COPY packages/telemetry ./packages/telemetry
COPY apps/core ./apps/core
RUN pnpm --filter @myapp/common build
RUN pnpm --filter @myapp/config build
RUN pnpm --filter @myapp/telemetry build
RUN pnpm --filter @myapp/core build:prod
# 3. Deploy step creates a self-contained directory
RUN pnpm deploy --filter @myapp/core --prod --legacy /deploy/core
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /deploy/core ./
CMD ["node", "dist/main"]
Zuerst werden alle package.json-Dateien kopiert, anschließend installiert und danach die Quellcodes. Docker nutzt danach die Installations-Ebene weiter, solange sich die Abhängigkeiten nicht ändern – und nicht bei jeder Änderung der Quellcodes.
Der Nachteil: Das Hinzufügen eines gemeinsamen Pakets bedeutet, dass die COPY-Zeilen in jeder verwendenden Dockerfile aktualisiert werden müssen. Eine Checkliste hilft, da fehlende Zeilen zu unklaren Build-Fehlern führen können. Am Ende erzeugt pnpm deploy einen eigenständigen Produktionsbaum, bei dem die Workspace-Pakete in echte Kopien umgewandelt werden, sodass das endgültige Image weder pnpm noch die gesamte Monorepo-Struktur benötigt.
Versionenverwaltung mit Katalogen
Versionsschwankungen sind die stillen Kosten eines Monorepos: NestJS 11.0 in einer Anwendung und 11.1 in einer anderen führen zu subtilen Fehlern in einzelnen Diensten.
pnpm-Kataloge definieren die gemeinsamen Abhängigkeitsbereiche einmal in pnpm-workspace.yaml:
catalogs:
nestjs:
'@nestjs/common': ^11.1.28
'@nestjs/core': ^11.1.28
'@nestjs/platform-fastify': ^11.1.28
testing:
vitest: ^3.2.6
'@vitest/coverage-v8': ^2.1.0
Dann verweisen die Anwendungen auf den Eintrag im Katalog:
{
"dependencies": {
"@nestjs/common": "catalog:nestjs"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}
Die Aktualisierung von NestJS überall erfordert lediglich eine Änderung im Workspace sowie einen Aufruf von pnpm install. Der Katalog enthält nur solche Abhängigkeiten, die von zwei oder mehr Anwendungen gemeinsam genutzt werden; eine Mongoose-Version, die nur von einem einzigen Dienst verwendet wird, kann lokal bleiben, solange sie keine Grenze überschreitet.
Kataloge verändern außerdem die Art und Weise, wie Updates überprüft werden. Eine Aktualisierung von NestJS ergibt sich dann als Diff auf Workspace-Ebene anstelle einer Vielzahl von Paketdateien, wodurch sich die Code-Review auf Release-Notes und brisante Änderungen konzentriert, anstatt darauf zu prüfen, ob jede Anwendung konsistent aktualisiert wurde.
Was ich falsch gemacht habe
Die Trennung von Typen und Hilfsfunktionen war eine voreilige Architekturentscheidung. Spätere Zusammenführung bedeutete umfangreiche Importaktualisierungen, die ein einzelnes Paket vermieden hätte.
Zu Beginn war das CI-System überdimensioniert: getrennte GitHub Actions-Arbeitsabläufe pro Anwendung mit Pfadfiltern, damit Suchänderungen die Kern-API nicht kompilieren konnten. Änderungen in gemeinsamen Paketen mussten an alle Nutzer weitergeleitet werden, und die Filter kosteten mehr Iterationen, als sie einsparten. Ein einziger PR-Arbeitsablauf, der alles kompiliert und durch den Turborepo-Cache beschleunigt wird, erwies sich als einfacher.
Was ich richtig gemacht habe
Durch strenge Auflösungsregeln wurde während der Migration eine echte, undeklarierte Abhängigkeit erkannt – genau die Art von Problem, das sonst erst Monate später auftauchen würde.
Die minimalen gemeinsamen Pakete blieben weiterhin minimal. Die Infrastruktur erleichtert das Teilen; Disziplin sorgt dafür, dass anwendungsbezogener Code nicht in common landet. Ein paar hundert Zeilen echten gemeinsamen Codes sind bereits eine Bereicherung.
Die mühsame Einrichtung des Dockerfiles hat sich ausgezahlt: Jeder Dienst wird separat gebaut, zieht nur die für ihn notwendigen Workspace-Pakete heran und erzeugt ein kleines Produktionsbild. Als schließlich eine dritte API sowie ein Admin-Dashboard zu sechs gemeinsam genutzten Paketen hinzukamen, bestand die Hinzufügung eines weiteren Dienstes größtenteils darin, bereits vorhandene Docker-, CI- und Telemetrie-Vorlagen zu verwenden.
Vermeiden Sie ein Monorepo, wenn es nur um einen Dienst geht und kein zweiter Dienst geplant ist. Wenn ein zweiter Dienst bevorsteht und Typen bereits zwischen den Repos kopiert werden, ist der Umstieg in der Regel längst überfällig. Die Migrationskosten entstehen frühzeitig; der Nutzen zeigt sich bereits beim ersten Mal, wenn sich eine gemeinsam genutzte Enumeration ändert und alle Nutzer dies in einem Commit übernehmen.
Ein Monorepo erfindet keine gute Architektur. Es macht gute Muster billiger, zu wiederholen, und schlechte Muster schwieriger, zu verbergen.
Operativ gesehen war der größte nachhaltige Vorteil die Vorhersehbarkeit während des Wachstums. Gemeinsame Enums verloren nicht mehr an Konsistenz. Die Telemetriedatenfelder stimmten in allen APIs überein. Docker-Bilder blieben klein, weil pnpm deploy das Projektstruktur-Scaffolding ignorierte. Die verbleibenden Kosten – wie die häufigen Änderungen im Dockerfile COPY-Befehl und der Drang, zu viel zu teilen – ließen sich mit Checklisten und Paketgrenzen bewältigen, nicht durch weitere Änderungen der Repository-Topologie.
Falls heute mit einer Neuimplementierung begonnen würde, wäre derselbe Stack weiterhin die Standardlösung für ein System dieser Größe: pnpm für strenge Installationen, Turborepo für Aufgabengraphen, Catalogs für gemeinsame Versionen sowie ein absichtlich kleines common-Paket. Der Unterschied bestünde darin, dass man mit einem statt zwei gemeinsamen Paketen sowie mit einem statt vielen CI-Arbeitsabläufen beginnen würde.