repo není ten problém
Monorepo často schytá vinu za průšvihy, které ve skutečnosti způsobuje nejasné vlastnictví, měkká pravidla pro závislosti a CI, které v podstatě jen ověří, že se kód zkompiluje na notebooku jednoho člověka. Tvar repa hraje mnohem menší roli než disciplína kolem něj. Převzali jsme repa, kde si Next.js app tahala interní soubory z jiné appky přes cestu typu ../../admin/src/lib/auth, Go worker lezl přes hranice služeb tím, že závisel na interním package jiné služby, a package manager tiše nainstaloval čtyři verze Reactu, protože nikdo pořádně nepřipnul peer ranges. Takové repo působilo prokletě. Nebylo prokleté, jen mu nikdo nevládl.
Náš výchozí setup ve steezr je jednoduchý a radši ho nechám jednoduchý, než abych honil každý módní build tool, který se zrovna objeví na GitHubu. Na JavaScript a TypeScript používáme pnpm 8 workspaces, na orchestrace tasků a remote cache hitů v CI Turborepo 1.x a go.work, aby byl lokální vývoj v Go snesitelný, aniž by se z každé služby stala obří koule sdílených packages. Celý point je v tom, že nejrychlejší cesta musí být zároveň ta nejbezpečnější, protože vývojáři si vždycky najdou cestu s nejmenším odporem, obzvlášť když hoří deadline a zákaznický portál musí být venku do pátku.
Monorepo proto musí mít pár věcí naprosto jasně. Které packages jsou v rámci repa veřejné a které jsou jen privátní implementační detail. Které appky smějí používat které sdílené knihovny. Které týmy můžou sahat na základní tooling. Které kontroly běží před merge a které chyby jsou tvrdý blok. Jestli tyhle odpovědi bydlí v Notionu, už jste prohráli. Musí být v package.json, pnpm-workspace.yaml, turbo.json, go.work, CODEOWNERS, lint pravidlech a v CI pipeline, která selže nahlas.
Repo nezůstane rozumné samo od sebe. Zůstane rozumné jen tehdy, když jsou pravidla zapsaná tam, kde je vývojáři nemůžou ignorovat.
pnpm s jasně danými hranami
pnpm je pořád správná volba pro velké frontendové repo, kde je mix všeho možného, protože workspace model odměňuje disciplínu, místo aby bordel v dependency graphu schovával za hoisting. npm se zlepšil, Yarn pořád existuje, Bun je rychlý a pro velké týmy pořád trochu pohyblivý cíl, pnpm je pořád ten nástroj, kterému věřím ve chvíli, kdy na sobě visí dvacet packages a chci, aby chyby vylezly brzo.
Náš root pnpm-workspace.yaml zůstává nudný:
1packages:2 - "apps/*"3 - "packages/*"4 - "tooling/*"5 - "services/web-*"
Většinu pravidel pak hlídá root package.json:
1{2 "name": "acme-monorepo",3 "private": true,4 "packageManager": "pnpm@8.15.7",5 "engines": {6 "node": ">=22.11.0",7 "pnpm": ">=8.15.0"8 },9 "scripts": {10 "build": "turbo run build",11 "dev": "turbo run dev --parallel",12 "lint": "turbo run lint",13 "test": "turbo run test",14 "typecheck": "turbo run typecheck",15 "check:deps": "pnpm -r exec npm-package-json-lint .",16 "check:boundaries": "pnpm -r exec depcruise src --config ../../tooling/dependency-cruiser.cjs"17 },18 "pnpm": {19 "overrides": {20 "react": "19.1.0",21 "react-dom": "19.1.0"22 },23 "packageExtensions": {24 "some-bad-package@*": {25 "peerDependencies": {26 "react": ">=18 <20"27 }28 }29 }30 }31}
Pár názorů natvrdo. Připněte verzi package manageru. Připněte Node. pnpm.overrides používejte na konzistenci napříč repem, když musí zůstat zarovnaná celá rodina packages, hlavně React a @types/react během upgrade. Nenechte appky spoléhat na náhodu v transitivních závislostech. Jestli apps/portal používá zod, deklarujte zod v apps/portal/package.json, i když na něm závisí i @acme/ui. pnpm skryté coupling potrestá, a to je přesně dobře.
U interních packages používáme všude workspace protocol:
1{2 "name": "@acme/ui",3 "version": "0.12.0",4 "private": false,5 "peerDependencies": {6 "react": ">=18 <20",7 "react-dom": ">=18 <20"8 },9 "dependencies": {10 "clsx": "^2.1.1"11 },12 "devDependencies": {13 "react": "workspace:^",14 "react-dom": "workspace:^",15 "typescript": "workspace:^"16 }17}
To workspace:^ je důležité. Interní packages jedou podle verzí řízených repem a peer dependencies nechávají vlastnictví Reactu na úrovni appky, kde má být. Když tohle přeskočíte, dřív nebo později narazíte na Invalid hook call. Hooks can only be called inside of the body of a function component. a pak strávíte půl dne zjišťováním, že máte v graphu dvě kopie Reactu.
turbo musí zůstat nudné
Turborepo je nejlepší ve chvíli, kdy funguje jako předvídatelný task graph a koordinátor cache, ne jako magická meta-build vrstva, které půlka týmu tak nějak mlhavě rozumí. Viděl jsem týmy, které do Turbo tasků nacpaly deployment logiku, tahání secrets, codegen, migrace a podivné shell wrappery, až se z turbo run build stalo malé náboženství. Pak se změnil jeden cache key, CI zpomalilo a nikdo neuměl říct proč.
Pipeline držte utaženou. U nás typicky vypadá takhle:
1{2 "$schema": "https://turbo.build/schema.json",3 "globalDependencies": [4 "pnpm-lock.yaml",5 "tsconfig.base.json",6 ".nvmrc",7 ".env.example"8 ],9 "tasks": {10 "build": {11 "dependsOn": ["^build"],12 "inputs": ["src/**", "package.json", "tsconfig.json", "next.config.*"],13 "outputs": ["dist/**", ".next/**", "build/**"]14 },15 "typecheck": {16 "dependsOn": ["^typecheck"],17 "inputs": ["src/**", "package.json", "tsconfig.json"]18 },19 "lint": {20 "dependsOn": ["^lint"],21 "inputs": ["src/**", "package.json", ".eslintrc.*", "eslint.config.*"]22 },23 "test": {24 "dependsOn": ["^build"],25 "inputs": ["src/**", "test/**", "vitest.config.*", "package.json"],26 "outputs": ["coverage/**"]27 },28 "dev": {29 "cache": false,30 "persistent": true31 }32 }33}
Remote caching je moment, kdy monorepo začne opravdu vracet hodnotu, hlavně když CI běží na každý pull request a půlka packages se ani nezměnila. Vercel Remote Cache funguje, self-hosted cache funguje, obojí je v pohodě. Důležité je, aby cache misses dávaly smysl. Když build task čte generované soubory, které nemáte deklarované v inputs, cache vám lže. Když task zapisuje do náhodných temp adresářů mimo outputs, cache je neúplná. Lidi pak přestanou věřit zeleným buildům, a jakmile tahle důvěra zmizí, začnou všechno pouštět lokálně znovu, čímž se celý point rozpadá.
Turbo navíc držíme čistě na JavaScript věcech. Go služby si nehrají na Turbo packages. Můžou se spouštět přes top-level scripty nebo CI matrix a tohle oddělení snižuje podivné coupling mezi runtime. Repo může být jeden celek, aniž by každý nástroj předstíral, že celé repo je jeho přirozené prostředí.
go.work jako nástroj pro hranice
Spousta týmů bere go.work jen jako convenience soubor pro lokální vývoj, což samozřejmě je, ale skončit u toho znamená minout to důležité. Je to také způsob, jak úplně jasně říct: tohle jsou oddělené moduly, vyvíjejí se nezávisle a lokální skládání dohromady nesmí tyhle hranice rozmazat.
Ořezaný příklad:
1go 1.22.623use (4 ./services/billing5 ./services/docproc6 ./services/auth7 ./libs/go/httpx8 ./libs/go/events9)
Každá služba má pořád svoje vlastní go.mod:
1module github.com/acme/monorepo/services/docproc23go 1.22.645require (6 github.com/acme/monorepo/libs/go/events v0.0.07 github.com/jackc/pgx/v5 v5.7.28)
Aby tohle zůstalo rozumné, držíme dvě pravidla. Za prvé, sdílený Go kód patří do jasně pojmenovaných knihoven pod libs/go/*, nikdy dovnitř jiné služby. Jestli services/billing/internal/pdf obsahuje užitečný kód a services/docproc ho importuje přes nějaký hacknutý replace directive, je to selhání procesu, ne chytrá zkratka. Go vám dává hranice přes internal packages z nějakého důvodu. Používejte je. Za druhé, každá služba se musí v CI buildit a testovat ve vlastním module contextu, ne jen přes workspace. Lokální go.work umí schovat chybějící deklarace verzí, náhodné replace a importy, které fungují jen proto, že váš notebook vidí celé repo.
Tu klasickou chybu jsme viděli dostkrát:
1package github.com/acme/monorepo/services/auth/internal/jwt is not in std
Někdy je to špatná import cesta, jindy do kódu proteče předpoklad, který platí jen ve workspace, i když se má vše kompilovat izolovaně. V obou případech je oprava strukturální. Sdílený kód se přesune do sdíleného modulu, závislost se správně deklaruje a služba znovu získá svoji autonomii.
Tohle je čím dál důležitější s růstem týmu. Ve chvíli, kdy jedna část lidí shipuje document processing pipeline v Go a jiný tým udržuje Django admin a zákaznický portál v Next.js, sdílené repo potřebuje silnější mechanickou disciplínu, ne slabší. go.work vám dá lokální ergonomii bez toho, aby obětoval hranice mezi moduly, pokud odoláte pokušení slít všechno do jednoho obřího Go modulu.
sdílené ui potřebuje disciplínu ve verzích
Sdílené UI knihovny bývají místo, kde frontendové monorepo začne hnít jako první, protože reuse chce každý a kompatibilitu nechce vlastnit skoro nikdo. Balíček s buttony začne nenápadně, pak do něj někdo přidá navigation závislou na auth, potom feature flags specifické pro jednu appku, potom form wrapper, který funguje jen s jedním backend contractem, a najednou je z @acme/ui odkládací šuplík se semver číslem.
Sdílené UI držte agresivně hloupé. Design tokens, primitives, skládací patterns, možná tenkou vrstvu opinionated komponent, pokud za vaším design systémem opravdu něco je. Byznys workflow patří do app packages. Jestli komponenta potřebuje vědět něco o tenant billing plánu, do sdílené knihovny nepatří.
Podporujeme jak Next.js 15 appky, které ještě jedou na React 18, tak novější appky už na React 19, a to přes peer ranges a přísná release pravidla:
1{2 "name": "@acme/ui",3 "version": "0.12.0",4 "exports": {5 ".": {6 "types": "./dist/index.d.ts",7 "import": "./dist/index.js"8 },9 "./styles.css": "./dist/styles.css"10 },11 "peerDependencies": {12 "react": ">=18 <20",13 "react-dom": ">=18 <20"14 },15 "sideEffects": [16 "**/*.css"17 ]18}
Strategie verzování je schválně přímočará. Patch na opravy chyb bez změny API nebo markup contractu. Minor na nové komponenty nebo props. Major na jakékoliv breaking API, změnu DOM struktury, která rozbije testy, změnu CSS contractu nebo změnu minimální verze Reactu. Když package sdílí víc app, které vydělávají peníze, major bump vyžaduje aspoň jeden upgrade PR, který dokáže, že cena migrace je přijatelná. Žádná hypotetická tvrzení o kompatibilitě.
Na úrovni lintu navíc zakazujeme importy z app do sdílených packages. @acme/ui nesmí importovat next/navigation, nesmí importovat apps/*, nesmí importovat config závislý na prostředí. Když komponenta potřebuje router adapter, dostane ho zvenku. První týden je to lehce otravné, za půl roku dramaticky čistší. Buď uhlídáte směr závislostí, nebo ten směr neuhlídá nic.
ci brány, které mají smysl
Většina monorepo CI pipeline je hlučná, protože kontroluje moc věcí, ale těch špatných, a málo těch správných. Užitečná pipeline má rychle odpovědět na to, jestli změna porušila hranice závislostí, rozbila izolované buildy nebo zavedla drift ve sdílených základech.
Základní brány, které používáme, jsou malé. Za prvé, frozen installs, vždycky.
1- name: Install JS deps2 run: pnpm install --frozen-lockfile
Za druhé, affected checks kvůli rychlosti, full checks na chráněných branchech kvůli jistotě.
1- name: Turbo build2 run: pnpm turbo run build test lint typecheck --filter=...[origin/main]
Za třetí, Go služby se buildí ve svých vlastních module adresářích, ne přes magii go.work.
1- name: Test Go services2 run: |3 for d in services/*; do4 if [ -f "$d/go.mod" ]; then5 echo "testing $d"6 (cd "$d" && GOWORK=off go test ./... && GOWORK=off go build ./...)7 fi8 done
Flag GOWORK=off odhalí spoustu věcí. Za čtvrté, kontroly dependency boundaries. Na TS import pravidla typicky používáme dependency-cruiser a pár vlastních scriptů na package policy, třeba zákaz přímých importů mezi app adresáři nebo undeclared dependencies nalezené přes pnpm list --depth -1 plus validaci package manifestů.
Za páté, ownership. CODEOWNERS působí nemoderně přesně do chvíle, než sdílený package rozbije tři appky, protože PR nereviewoval nikdo, kdo mu opravdu rozumí. Vyžadujeme vlastníky pro packages/ui/**, tooling/** a root config soubory. Změna turbo.json, root TypeScript configu nebo lockfile strategie nesmí být drive-by commit.
Ještě jedna věc, která se vrátí rychle, kontroly driftu lockfile. Když PR změní kdekoliv package.json a ne pnpm-lock.yaml, fail. Když sdílený package změní peer dependency ranges bez release note nebo changesetu, fail. Když se změní go.mod v Go knihovně a downstream služby projdou jen s aktivním go.work, fail. Nejsou to sexy kontroly, nebudete o nich mluvit na konferenci, ale přesně ony týmu zachrání středeční odpoledne, které by jinak padlo na rozmotávání náhodného coupling, jež mělo umřít už v pull requestu.
