Przenieś backend NestJS do monorepo z pnpm i Turborepo
Dziel się kontraktami TypeScript pomiędzy API za pomocą przestrzeni roboczych pnpm, grafów zadań Turborepo, plików Dockerfile przyjaznych do cache’owania oraz katalogów pnpm — bez przekształcania standardowych elementów w nieuporządkowany magazyn.
Źródła: Why I Chose a Monorepo for My Backend (And What Actually Happened)
Po około dziewięciu miesiącach rozwoju backendu w NestJS okazało się, że jedna API, jeden plik Dockerfile oraz jedna ścieżka przetwarzania to za mało. Pojawiła się potrzeba drugiej API, potem trzeciej, a także interfejsu administracyjnego w React. Otwartym pytaniem było to, jak podzielić odpowiedzialności bez utraty wspólnych kontraktów w TypeScript.
Zastosowano rozwiązanie w postaci monorepo z użyciem pnpm workspace, zarządzanego za pomocą Turborepo: co zadziałało, co sprawiło problemy i co zmieniłoby się przy drugiej próbie — wyłącznie decyzje inżynierskie, bez elementów brandingu produktu.
Ból, który to wszystko zapoczątkował
Pierwsza usługa zaczęła się jako jedna aplikacja NestJS, obsługująca autoryzację, zarządzanie treścią, przesyłanie plików na Cloudflare R2 oraz wyszukiwanie oparte na Meilisearch. Taka struktura utrzymywała się do chwili, gdy wyszukiwanie wymagało własnego harmonogramu wdrażania i granic awarii, przy jednoczesnym dalszym współdzieleniu typów i pomocników z główną API.
Pojawiły się dwie opcje: skopiowanie udostępnionych elementów do innego repozytorium i ręczne utrzymywanie ich synchronizacji, albo udostępnienie kodu bezpośrednio w tym samym miejscu, pozwalając każdej aplikacji być budowana i dostarczana osobno.
Duplikowanie typów zmiennych w różnych repozytoriach wydaje się nieszkodliwe, dopóki jedna strona nie dokona aktualizacji, a druga nie zrobi tego. Już niewielka różnica w typie content-type wystarczyła, by odrzucić możliwość udostępniania między wieloma repozytoriami w takim skali.
Dlaczego pnpm, dlaczego Turborepo
Porównano trzy rozwiązania: Nx, przestrzenie robocze Yarn z Lerną oraz przestrzenie robocze pnpm z Turborepo.
Nx był skuteczny, ale sprawiał wrażenie drugiego frameworka: wymagał długiego czasu na naukę i generował zbyt ciężkie struktury, w porównaniu do potrzeb czterech aplikacji i kilku wspólnych pakietów. Narzędzia do wizualizacji grafu oraz mechanizmy obsługi poleceń były przesadne w stosunku do rozmiaru problemu.
Przestrzenie robocze Yarn w połączeniu z Lerną wydawały się znajome z czasów Yarn 1.x, jednak sytuacja z utrzymaniem Lerny budziła obawy, a luźne podejście Yarn już wtedy dopuszczało istnienie zależności widmowych – importów, które działały tylko dlatego, że lokalnie znajdował się odpowiedni pakiet transytywny, a potem zawodziły w środowisku produkcyjnym.
pnpm przewyższył konkurencję dzięki ścisłej izolacji: każdy import musi być zadeklarowany w pliku package.json. Podczas migracji natychmiast zaznaczył @nestjs/mapped-types jako używany, ale nierozdeklarowany, co Yarn pozwalał na to poprzez analizę struktury zależności. W połączeniu z możliwością łączenia przestrzeni roboczych bez konieczności ręcznego tworzenia skomplikowanych powiązań, ta ścisłość okazała się decydująca.
Turborepo koordynuje procesy kompilacji. Deklaracja "dependsOn": ["^build"] sprawia, że wspólne pakiety są kompilowane przed pakietami je używającymi, zapisuje wyniki w pamięci cache oraz paralelizuje niezależne zadania. Większość zasad znajduje się w jednym pliku turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
Token ^build oznacza „najpierw zbuduj zależności”. Turborepo czyta graf package.json, aby dalej postępować zgodnie z instrukcjami.
Taka minimalistyczna konfiguracja jest celowa. Celem migracji było szybsze uzyskiwanie informacji zwrotnych w przypadku małego grafu usług, a nie pełne narzędzie do zarządzania zadaniami dla całego zespołu platformy. Zachowanie deklaratywnego stylu orkiestracji sprawiło, że wdrożenie stało się prostsze po pojawieniu się trzeciej API – nowe aplikacje kopiowały nazwy zadań z pliku turbo.json i korzystały z tych samych kluczy cache’u.
To, co faktycznie udostępniam
Na początku istniały oddzielne pakiety dla typów i narzędzi pomocniczych. W ciągu kilku miesięcy połączono je w jeden pakiet @myapp/common. Taki podział wydawał się logiczny na papierze, ale w praktyce był uciążliwy – każda lista wartości wymagała osobnego miejsca przechowywania, a większość plików i tak importowała oba pakiety.
Obecny wspólny struktura wygląda następująco:
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
Celowo utrzymuj to w małym rozmiarze. Dziel się tylko typami międzyusługowymi, enumeracjami używanymi przez wiele aplikacji oraz pojedynczym narzędziem typu UUID. Wszystko inne pozostaje lokalne dla danej aplikacji. Pakiet stanowi umowę, a nie skrzynię na śmieci.
Później dwa kolejne wspólne pakiety również znalazły swoje miejsce:
Pakiet config załadowuje tajne dane. Obie API pobierają je z menedżera tajemnic podczas uruchamiania, więc funkcja loadSecrets() znajduje się tylko raz i jest wykonywana z każdego pliku main.ts przed innymi czynnościami startowymi.
Pakiet telemetry obejmuje funkcje śledzenia typu OpenTelemetry oraz strukturalnego logowania w formacie JSON, w tym filtrowanie niepotrzebnych rekordów z kontroli sprawności. Dzięki temu podejściu wspólny kod eliminuje rzeczywiste różnice pomiędzy usługami.
Pakiet vitest-config eksportuje wspólną fabrykę Vitest, dzięki czemu konfiguracja testów pozostaje spójna bez konieczności kopiowania plików konfiguracyjnych.
Problem z Dockerfile
Budowanie w Monorepo z użyciem Dockera jest tematem często pomijanym w poradnikach. Kontekstem budowania jest korzeń repozytorium. Każda aplikacja ma swój własny plik Dockerfile, ale musi mieć dostęp do wspólnych pakietów. Kluczowym elementem jest odpowiednie uporządkowanie poleceń COPY sprzyjające wykorzystaniu pamięci cache.
Ustalony wzorzec działania:
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"]
Najpierw kopiuje się każdy plik package.json, następnie instalują pakiety, a dopiero potem źródła kodu. Docker ponownie wykorzystuje warstwę instalacji dopóki zależności się nie zmienią, a nie przy każdej modyfikacji źródeł.
Koszt tego podejścia: dodanie wspólnego pakietu oznacza konieczność aktualizacji linii COPY we wszystkich plikach Dockerfile, które go używają. Lista kontrolna jest pomocna, ponieważ brakująca linia może spowodować niejasne błędy podczas budowania. Na końcu polecenie pnpm deploy tworzy samodzielny drzewo produkcyjne, w którym pakiety z przestrzeni roboczej są zamieniane na rzeczywiste kopie, dzięki czemu ostateczny obraz nie wymaga ani pnpm, ani pełnej struktury monorepo.
Zarządzanie wersjami za pomocą katalogów
Różnice w wersjach to cicha „podatek” dla monorepo: NestJS 11.0 w jednej aplikacji i 11.1 w innej sprzyjają pojawianiu się subtelnych błędów w poszczególnych usługach.
Katalogi pnpm definiują wspólne zakresy tylko raz w pliku 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
Następnie aplikacje odwołują się do wpisu w katalogu:
{
"dependencies": {
"@nestjs/common": "catalog:nestjs"
},
"devDependencies": {
"vitest": "catalog:testing"
}
}
Zaktualizowanie NestJS we wszystkich miejscach polega teraz na jednej edycji w przestrzeni roboczej oraz wywołaniu polecenia pnpm install. Katalog zawiera tylko zależności wspólne dla dwóch lub więcej aplikacji; wersja Mongoose używana przez pojedynczą usługę może pozostać lokalna, dopóki nie przekroczy określonych granic.
Katalogi zmieniają również sposób przeglądania aktualizacji. Aktualizacja NestJS staje się różnicą na poziomie całej przestrzeni roboczej, zamiast skupiać się na poszczególnych plikach pakietów, co sprawia, że przegląd kodu koncentruje się na notatkach wydawniczych i zmianach zakłócających funkcjonowanie, a nie na tym, czy wszystkie aplikacje zostały zaktualizowane w sposób spójny.
Czego nie zrozumiałem
Rozdzielanie typów i narzędzi pomocniczych było przedwczesną architekturą. Ich późniejsze połączenie oznaczało konieczność przeprowadzenia obszernych aktualizacji importów, których uniknęłoby pojedyncze pakiety.
Początkowo system CI był zbyt skomplikowany: oddzielne workflowy GitHub Actions dla każdego aplikacji z filtrami ścieżek, aby zmiany w wyszukiwarkach nie wpływały na budowę podstawowej API. Zmiany w pakietach współdzielonych musiały trafić do każdego użytkownika, a filtry powodowały więcej iteracji niż oszczędzały. Okazało się prostsze użyć jednego workflowu PR, który buduje wszystko, przyspieszonego przez cache Turborepo.
To, co zrobiłem dobrze
Surowe zasady rozwiązywania problemów wykryły rzeczywistą, nierozdeklarowaną zależność podczas migracji – tego typu błąd inaczej ujawniłby się dopiero po kilku miesiącach.
Pakiety współdzielone o minimalnej wielkości pozostały takie. Infrastruktura ułatwia współdzielenie, a dyscyplina zapobiega umieszczaniu kodu specyficznego dla aplikacji w katalogu common. Kilkaset linijek rzeczywistego kodu współdzielonego to zaletą.
Kosztowna konfiguracja Dockerfile przyniosła efekty: każda usługa jest budowana osobno, pobiera tylko te pakiety z przestrzeni roboczej, które są jej potrzebne, i tworzy mały obraz produkcyjny. Gdy obok sześciu pakietów współdzielonych pojawiły się trzecia API oraz panel administracyjny, dodanie kolejnej usługi polegało głównie na powtarzaniu już istniejących szablonów dla Dockera, CI i telemetrii.
Pomijaj monorepo w przypadku jednej usługi, jeśli nie planuje się drugiej. Gdy druga usługa jest już bliska realizacji, a typy są kopiowane między repozytoriami, przechodzenie na monorepo zwykle jest już opóźnione. Koszt migracji jest ponoszony na początku; korzyści pojawiają się za pierwszym razem, gdy zmieni się wspólny enum i każdy konsument przyjmie tę zmianę w jednym komitcie.
Monorepo nie tworzy dobrej architektury – sprawia jedynie, że dobre wzorce są łatwiejsze do powtarzania, a złe trudniejsze do ukrycia.
Z punktu widzenia funkcjonalnego największym trwałym sukcesem była przewidywalność w warunkach wzrostu. Wspólne typy zmiennych przestały się zmieniać losowo. Pola telemetryczne były spójne we wszystkich API. Obrazy Docker pozostawały małe, ponieważ pnpm deploy usuwał elementy szkieletowe środowiska pracy. Pozostałe koszty — częste modyfikacje plików Dockerfile COPY oraz pokusa nadmiernego udostępniania danych — dało się kontrolować za pomocą list kontrolnych i wyraźnych granic pakietów, a nie poprzez kolejną zmianę topologii repozytorium.
Gdyby dziś rozpocząć nową wersję oprogramowania, ta sama kombinacja narzędzi nadal byłaby standardem dla systemów tego rozmiaru: pnpm do ścisłych instalacji, Turborepo do zarządzania zadaniami, katalogi do przechowywania wspólnych wersji oraz celowo mały pakiet common. Zmiana polegałaby na użyciu jednego wspólnego pakietu zamiast dwóch oraz jednego procesu CI zamiast wielu.
Literatura pokrewna
- Gdzie mieści się Turborepo w monorepo NestJS i kiedy należy go pominąć — Jak Turborepo, przestrzenie robocze pnpm oraz NestJS dzielą zadania w monorepo typu TypeScript: wspólne pakiety, grafy zadań, cacheowanie, ryzyko powiązań oraz sytuacje, gdy należy go pominąć.
- Budowanie monorepo pnpm i Turborepo dla aplikacji Node.js krok po kroku — Konfiguracja monorepo typu TypeScript w pustej folderze przy użyciu przestrzeni roboczych pnpm i Turborepo, a następnie uruchamianie, budowanie oraz filtrowanie zadań w aplikacji internetowej, API i wspólnych pakietach.