# Un monorepo cu trei aplicații: cum construim doar ce s-a schimbat și am dus pipeline-ul de la opt minute la trei

Trei aplicații Next.js, patru pachete partajate, un singur repository. Asta e forma platformei pe care o rulăm pentru un client din SUA, și e forma corectă: tipuri partajate, un singur lockfile, un singur pull request pentru o schimbare care atinge API-ul și cele două front-end-uri care îl consumă. Partea greșită era pipeline-ul, care reconstruia toate cele trei aplicații la fiecare commit, inclusiv la un commit care schimba un README.

Opt minute per push. Trei job-uri CodeBuild, trei build-uri Docker, trei push-uri de imagine, indiferent dacă se schimbase ceva în aplicațiile alea sau nu. Iată cum am ajuns la trei minute pe un commit tipic și sub un minut pe un commit doar cu documentație, cu tooling-ul care a făcut-o, capcana în care am căzut și singurul caz în care reconstruirea a tot e corectă.

## Structura

```
.
├── apps/
│   ├── web/          # aplicația Next.js publică
│   ├── admin/        # aplicația Next.js internă
│   └── api/          # route handler-e Next.js, desfășurate ca serviciu propriu
├── packages/
│   ├── db/           # schemă, migrări, helper-e de query
│   ├── ui/           # componente partajate
│   ├── config/       # preset-uri eslint, tsconfig, tailwind
│   └── types/        # tipuri TypeScript partajate, client de API generat
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml
```

pnpm workspaces pentru graful de pachete, Turborepo pentru graful de task-uri. Fiecare aplicație depinde de un subset al pachetelor; `web` și `admin` depind de `ui`, toate trei depind de `db` și `types`, totul depinde de `config`. Graful ăla de dependențe e tot input-ul pentru „ce s-a schimbat”.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Graf de dependențe. Aplicațiile web, admin și api sus. Pachetele ui, db, types și config dedesubt. web și admin depind de ui; toate trei aplicațiile depind de db și types; totul depinde de config. O schimbare în ui marchează web și admin ca afectate, nu api. O schimbare în db le marchează pe toate trei. O schimbare într-un README nu marchează nimic.">
<defs><marker id="arrG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="120" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="195" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/web</text>
<rect x="375" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="450" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/admin</text>
<rect x="630" y="30" width="150" height="50" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="705" y="60" text-anchor="middle" fill="#f1f3ff" font-weight="700">apps/api</text>
<rect x="40" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="180" text-anchor="middle" fill="#f1f3ff">packages/ui</text>
<rect x="260" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="335" y="180" text-anchor="middle" fill="#f1f3ff">packages/db</text>
<rect x="480" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="555" y="180" text-anchor="middle" fill="#f1f3ff">packages/types</text>
<rect x="700" y="150" width="150" height="50" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="775" y="180" text-anchor="middle" fill="#f1f3ff">packages/config</text>
<line x1="170" y1="82" x2="125" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="200" y1="82" x2="320" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="230" y1="82" x2="530" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="410" y1="82" x2="140" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="440" y1="82" x2="345" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="470" y1="82" x2="545" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="690" y1="82" x2="360" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="705" y1="82" x2="570" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<line x1="740" y1="82" x2="770" y2="148" stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrG)"/>
<text x="450" y="232" text-anchor="middle" fill="#9aa3c7">schimbi packages/ui → build web + admin · schimbi packages/db → build toate trei · schimbi README → build nimic</text>
</g>
</svg>
</div>

## Pasul 1: lași Turborepo să decidă ce e afectat

Turborepo cunoaște deja graful. Sintaxa `--filter` cu un interval git îl întreabă ce workspace-uri s-au schimbat, direct sau printr-o dependență, de la un commit încoace:

```bash
# ce aplicații au nevoie de build pentru push-ul ăsta?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'
```

`--affected` compară arborele de lucru cu `origin/main` implicit (configurabil cu `TURBO_SCM_BASE`), parcurge graful de dependențe și întoarce aplicațiile care depind tranzitiv de ceva ce s-a schimbat. O schimbare în `packages/ui` întoarce `web` și `admin`. O schimbare în `apps/api/app/orders/route.ts` întoarce `api`. O schimbare în `README.md` nu întoarce nimic.

Scriptul de deploy citește lista aia și pornește un job CodeBuild per aplicație din ea, în paralel, în loc să pornească mereu trei. Asta e toată schimbarea din fluxul de control al pipeline-ului: zece linii de shell care înlocuiesc o listă hard-codată de trei.

Două lucruri pe care Turborepo le consideră „afectează tot”, corect: lockfile-ul și config-ul rădăcină. Un bump de dependență în `pnpm-lock.yaml` reconstruiește toate cele trei aplicații, pentru că oricare ar fi putut prelua noua versiune. La fel o schimbare în `turbo.json` sau în `package.json`-ul rădăcină. Am încercat o dată să excludem lockfile-ul ca să accelerăm un bump de rutină și am livrat o aplicație cu o dependență veche. Nu face asta.

## Pasul 2: cache-uiești output-urile de build, remote

În interiorul build-ului fiecărei aplicații, Turborepo cache-uiește output-urile task-urilor indexate după un hash al input-urilor: fișiere sursă, dependențe, variabile de mediu pe care le declari. Un al doilea build cu aceleași input-uri restaurează output-ul din cache în loc să ruleze `next build`. Local e automat. În CI, fiecare job CodeBuild pornește de pe un disc gol, deci cache-ul trebuie să trăiască undeva partajat.

Rulăm un mic remote cache self-hosted, o implementare open-source a API-ului de remote cache Turborepo, pe un `t4g.nano` care scrie în S3. Fiecare job CodeBuild și fiecare mașină de developer arată spre el:

```json
// .turbo/config.json, sau prin env în CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
```

```bash
# în buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web
```

Efectul: `packages/types` și `packages/db` sunt construite o singură dată, de job-ul care ajunge primul, și fiecare alt job le restaurează într-o secundă. Un build de `web` care a schimbat o pagină restaurează pachetele partajate din cache și rulează `next build` doar pentru `web` în sine. Remote cache-ul costă $3 pe lună pentru instanță și bănuți pentru S3.

## Pasul 3: cache-uiești și layer-ele Docker

Build-ul Docker e cealaltă jumătate a timpului. O [imagine Node multi-stage](/ro/blog/docker-images-for-node-in-2026-distroless-multi-stage) are un layer scump de `pnpm install` care se schimbă doar când se schimbă lockfile-ul, și un layer de `next build` care se schimbă la fiecare commit. BuildKit poate exporta cache-ul de layer-e într-un registry și îl poate importa la următorul build:

```bash
docker buildx build \
  --cache-from type=registry,ref=$ECR/web:buildcache \
  --cache-to   type=registry,ref=$ECR/web:buildcache,mode=max \
  --build-arg RELEASE_SHA=$SHA \
  -t $ECR/web:$SHA --push .
```

Cu cache-ul cald, layer-ul de `pnpm install` e restaurat în zece secunde în loc să ruleze nouăzeci. Layer-ul de `next build` tot rulează, dar cache-ul Turborepo din interiorul lui înseamnă că în mare parte restaurează și el. `mode=max` cache-uiește etapele intermediare, ceea ce face etapa de builder refolosibilă; imaginea de cache trăiește în ECR sub un tag fix și costă câteva sute de megabyți de stocare.

## Cifrele

| Tip de commit | Înainte | După | Ce rulează |
|---|---|---|---|
| Doar documentație, infra, config CI | 8 min | 40 s | Turborepo zice că nimic nu e afectat; scriptul de deploy sare toate build-urile; doar CDK diff |
| O pagină în `apps/web` | 8 min | 2 min 50 s | Un job CodeBuild; pachetele partajate din remote cache; layer-ul Docker de install din cache-ul de registry |
| Schimbare în `packages/ui` | 8 min | 3 min 20 s | Două job-uri în paralel, `web` și `admin`; `api` neatins |
| Schimbare în `packages/db` | 8 min | 3 min 40 s | Trei job-uri în paralel; cache-urile tot lovesc pentru layer-ele de install |
| Bump de lockfile | 8 min | 6 min | Trei job-uri; layer-ul de install reconstruit peste tot; cache-ul Turborepo invalidat; corect |

Commit-ul median e al doilea rând. De la opt minute la sub trei, și, pentru că job-urile care rulează sunt în paralel, *maximul* e acum cazul de lockfile la șase, nu vechiul opt de la fiecare commit.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Grafic cu bare al duratei pipeline-ului pe tip de commit, înainte și după. Înainte: 8 minute pentru orice tip. După: 40 de secunde doar documentație, 2 minute 50 pentru o schimbare într-o aplicație, 3 minute 20 pentru o schimbare în pachetul ui, 3 minute 40 pentru o schimbare în pachetul db, 6 minute pentru un bump de lockfile.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Durata pipeline-ului pe tip de commit</text>
<g fill="#9aa3c7">
<text x="20" y="60">doar docs</text><rect x="180" y="48" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="58" width="40" height="8" rx="2" fill="#4fffb0"/><text x="670" y="60" fill="#f1f3ff">8:00 → 0:40</text>
<text x="20" y="94">o aplicație</text><rect x="180" y="82" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="92" width="170" height="8" rx="2" fill="#4fffb0"/><text x="670" y="94" fill="#f1f3ff">8:00 → 2:50</text>
<text x="20" y="128">packages/ui</text><rect x="180" y="116" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="126" width="200" height="8" rx="2" fill="#4fffb0"/><text x="670" y="128" fill="#f1f3ff">8:00 → 3:20</text>
<text x="20" y="162">packages/db</text><rect x="180" y="150" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="160" width="220" height="8" rx="2" fill="#4fffb0"/><text x="670" y="162" fill="#f1f3ff">8:00 → 3:40</text>
<text x="20" y="196">bump de lockfile</text><rect x="180" y="184" width="480" height="8" rx="2" fill="#2a3150"/><rect x="180" y="194" width="360" height="8" rx="2" fill="#ffd166"/><text x="670" y="196" fill="#f1f3ff">8:00 → 6:00 · corect</text>
</g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">gri: înainte, fiecare commit · verde: după · galben: cazul care ar trebui să reconstruiască tot, și o face</text>
</g>
</svg>
</div>

## Capcana: o aplicație afectată care n-a fost construită

După două luni, un deploy a livrat `admin` fără o schimbare de care `admin` avea nevoie. Schimbarea era în `packages/types`, un client de API generat, iar `admin` depinde de `types`, deci Turborepo ar fi trebuit s-o semnaleze. N-a făcut-o, pentru că clientul generat era produs de un script care rula *în afara* grafului de task-uri Turborepo, scriind fișiere în `packages/types/generated/`, care era în `.gitignore`. Turborepo hash-uiește input-uri urmărite. Output-ul generat nu era urmărit, deci din punctul lui de vedere `types` nu se schimbase.

Rezolvarea a fost să facem generarea un task Turborepo cu output-uri declarate, ca hash-ul să includă input-urile generatorului (spec-ul OpenAPI) și output-ul să fie cache-uit și restaurat ca oricare altul. Lecția se generalizează: orice produce input-uri de build trebuie să fie în interiorul grafului, altfel graful te minte. Am auditat fiecare script din `package.json` pentru același pattern și am mai găsit unul.

## Când reconstruiești tot oricum

- S-a schimbat lockfile-ul sau config-ul rădăcină. Turborepo face asta singur; nu suprascrie.
- Un bump al imaginii de bază în Dockerfile-uri. Dockerfile-ul e un input pentru cache-ul Docker, nu pentru Turborepo, deci facem grep pe diff după `Dockerfile` și forțăm toate trei.
- Un tag de release. Fiecare release cu tag reconstruiește toate trei de la zero, fără cache-uri, ca artefactul pentru o versiune să fie reproductibil din sursă și nu din ce s-a nimerit să fie în cache în ziua aia. Durează opt minute, o dată pe săptămână, și e build-ul pe care l-am vrea dacă ar trebui vreodată să explicăm exact ce s-a livrat.

## Versiunea scurtă

Lasă unealta care cunoaște deja graful de dependențe să decidă ce se construiește. Pune cache-ul de task-uri undeva unde ajunge fiecare builder. Cache-uiește layer-ul Docker scump în registry. Ține fiecare generator în interiorul grafului. Reconstruiește tot la schimbări de lockfile și la tag-uri de release, intenționat. De la opt minute la trei, pentru o zi de configurare și $3 pe lună.

Dacă monorepo-ul tău construiește tot la fiecare push, [putem lega asta într-o zi](/contact); partea de Turborepo e o oră, cache-ul Docker e celelalte șapte.
