Un monorepo e un singur repository git care conține mai multe proiecte: de exemplu magazinul, panoul de administrare și librăriile pe care le împart. Nu e un monolit — fiecare aplicație se construiește și se deployează separat. Doar codul stă la un loc, cu o singură versiune de Angular și o singură istorie de commit-uri.
Câștigul: o schimbare care atinge o librărie și cele două aplicații care o folosesc intră într-un singur PR, testat împreună. Costul: fără unelte, fiecare PR reconstruiește și retestează tot; fără reguli, oricine importă orice. Pagina acoperă ambele jumătăți: workspace-ul cu Angular CLI și ng-packagr, apoi ce adaugă Nx peste el — graful de proiecte, affected, cache-ul și granițele verificate la lint.
- Un monorepo ține mai multe aplicații și librării în același repository, cu un singur set de versiuni pentru dependențe.
- Angular CLI suportă nativ mai multe proiecte într-un workspace (
ng generate library). Nx adaugă graful dependențelor, granițe și cache. - O librărie are un API public: ce nu e exportat din fișierul ei de intrare rămâne intern.
- „Affected” rulează build-ul și testele doar pentru ce a fost atins de o schimbare.
- De cele mai multe ori un folder ajunge. O librărie se justifică atunci când e publicată sau folosită de mai multe aplicații.
„Folder, librărie sau pachet npm” e o decizie de arhitectură, nu de stil. Ea stabilește cât de repede livrezi o schimbare transversală și cât de ușor strici ceva fără să observi. Documentația Angular o spune direct: o librărie te obligă să decuplezi codul de logica aplicației, dar cere timp de întreținere — se amortizează când o folosesc mai multe aplicații.
Monorepo, polyrepo, monolit
Cele trei modele diferă mai ales prin costul unei schimbări transversale. Să zicem că modelul Product primește un câmp nou, folosit de librăria de UI și de ambele aplicații:
| Model | Cum arată | Schimbarea „Product” | Prețul |
|---|---|---|---|
| Monolit | O aplicație, un deploy, foldere pe domenii | 1 PR, trivial | Nu servește două aplicații cu ritmuri diferite |
| Polyrepo + npm | Fiecare librărie în repo-ul ei, publicată cu versiune | 3+ PR-uri: lib → publish → bump în fiecare aplicație | Versiuni care diverg; un fix ajunge în producție când „își aduce aminte” fiecare echipă |
| Monorepo | Aplicații + librării în același repo, consumate din workspace | 1 PR atomic, testat cu toți consumatorii | CI-ul trebuie să fie inteligent (affected, cache) și granițele trebuie impuse |
Un workspace Angular CLI are un singur package.json la rădăcină, deci toate proiectele folosesc aceeași versiune de Angular, RxJS sau CDK — ceea ce Nx numește single version policy. Un ng update actualizează tot repo-ul o dată. Prețul e coordonarea: nicio echipă nu rămâne în urmă, dar nici nu poate fugi înainte. Dacă echipele chiar trebuie să livreze independent, cu ritmuri și versiuni diferite, pasul următor sunt micro frontend-urile (care, ironic, trăiesc des tot într-un monorepo).
Workspace multi-proiect cu Angular CLI
ng new creează implicit o aplicație chiar la rădăcină. Pentru un monorepo pornești de la un workspace gol (--no-create-application), iar fiecare ng generate application sau ng generate library adaugă un proiect în projects/ și o intrare în angular.json. O librărie cu scope (@acme/ui) ajunge în projects/acme/ui, iar numele proiectului e chiar numele pachetului.
# 1. Workspace gol: fără aplicație la rădăcină (createApplication: false)
ng new acme --no-create-application
cd acme
# 2. Proiectele ajung în projects/ (newProjectRoot)
ng generate application shop
ng generate application admin
ng generate library @acme/ui # → projects/acme/ui, prefix implicit „lib”
# 3. Fiecare comandă primește numele proiectului
ng serve shop
ng build admin
ng test @acme/ui
ng build @acme/ui --watch # rebuild incremental cât lucrezi la librărieDiferența importantă e în angular.json: aplicațiile folosesc builder-ul @angular/build:application (esbuild), librăriile @angular/build:ng-packagr, care produce un pachet în Angular Package Format și compilează mereu AOT. Pentru librării, configurația implicită e production.
{
"newProjectRoot": "projects",
"projects": {
"shop": {
"projectType": "application",
"root": "projects/shop",
"sourceRoot": "projects/shop/src",
"architect": {
"build": { "builder": "@angular/build:application" },
"serve": { "builder": "@angular/build:dev-server" }
}
},
"@acme/ui": {
"projectType": "library",
"root": "projects/acme/ui",
"sourceRoot": "projects/acme/ui/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"defaultConfiguration": "production",
"configurations": {
"production": { "tsConfig": "projects/acme/ui/tsconfig.lib.prod.json" },
"development": { "tsConfig": "projects/acme/ui/tsconfig.lib.json" }
}
}
}
}
}
}Evită prefixul ng- (e rezervat pentru framework). Pentru pachete publice convenția e ngx-; pentru cele interne, un scope de firmă (@acme/…) e cel mai clar și se potrivește cu registry-ul privat.
Anatomia unei librării
Patru fișiere definesc o librărie. Fiecare răspunde la o întrebare precisă:
ng-package.json— unde ajunge build-ul (dest) și care e fișierul cu API-ul public (lib.entryFile).package.json— numele, versiunea și dependențele.@angular/*stau înpeerDependencies: dacă ar fi îndependencies, aplicația ar putea primi o a doua copie de Angular și s-ar strica la runtime."sideEffects": falseîi spune bundler-ului că poate elimina tot ce nu importi.public-api.ts— contractul. Ce nu e exportat aici e privat, deci îl poți refactoriza fără să anunți pe nimeni.tsconfig.lib.prod.json—compilationMode: "partial": formatul partial-Ivy e stabil între versiuni de Angular, iar compilarea se termină în build-ul aplicației, cu versiunea ei de compilator. De aici regula: aplicația trebuie să aibă aceeași versiune de Angular sau una mai nouă decât cea cu care a fost construită librăria.
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/acme/ui",
"lib": {
"entryFile": "src/public-api.ts"
}
}Entry points secundare
Angular Package Format pune, în principal, un singur modul ES „plat” per entry point. Un bundler nu poate sparge un modul în mai multe chunk-uri, așa că entry point-ul e granularitatea minimă de lazy loading. De aceea Angular Material are câte un entry point per componentă (@angular/material/button, …/tabs). Al doilea motiv: izolezi API-uri speciale, cum e @acme/ui/testing, ca să nu ajungă din greșeală în producție.
projects/acme/ui/
├── ng-package.json → @acme/ui (entry point primar)
├── src/public-api.ts
├── button/
│ ├── ng-package.json → @acme/ui/button (entry point secundar)
│ └── src/public-api.ts
└── testing/
├── ng-package.json → @acme/ui/testing (harness-uri, mock-uri pentru teste)
└── src/public-api.ts
ng-packagr descoperă singur fiecare ng-package.json; nu înregistrezi nimic altundeva.O librărie cu un singur scop (un client HTTP, un set de utilitare) stă bine cu un singur entry point — și @angular/core are unul singur pentru runtime.
Consum, build și publicare
În același workspace nu trebuie să publici nimic, dar trebuie să construiești librăria înainte. ng generate library adaugă în tsconfig.json un alias care indică spre dist/, nu spre sursă. Motivul: aplicațiile și librăriile au build-uri diferite (esbuild vs ng-packagr), iar același TypeScript poate ieși ca JavaScript diferit. Documentația cere explicit ca alias-urile să indice spre librăria construită. În timp ce lucrezi, ng build @acme/ui --watch face rebuild incremental.
{
"compilerOptions": {
"paths": {
// adăugat automat de ng generate library — indică spre BUILD, nu spre sursă
"@acme/ui": ["./dist/acme/ui"],
// adăugat de tine, pentru entry points secundare
"@acme/ui/*": ["./dist/acme/ui/*"]
}
}
}Publicarea înseamnă build în configurația production (implicită) și npm publish din dist — publici pachetul generat, nu folderul sursă.
# Build de distribuție: configurația production (implicită pentru librării) → partial-Ivy
ng build @acme/ui
# Publici CONȚINUTUL din dist, nu folderul sursă
cd dist/acme/ui
npm publish # sau: npm publish --registry https://npm.firma.ro
# Consumatorul (alt repo) îl instalează ca pe orice pachet
npm install @acme/ui@^1.4.0| Consumatorul | Cum primește codul | Versiuni |
|---|---|---|
| Aplicație din același repo | Alias în tsconfig, mereu ultima versiune din branch | Nu ai nevoie: toți sunt pe HEAD |
| Alt repo / alt client | npm install @acme/ui din registry | Semver strict, changelog, peerDependencies corecte |
Generatorul @nx/angular:lib pune alias-ul în tsconfig.base.json spre sursă (libs/…/src/index.ts): librăria se compilează împreună cu aplicația, fără pasul de build separat. Build-ul propriu (--buildable / --publishable) îl adaugi doar pentru librăriile pe care le publici sau pentru build-uri incrementale. Nu amesteca cele două stiluri în același workspace fără să știi de ce.
Nx: graful și granițele
Nx folosește aceleași builder-e Angular, dar ține configurația fiecărui proiect în propriul project.json și construiește un graf de proiecte din analiza importurilor. Pe graful ăsta se sprijină tot restul: nx graph ți-l arată, affected îl parcurge invers, iar regula de lint îl verifică la fiecare import. Într-un workspace Angular CLI existent pornești cu npx nx@latest init.
# Workspace Angular CLI existent → Nx (păstrează structura)
npx nx@latest init
# …sau mută proiectele în apps/ și libs/
npx nx@latest init --integrated
# Librării cu tag-uri din prima zi
nx g @nx/angular:lib libs/shop/feature-cart --tags=scope:shop,type:feature
nx g @nx/angular:lib libs/shared/ui --tags=scope:shared,type:ui
nx g @nx/angular:lib libs/shared/ui --publishable --importPath=@acme/ui # doar dacă o publici
# Graful de proiecte, interactiv în browser
nx graph
nx graph --focus shop-feature-cart
# Rulează doar ce e afectat de schimbările față de main
nx affected -t lint test build
nx affected -t build --graph # vezi graful de task-uri înainte să rulezi
nx run-many -t test # tot, fără filtruTipuri de librării
Ghidul Nx propune patru tipuri — aceleași roluri din smart vs dumb și din structura de foldere, doar că acum granița e o librărie cu API public:
| Tip | Ce conține | Poate depinde de |
|---|---|---|
feature | Pagini și componente container pentru un caz de utilizare; de obicei lazy | orice tip |
ui | Componente prezentaționale; datele vin prin input-uri, fără servicii injectate | ui, util |
data-access | Comunicarea cu backend-ul și starea (store-uri) | data-access, util |
util | Funcții pure, adesea fără cod de framework | util |
acme/
├── nx.json ← cache, targetDefaults, defaultBase
├── tsconfig.base.json ← "@acme/shop/feature-cart": ["libs/shop/feature-cart/src/index.ts"]
├── apps/
│ ├── shop/project.json tags: scope:shop, type:app
│ └── admin/project.json tags: scope:admin, type:app
└── libs/
├── shop/
│ ├── feature-catalog/ tags: scope:shop, type:feature (pagini smart, lazy)
│ ├── feature-cart/ tags: scope:shop, type:feature
│ └── data-access/ tags: scope:shop, type:data-access (API + store)
├── admin/
│ ├── feature-users/ tags: scope:admin, type:feature
│ └── data-access/ tags: scope:admin, type:data-access
└── shared/
├── ui/ tags: scope:shared, type:ui (componente dumb)
├── data-access/ tags: scope:shared, type:data-access (auth, config HTTP)
└── util/ tags: scope:shared, type:util (funcții pure)Tag-uri pe două dimensiuni
Fiecare proiect primește tag-uri în project.json. Combinația care funcționează în practică e scope (domeniul: scope:shop, scope:admin, scope:shared) × type (stratul: type:feature, type:ui…). Scope-ul răspunde la „cine vorbește cu cine”, tipul la „în ce direcție curg dependențele”.
{
"name": "shop-feature-cart",
"sourceRoot": "libs/shop/feature-cart/src",
"projectType": "library",
"tags": ["scope:shop", "type:feature"]
}Regula @nx/enforce-module-boundaries
Regula de lint primește o listă depConstraints. Pentru un import, Nx ia toate constrângerile al căror sourceTag îl are proiectul sursă și le aplică cu ȘI: shop/feature-cart trebuie să satisfacă și regula de scope, și pe cea de tip. onlyDependOnLibsWithTags cere ca ținta să aibă cel puțin unul dintre tag-uri. Un proiect fără niciun tag potrivit nu are voie să depindă de nimic.
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
// Dimensiunea 1: domeniul (cine cu cine are voie să vorbească)
{ sourceTag: 'scope:shop', onlyDependOnLibsWithTags: ['scope:shop', 'scope:shared'] },
{ sourceTag: 'scope:admin', onlyDependOnLibsWithTags: ['scope:admin', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// Dimensiunea 2: tipul (în ce direcție curg dependențele)
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
],
},
],
},
},
];Pe lângă tag-uri, regula are și verificări fixe, care rulează înaintea lor. Trei contează zilnic, în ordinea asta: dependențe circulare, importul unei aplicații (interzis) și importul static al unei librării încărcate lazy (ar trage chunk-ul în bundle-ul inițial). Le vezi pe toate în demo:
scope:shopscope:sharedscope:adminlazy (loadChildren)
Aceeași regulă acceptă și forme mai fine:
// Alte forme utile de constrângeri (aceeași regulă, aceleași depConstraints):
[
// „ui” nu are voie să ajungă la data-access nici măcar TRANZITIV (verifică tot lanțul)
{ sourceTag: 'type:ui', notDependOnLibsWithTags: ['type:data-access'] },
// regula se aplică doar proiectelor care au AMBELE tag-uri
{ allSourceTags: ['scope:shop', 'type:ui'], onlyDependOnLibsWithTags: ['scope:shared'] },
// pachete npm interzise într-un tip de proiect
{ sourceTag: 'type:util', bannedExternalImports: ['@angular/*'] },
// glob și regex în tag-uri
{ sourceTag: 'scope:*', onlyDependOnLibsWithTags: ['/^scope:(shared|core)$/'] },
]Atenție la diferență: notDependOnLibsWithTags verifică tot lanțul de dependențe al țintei, nu doar ținta directă. E unealta potrivită pentru „UI-ul nu ajunge niciodată la data-access, nici prin ocolișuri”. Pentru granițele dintre foldere într-o singură aplicație, fără Nx, vezi Granițe.
Affected și cache
nx affected -t test pornește de la fișierele schimbate între --base (implicit defaultBase, adică main) și --head, le mapează pe proiecte, apoi urcă în graf pe muchiile inverse: tot ce depinde, direct sau tranzitiv, de un proiect schimbat e afectat. În workspace-ul din demo, o schimbare în shop/data-access afectează 4 proiecte (librăria, cele două feature-uri shop și aplicația shop); una în shared/util le afectează pe toate 10.
Peste asta vine cache-ul: înainte să ruleze un task, Nx calculează un hash din inputs — sursele proiectului, ale dependențelor (după inputs), configurația, comanda. Dacă hash-ul a mai fost văzut, Nx reface rezultatul (output-ul din terminal și fișierele) în loc să ruleze. Cu un remote cache, un task rulat o dată în CI nu mai rulează pe laptopul nimănui.
modificat ✎afectatrulat ✓din cache
Apasă pe un proiect din graf: îi modifici un fișier.
- Terminalul CI apare aici.
{
"defaultBase": "main",
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": { "cache": true }
}
}Dacă toate aplicațiile depind de o librărie shared uriașă, orice schimbare în ea afectează tot repo-ul și affected nu mai economisește nimic. Librării mai mici, pe domenii și tipuri, înseamnă grafuri mai înguste și mai multe cache hit-uri.
Cache-ul presupune task-uri fără efecte secundare: aceleași inputs dau același rezultat. Un e2e care lovește un backend real nu e cacheabil — rezultatul depinde de ceva ce Nx nu vede.
Folder sau librărie?
Ghidul Nx despre dimensiunea proiectelor rezumă bine compromisul. Spargi în librării când testele te încetinesc (affected și cache încep să sară peste bucăți), când codul are alt proprietar (o echipă, un CODEOWNERS) sau când vrei granițe verificate. Nu spargi ce se schimbă mereu împreună — granița costă navigare și configurare fără să cumpere nimic. Iar cât timp designul e în explorare, un singur proiect e mai ieftin; extragi librăria după ce API-ul s-a liniștit.
Un folder în feature (sau în shared/) cu un index.ts ca API public îți dă 80% din beneficii fără configurare. Extragi librăria când apare al doilea consumator.
src/app/features/cart/ui/ + regulă de lint pe importuriPentru varianta „folder”, regulile din Shared vs feature și un index.ts ca API public îți dau majoritatea beneficiilor, fără configurare suplimentară.
Când DA, când NU
- Două sau mai multe aplicații împart cod de domeniu sau un design system intern.
- Schimbări transversale dese (modele API, auth, temă) pe care vrei să le livrezi atomic, într-un PR.
- Mai multe echipe în același cod: tag-urile și regula de lint fac granițele explicite.
- CI lent:
affected+ cache reduc timpul proporțional cu ce ai atins. - Un design system consumat și din afara repo-ului: librărie publicabilă cu ng-packagr.
- O aplicație, o echipă: foldere pe domenii (Structura de foldere) și o regulă
no-restricted-importsîți dau aceleași granițe. - Echipe care trebuie să livreze complet independent, chiar cu versiuni diferite de Angular: polyrepo sau micro frontends.
- O librărie per componentă: configurare fără beneficiu. O librărie per domeniu și tip, nu per fișier.
- Publicare pe npm pentru cod folosit doar în repo: versiunile adaugă ceremonie fără valoare.
Capcane frecvente
"@acme/ui": ["projects/acme/ui/src/public-api.ts"] pare comod (nu mai construiești librăria), dar aplicația compilează codul cu esbuild, iar consumatorii externi primesc ce produce ng-packagr. Bug-urile apar doar la ei. Fix: alias spre dist și ng build @acme/ui --watch — sau treci pe Nx, gândit să compileze din sursă.
Librăria își aduce propria copie de Angular, iar aplicația ajunge cu două instanțe: DI-ul și detecția de schimbări se strică în moduri greu de diagnosticat. Fix: tot ce e @angular/* merge în peerDependencies.
- Importuri adânci (
@acme/ui/src/lib/button/button) care ocolescpublic-api.ts: primul refactor intern sparge consumatorii. Fix: exportă din API-ul public sau creează un entry point secundar; interzice tiparul cuno-restricted-imports. - Importuri relative între entry points ale aceleiași librării: ng-packagr construiește fiecare entry point separat și build-ul eșuează. Fix: importă prin numele pachetului (
@acme/ui). - Proiecte fără tag-uri, „rezolvate” cu
sourceTag: '*'→'*': regula nu mai verifică nimic. Fix:--tagsla generare și tag-urile verificate la review. - O librărie shared „ladă de vechituri” de care depinde tot: affected = tot repo-ul la fiecare PR. Fix: sparge-o pe domenii și tipuri.
- Cache pe task-uri nedeterministe (e2e pe un backend real, teste care citesc ora): Nx reface un rezultat care nu mai e adevărat. Fix: fără
cache: truepe acele target-uri sau inputs complete. - Import static peste un
loadChildren: chunk-ul lazy dispare în bundle-ul inițial. Regula Nx îl prinde; fără Nx, verifică bundle-ul (vezi Performanță).
Fișă rapidă
ANGULAR CLI (multi-proiect)
ng new acme --no-create-application workspace gol
ng g application shop | ng g library @acme/ui
ng build @acme/ui [--watch] librăriile se construiesc ÎNAINTE de consum
tsconfig paths → ./dist/acme/ui (+ "@acme/ui/*" pentru entry points secundare)
cd dist/acme/ui && npm publish doar ce iese din repo se publică
LIBRĂRIE
ng-package.json dest + lib.entryFile (secundar: doar "lib")
public-api.ts API-ul public — restul e privat
package.json peerDependencies pentru @angular/*, "sideEffects": false
compilationMode "partial" pentru npm
NX
nx graph graful de proiecte
nx affected -t lint test build doar proiectele afectate (dependenți tranzitivi incluși)
"cache": true în targetDefaults hash pe inputs → replay la a doua rulare
tags + @nx/enforce-module-boundaries → granițe verificate la lint
tipuri: feature → ui, data-access, util · ui → ui, util · data-access → data-access, util · util → util