Team bắt đầu với một repo chứa cả frontend, backend, và shared utilities. Rồi team tăng lên 15 người, tách 3 squad, backend chia 4 service. Mỗi lần merge PR cho service A, CI chạy test toàn bộ — pipeline 25 phút, queue dài. Ai đó đề xuất tách repo, ai đó phản đối vì share code khó.

Cuộc tranh luận monorepo hay polyrepo không có đáp án phổ quát — phụ thuộc quy mô team, mức coupling giữa các project, và khả năng đầu tư tooling. Bài này phân tích tradeoff thực tế của cả hai, tooling phổ biến, và những tình huống mà mỗi lựa chọn phù hợp hơn.


Hai mô hình, hai triết lý

Monorepo là một repository chứa nhiều project — có thể là nhiều service, nhiều thư viện, frontend và backend, thậm chí nhiều ứng dụng hoàn toàn khác nhau. Google nổi tiếng chạy gần như toàn bộ codebase trong một repo khổng lồ. Meta, Microsoft (Windows), Twitter cũng dùng monorepo ở các mức độ khác nhau. Điểm chung: mọi code nằm cùng một chỗ, mọi thay đổi có thể là một commit duy nhất xuyên nhiều project.

Polyrepo (hay multi-repo) là mô hình mỗi project có repo riêng. Service A một repo, service B một repo, thư viện shared một repo khác. Đây là mô hình tự nhiên nhất — hầu hết startup và team nhỏ bắt đầu với polyrepo vì GitHub/GitLab mặc định tạo repo per project, không cần tooling đặc biệt.

Cần phân biệt rõ ngay từ đầu: monorepo không phải monolith. Monolith là kiến trúc — toàn bộ ứng dụng deploy thành một đơn vị duy nhất. Monorepo là chiến lược quản lý source code — nhiều project độc lập nằm chung repo nhưng vẫn build, test, deploy riêng. Hoàn toàn có thể chạy microservices trong monorepo — mỗi service có Dockerfile riêng, pipeline riêng, deploy riêng. Ngược lại, polyrepo cũng có thể chứa monolith nếu toàn bộ code nằm trong một repo duy nhất và deploy thành một artifact.

Nhầm lẫn này dẫn đến phản xạ “monorepo = quay lại monolith = thụt lùi”, trong khi thực tế monorepo là công cụ tổ chức code, không quyết định kiến trúc runtime. Một ví dụ cụ thể: repo của Vercel chứa cả Next.js framework, Turborepo, và nhiều package khác — tất cả trong một monorepo, nhưng mỗi package build và publish độc lập, có version riêng, release cycle riêng. Đó là monorepo, không phải monolith.


Monorepo — khi mọi thứ ở cùng một chỗ

Atomic changes xuyên project

Đây là lợi thế lớn nhất và khó replicate nhất ở polyrepo. Khi thay đổi API của shared library, bạn cập nhật library và tất cả consumer trong cùng một PR. Reviewer thấy toàn bộ ảnh hưởng trong một diff — không cần mở 5 PR ở 5 repo rồi merge theo đúng thứ tự. CI chạy test cho cả library lẫn consumer, đảm bảo không break gì trước khi merge.

Trong polyrepo, cùng thay đổi đó yêu cầu: PR ở repo library, chờ merge, publish version mới, rồi PR ở từng repo consumer để bump version. Nếu consumer A bump nhưng consumer B chưa kịp, hai service chạy version khác nhau của library — có thể gây bug tinh vi mà chỉ lộ khi hai service giao tiếp với nhau. Đây là vấn đề “coordinated change” mà polyrepo giải quyết rất vất vả.

Code sharing tự nhiên

Trong monorepo, tạo shared package là chuyện vài phút — tạo thư mục packages/utils, export function, import từ service. Không cần publish lên npm registry, không cần version, không cần chờ CI publish. Workspace protocol của pnpm, yarn, hoặc npm workspaces cho phép import trực tiếp giữa các package trong cùng repo.

Điều này khuyến khích tái sử dụng code thay vì copy-paste. Khi validation logic cho email nằm trong packages/validators, mọi service dùng chung — sửa một chỗ, tất cả được cập nhật. Trong polyrepo, cùng logic đó thường bị copy vào từng repo vì effort publish shared package quá cao cho một function nhỏ. Sau 6 tháng, 5 repo có 5 version khác nhau của email validator, mỗi cái có bug riêng.

Tooling và convention thống nhất

Monorepo cho phép enforce convention từ một chỗ: một .eslintrc gốc, một tsconfig base, một Prettier config, một Dockerfile template. Developer chuyển từ project này sang project khác không phải “học lại” cách format code hay cấu trúc thư mục. Onboarding engineer mới cũng nhanh hơn — clone một repo, chạy một lệnh setup, tất cả project sẵn sàng.

CI/CD cũng đơn giản hơn ở khía cạnh configuration: một pipeline definition có thể phục vụ nhiều project với parameter hoá. Không cần maintain 10 file .github/workflows/ci.yml gần giống nhau ở 10 repo — mỗi lần thêm step mới (ví dụ security scan) chỉ sửa một chỗ.

Dependency update cũng hưởng lợi. Renovate hoặc Dependabot chạy trên một repo, tạo một PR update lodash cho toàn bộ workspace — thay vì 10 PR ở 10 repo mà mỗi cái phải review và merge riêng. Với thư viện có CVE cần patch gấp, monorepo cho phép update và deploy tất cả service trong một workflow thay vì chạy qua từng repo.

Thách thức của monorepo

Repo lớn chậm — đây là vấn đề thực sự khi repo đạt hàng trăm nghìn commit và hàng GB. git clone lần đầu có thể mất vài phút. git status, git log, git blame chậm dần. CI checkout tốn thời gian và bandwidth. Google giải quyết bằng hệ thống custom (Piper, CitC) thay vì Git. Với team dùng Git, shallow clone (--depth 1), partial clone (--filter=blob:none), và sparse checkout giảm thiểu nhưng không loại bỏ hoàn toàn.

Noise trong change history là vấn đề khác. Developer service A mở git log thấy hàng trăm commit của service B, C, D chen vào. PR feed trên GitHub đầy thay đổi không liên quan. Notification spam nếu watch repo. Giải pháp là dùng path filter khi xem log (git log -- packages/service-a/), nhưng trải nghiệm mặc định vẫn noisy hơn polyrepo.

Ownership ranh giới mờ nếu không có convention rõ ràng. Khi mọi thứ nằm chung repo, developer dễ sửa code của team khác mà không qua review đúng người. Một commit “sửa nhanh” ở shared library có thể break 3 service khác mà tác giả không biết. CODEOWNERS file giải quyết phần nào nhưng cần discipline để maintain — sẽ nói kỹ hơn ở phần sau.

CI chạy lâu nếu không có affected detection. Pipeline naive chạy test toàn bộ repo mỗi PR — với repo nhỏ thì chấp nhận được, với repo 20 project thì 30 phút pipeline là bottleneck nghiêm trọng. Đây là lý do tooling monorepo (Turborepo, Nx, Bazel) tồn tại — chúng giải quyết đúng bài toán “chỉ build và test những gì thay đổi”.

Một vấn đề ít được nhắc đến: merge conflict frequency tăng theo số developer commit vào cùng repo. Trong polyrepo, conflict chỉ xảy ra giữa developer cùng team sửa cùng file. Trong monorepo, file cấu hình gốc (package.json root, lock file, CI config) trở thành điểm xung đột khi nhiều team cùng thêm dependency hoặc sửa pipeline. Lock file đặc biệt problematic — pnpm-lock.yaml trong monorepo lớn có thể hàng chục nghìn dòng, merge conflict ở file này gần như không giải quyết thủ công được mà phải regenerate.


Polyrepo — khi mỗi team một vương quốc

Ownership rõ ràng

Mỗi repo có team sở hữu cụ thể. Quyền merge, quyền deploy, branch protection — tất cả scoped theo repo. Team A không thể vô tình merge code vào service của team B. Trách nhiệm rõ ràng: repo ai team đó lo, từ CI pipeline đến dependency update đến on-call.

Điều này đặc biệt quan trọng ở tổ chức lớn với nhiều team autonomous. Mỗi team có thể chọn tech stack riêng (service A dùng Go, service B dùng Python), release cadence riêng (team A deploy 5 lần/ngày, team B deploy weekly), và convention riêng mà không ảnh hưởng team khác. Không cần consensus toàn công ty về ESLint rules hay TypeScript version — mỗi team tự quyết trong phạm vi repo của mình.

Trong tổ chức có compliance requirement (SOC 2, PCI DSS), polyrepo cho phép audit trail sạch hơn — mỗi repo có access log riêng, commit history riêng, và deployment pipeline riêng. Auditor review một service cụ thể chỉ cần xem repo đó, không phải lọc qua hàng nghìn commit không liên quan trong monorepo.

CI/CD độc lập

Pipeline của repo A chỉ chạy khi repo A có thay đổi. Không ai phải chờ test của repo B pass. Build time nhanh vì scope nhỏ — chỉ build và test một project duy nhất. Deploy cũng độc lập hoàn toàn: merge vào main repo A trigger deploy service A, không liên quan gì đến service B.

Với team cần deploy independence nghiêm ngặt — ví dụ service payment không được deploy cùng lúc service notification vì blast radius — polyrepo enforce sự tách biệt này ở mức cấu trúc, không phụ thuộc vào discipline của developer.

Repo nhỏ, nhanh, gọn

Clone nhanh, git log sạch chỉ có commit liên quan, PR feed không bị noise. Developer mới join team chỉ cần clone repo của team mình — không cần tải về toàn bộ codebase công ty. IDE mở nhanh hơn, search nhanh hơn, file tree gọn hơn.

Khía cạnh bảo mật cũng đáng cân nhắc: trong polyrepo, access control scoped theo repo. Team chỉ có read access vào repo mình cần, không thấy code nhạy cảm của team khác (ví dụ billing logic, security module). Trong monorepo, mọi người có access vào toàn bộ codebase — fine-grained access control ở mức thư mục trong Git không phải native feature và cần tool bổ sung.

Thách thức của polyrepo

Dependency versioning là cơn đau lớn nhất. Khi service A dùng [email protected] và service B dùng [email protected], cả hai phải tương thích ngược. Nếu [email protected] có breaking change, service A bị kẹt ở version cũ cho đến khi có người update — và “có người” đó thường là không ai, vì không có incentive rõ ràng.

Diamond dependency là biến thể tệ hơn: service A dùng lib X v2 và lib Y v1, mà lib Y v1 cũng dùng lib X nhưng v1. Hai version của lib X xung đột trong cùng runtime — hoặc gây compile error, hoặc tệ hơn là compile thành công nhưng runtime behavior không nhất quán vì hai version serialize data khác nhau. Trong monorepo, vấn đề này lộ ngay lúc build vì tất cả dùng chung dependency tree. Trong polyrepo, nó ẩn cho đến khi hai service giao tiếp và data format không khớp — loại bug khó debug nhất vì mỗi service chạy đúng khi test riêng lẻ.

Code duplication là hệ quả tất yếu khi share code quá khó. Validation logic, error handling pattern, API client — những thứ lẽ ra dùng chung lại bị copy vào từng repo. Mỗi bản copy diverge theo thời gian, bug fix ở repo này không tự lan sang repo khác. Technical debt tích luỹ âm thầm.

Coordinated changes cực kỳ vất vả. Thay đổi API contract giữa service A và B yêu cầu: PR ở repo A (provider), deploy, PR ở repo B (consumer), deploy. Thứ tự quan trọng — deploy consumer trước provider sẽ break. Với thay đổi ảnh hưởng 5 service, bạn cần 5 PR merge theo đúng thứ tự — overhead coordination cao, dễ sai.

Tooling fragmentation là hệ quả dài hạn. Mỗi repo tự cấu hình ESLint, TypeScript, Docker, CI — ban đầu copy từ template nhưng dần diverge theo thời gian. Team A nâng TypeScript 5.4, team B vẫn ở 4.9 vì “không có thời gian update”. Khi engineer chuyển team, họ phải adapt với convention khác, config khác, thậm chí version khác của cùng tool. Có thể giải quyết bằng shared config package (ví dụ @myorg/eslint-config), nhưng lại quay về bài toán dependency versioning — team nào cũng phải bump package config khi có thay đổi.


Tooling monorepo hiện đại

Monorepo chỉ khả thi khi có tooling giải quyết hai bài toán cốt lõi: chỉ build/test những gì thay đổi (affected detection) và không build lại thứ đã build (caching). Không có hai thứ này, monorepo với 20 project sẽ có CI chậm hơn 20 repo riêng lẻ.

Turborepo

Turborepo (Vercel) tập trung vào đơn giản và nhanh. Nó hiểu dependency graph giữa các package trong workspace, chạy task theo đúng thứ tự, song song hoá task không phụ thuộc nhau. Cấu hình qua turbo.json — khai báo pipeline task (build, test, lint) và dependency giữa chúng.

Điểm mạnh lớn nhất của Turborepo là remote cache. Khi developer A đã build packages/ui với input hash X, kết quả được cache lên remote storage (Vercel hoặc self-hosted). Developer B clone repo, chạy build — Turborepo thấy input hash X đã có trong cache, skip build hoàn toàn, tải artifact từ cache. CI cũng tương tự — nếu packages/ui không thay đổi giữa hai PR, CI không build lại.

Turborepo phù hợp với ecosystem JavaScript/TypeScript — nó hiểu npm/yarn/pnpm workspace natively. Với team full-stack JS, setup Turborepo mất khoảng 30 phút và hiệu quả ngay lập tức.

Nx

Nx (Nrwl) mạnh hơn Turborepo về phân tích dependency và affected detection. Lệnh nx affected --target=test chỉ chạy test cho project bị ảnh hưởng bởi thay đổi hiện tại — Nx phân tích import graph để xác định project nào depend vào file đã sửa. Nếu bạn sửa packages/auth, Nx biết service-a import packages/auth nên cần test lại, nhưng service-b không import nên skip.

Nx cũng có remote cache (Nx Cloud), task orchestration, và generator (scaffold project mới theo template). Hỗ trợ nhiều ngôn ngữ — JavaScript, TypeScript, Go, Java, Python, Rust — qua plugin system. Với team multi-language monorepo, Nx linh hoạt hơn Turborepo.

Nhược điểm là learning curve cao hơn Turborepo. Config phức tạp hơn, concept nhiều hơn (project graph, task graph, executors, generators). Với team nhỏ chỉ cần “chạy nhanh hơn”, Turborepo có ROI tốt hơn. Với team lớn cần governance và affected detection chính xác, Nx đáng đầu tư.

Bazel

Bazel (Google) là build system hermetic — mọi build phải khai báo explicit input và output, không phụ thuộc vào trạng thái máy local. Nếu input giống nhau, output giống nhau bất kể chạy trên máy nào. Điều này cho phép cache cực kỳ chính xác và distributed build (chia build task ra nhiều máy).

Bazel hỗ trợ mọi ngôn ngữ nhưng yêu cầu viết BUILD file cho từng target — overhead cấu hình rất cao so với Turborepo hay Nx. Đổi lại, với repo cực lớn (hàng nghìn target, hàng triệu dòng code), Bazel scale tốt nhất vì hermetic build đảm bảo correctness mà cache-based system khó đạt được.

Phù hợp với tổ chức lớn (> 100 engineer) có team platform riêng để maintain BUILD config. Startup 10 người dùng Bazel là over-engineering — effort viết BUILD file lớn hơn thời gian tiết kiệm được từ caching.

So sánh nhanh

Turborepo dễ nhất — setup 30 phút, hiệu quả ngay cho JS/TS monorepo. Nx mạnh nhất về affected detection và multi-language — đầu tư learning curve nhiều hơn nhưng scale tốt hơn cho team lớn. Bazel chính xác nhất về caching nhờ hermetic build — nhưng overhead cấu hình rất cao, chỉ hợp lý khi repo cực lớn và có team platform dedicate. Lựa chọn phụ thuộc vào quy mô repo, số ngôn ngữ, và budget thời gian team sẵn sàng đầu tư vào tooling.

Lerna — di sản

Lerna từng là tool monorepo phổ biến nhất trong ecosystem JavaScript, đặc biệt cho việc quản lý versioning và publishing nhiều package từ một repo. Hiện tại Lerna đã chuyển sang maintain bởi Nx team, và bản chất là wrapper mỏng trên Nx. Task running và caching của Lerna giờ delegate cho Nx engine bên dưới. Nếu đang dùng Lerna cho project mới, nên cân nhắc chuyển thẳng sang Turborepo hoặc Nx — cả hai đều hiện đại hơn, có remote cache native, và được maintain tích cực hơn.


Task caching và remote cache

Caching là yếu tố quyết định monorepo có nhanh hay không. Ý tưởng đơn giản: mỗi task (build, test, lint) có input (source files, config, dependency versions) và output (artifact, test result). Hash input — nếu hash giống lần chạy trước, output không đổi, skip task và dùng kết quả cũ.

Local cache lưu trên máy developer — lần build thứ hai nhanh hơn lần đầu nếu source không đổi. Hữu ích khi developer switch branch rồi quay lại — package không thay đổi sẽ không build lại. Nhưng local cache không giúp CI vì mỗi CI run thường bắt đầu từ clean state, cache phải build lại từ đầu.

Remote cache lưu trên server chia sẻ — CI run A đã build packages/ui, CI run B (cho PR khác) không cần build lại nếu packages/ui không thay đổi. Developer clone repo fresh, chạy build — thay vì build 20 phút, tải cache 30 giây. Tiết kiệm có thể lên tới 80-90% thời gian CI với repo lớn.

Remote cache cần tin tưởng — nếu cache bị poison (artifact sai nhưng hash đúng), mọi build sau đó dùng artifact sai. Bazel giải quyết bằng hermetic build (hash bao gồm mọi input, kể cả compiler version). Turborepo và Nx dựa vào hash file content và config — đủ chính xác cho hầu hết trường hợp nhưng không hermetic 100%.

Một lưu ý thực tế: remote cache cần infra riêng. Turborepo dùng Vercel Remote Cache (miễn phí cho hobby, trả phí cho team). Nx dùng Nx Cloud. Self-host cũng được nhưng cần maintain storage backend (S3, GCS) và access control. Chi phí này nên tính vào quyết định adopt monorepo — nếu team không sẵn sàng trả tiền hoặc maintain remote cache, CI monorepo sẽ chậm hơn polyrepo đáng kể.


Code ownership trong monorepo

Khi mọi code nằm chung repo, ai có quyền sửa gì? Không có câu trả lời mặc định — mọi người đều có quyền commit vào bất kỳ file nào. Đây vừa là sức mạnh (ai cũng contribute được) vừa là rủi ro (ai cũng break được).

CODEOWNERS file là cơ chế phổ biến nhất. GitHub, GitLab, Bitbucket đều hỗ trợ — khai báo team hoặc cá nhân chịu trách nhiệm cho từng path:

# .github/CODEOWNERS
packages/payment/    @team-payment
packages/auth/       @team-auth
packages/ui/         @team-frontend
packages/shared/     @team-platform

Khi PR chạm vào packages/payment/, GitHub tự thêm @team-payment làm required reviewer. PR không merge được nếu thiếu approval từ owner — enforce ở platform level, không phụ thuộc vào discipline.

Tuy nhiên CODEOWNERS chỉ là nửa bài toán. Nó ngăn merge code chưa được review, nhưng không ngăn developer tạo PR sửa code team khác. Trong monorepo, việc developer team A sửa “nhanh” một function ở packages/shared rồi submit PR là rất tự nhiên — và nếu team platform approve quá dễ dãi, code quality của shared package sẽ degradate. Convention “owner team phải review kỹ” cần culture support, không chỉ tool.

Ngoài CODEOWNERS, một số team dùng thêm module boundary lint — Nx hỗ trợ @nx/enforce-module-boundaries rule, khai báo tag cho mỗi project và restrict import giữa các tag. Ví dụ: project tag scope:payment chỉ được import từ scope:shared, không được import trực tiếp từ scope:auth. Vi phạm bị lint rule chặn ngay trong IDE, không chờ đến code review. Đây là cách enforce architectural boundary bằng tooling thay vì chỉ dựa vào convention.


Dependency management nội bộ

Trong monorepo, dependency giữa các package thường dùng workspace protocol"@myorg/shared": "workspace:*" trong package.json (pnpm) hoặc tương đương yarn/npm. Package manager resolve dependency từ source trực tiếp trong repo thay vì tải từ registry. Thay đổi ở packages/shared phản ánh ngay lập tức khi build packages/service-a — không cần publish version mới.

Ưu điểm rõ ràng: không có version mismatch giữa consumer và provider. Nhưng cũng có nhược điểm: vì luôn dùng “latest” từ source, breaking change ở shared package ảnh hưởng ngay tất cả consumer. Nếu team shared library không chạy test consumer trước khi merge, họ có thể break production. Đây là lý do CI affected detection quan trọng — khi packages/shared thay đổi, CI phải test tất cả package depend vào nó.

Trong polyrepo, dependency nội bộ thường qua private registry (npm private, Artifactory, GitHub Packages). Package shared publish version mới (1.2.0 → 1.3.0), consumer bump version khi sẵn sàng. Semver cho phép consumer kiểm soát khi nào adopt breaking change — linh hoạt hơn nhưng cũng dẫn đến tình trạng consumer “mãi không bump” và chạy version cũ tháng trời.

Không có cách nào hoàn hảo — monorepo hy sinh flexibility để đổi lấy consistency, polyrepo hy sinh consistency để đổi lấy flexibility. Chọn cái nào phụ thuộc vào mức coupling giữa các project: coupling cao thì consistency quan trọng hơn (monorepo), coupling thấp thì flexibility quan trọng hơn (polyrepo).


CI/CD — affected builds hay build everything

CI monorepo naive (build everything) là anti-pattern. Với 10 project trong repo, mỗi PR trigger build 10 project — 9 trong số đó không liên quan. Thời gian CI tăng tuyến tính theo số project, developer chờ lâu, merge queue dài.

Affected-only build giải quyết bằng cách chỉ build và test project bị ảnh hưởng bởi thay đổi trong PR. Nx dùng import graph analysis, Turborepo dùng file hash comparison, Bazel dùng explicit dependency declaration. Kết quả tương tự: PR sửa packages/auth chỉ trigger build cho packages/auth và các project import nó — có thể chỉ 2 trong 10 project.

Nhưng affected detection có edge case. Thay đổi ở root config (tsconfig.base.json, .eslintrc, Dockerfile base) ảnh hưởng tất cả project — affected detection phải nhận ra điều này và trigger full build. Thay đổi ở CI config cũng vậy. Nếu tool affected detection không cover những trường hợp này, bạn có thể miss bug mà chỉ lộ khi config gốc thay đổi.

Deploy trong monorepo cũng cần granular. Merge vào main không nên deploy tất cả service — chỉ deploy service có thay đổi. Mỗi service cần deploy pipeline riêng, trigger bởi path filter: thay đổi trong packages/service-a/ trigger deploy service A, bỏ qua service B. GitHub Actions hỗ trợ path filter natively, GitLab CI dùng rules:changes.

CI polyrepo đơn giản hơn ở khía cạnh này — mỗi repo một pipeline, không cần affected detection. Nhưng khi cần coordinated deploy (thay đổi API contract giữa hai service), polyrepo phải giải quyết bằng process ngoài CI: deploy plan document, manual coordination, hoặc feature flag để decouple deploy order.


Khi nào chọn monorepo

Monorepo phù hợp nhất khi các project có coupling cao — shared data model, shared UI components, API contract thay đổi thường xuyên giữa frontend và backend. Atomic change xuyên project là lợi thế quyết định trong tình huống này.

Team nhỏ đến trung bình (dưới 50 engineer) cũng hưởng lợi lớn từ monorepo vì overhead tooling thấp hơn overhead coordination polyrepo. Một team 10 người maintain 5 service trong monorepo với Turborepo hiệu quả hơn nhiều so với 5 repo riêng lẻ phải coordinate dependency version, CI config, và shared code.

Khi team dùng cùng tech stack — tất cả TypeScript, hoặc tất cả Go — monorepo cho phép share tooling, compiler config, linting rules mà không duplicate. Đây là lý do monorepo phổ biến trong ecosystem JavaScript nơi frontend và backend thường cùng ngôn ngữ.

Shared library development là use case rất mạnh. Team platform maintain packages/shared cùng repo với consumer — mọi breaking change được phát hiện ngay bởi CI, không cần chờ consumer bump version mới biết break.

Một tín hiệu rõ ràng nên cân nhắc monorepo: nếu team thường xuyên phải mở PR ở nhiều repo cho cùng một feature — sửa API ở repo backend, sửa client ở repo frontend, sửa type definition ở repo shared — thì coordinated change đang tốn thời gian đáng kể. Monorepo biến 3 PR thành 1, reviewer thấy toàn bộ thay đổi trong context, CI verify tất cả cùng lúc.


Khi nào chọn polyrepo

Polyrepo phù hợp khi team cần autonomy cao — mỗi team chọn tech stack riêng, release cadence riêng, convention riêng mà không ảnh hưởng team khác. Tổ chức lớn với nhiều team độc lập (mô hình “you build it, you run it”) thường chọn polyrepo vì ranh giới ownership rõ ràng ở mức cấu trúc.

Khi các service có tech stack khác nhau — service A bằng Go, service B bằng Python, service C bằng Java — monorepo vẫn khả thi (Bazel hỗ trợ multi-language) nhưng overhead cấu hình cao. Polyrepo đơn giản hơn vì mỗi repo có toolchain riêng không xung đột.

Deploy independence nghiêm ngặt cũng nghiêng về polyrepo. Khi service payment không được phép deploy cùng pipeline với service notification vì compliance requirement, polyrepo enforce sự tách biệt này ở mức infra — không có rủi ro CI misconfiguration deploy nhầm service.

Khi coupling giữa service thấp — mỗi service có data store riêng, giao tiếp qua API contract ổn định, thay đổi một service hiếm khi ảnh hưởng service khác — lợi thế atomic change của monorepo không đáng overhead tooling.

Open source project cũng thường dùng polyrepo vì contributor chỉ quan tâm đến một project cụ thể. Fork monorepo chứa 20 project chỉ để contribute vào 1 project là trải nghiệm không tốt — contributor phải hiểu cấu trúc repo, setup tooling monorepo, và CI chạy cho cả repo thay vì chỉ project họ sửa. Vì lý do này, hầu hết hệ sinh thái open source (trừ một số ngoại lệ như Babel, React) dùng polyrepo.


Mô hình hybrid

Thực tế không phải lúc nào cũng chọn một trong hai cực. Nhiều tổ chức dùng hybrid: vài monorepo nhóm theo domain, mỗi monorepo chứa các project liên quan chặt chẽ.

Ví dụ: monorepo platform chứa API gateway, auth service, shared middleware — những thứ coupling cao, thay đổi thường xuyên cùng nhau. Monorepo commerce chứa order service, payment service, inventory — cùng domain business, dùng chung data model. Repo riêng cho data pipeline vì tech stack khác (Python, Spark) và team data hoạt động độc lập. Repo riêng cho mobile app vì build toolchain hoàn toàn khác biệt (Xcode, Gradle) và release cycle theo app store review.

Mô hình hybrid giữ lợi thế atomic change trong phạm vi domain mà không bắt toàn bộ công ty vào một repo khổng lồ. Ranh giới monorepo theo domain cũng khớp với ranh giới team — team platform own monorepo platform, team commerce own monorepo commerce. Khi team mới thành lập, quyết định “join monorepo nào hay tạo repo riêng” trở thành câu hỏi có framework rõ ràng: coupling cao với domain nào thì vào monorepo đó, coupling thấp thì repo riêng.

Nhược điểm là shared code giữa các monorepo vẫn cần polyrepo workflow — publish qua registry, version management. Nhưng số dependency cross-monorepo thường ít hơn nhiều so với cross-service trong polyrepo thuần, nên overhead chấp nhận được.

Hybrid cũng là con đường migration tự nhiên. Bắt đầu với polyrepo, nhận ra 3 repo coupling cao, merge thành một monorepo. Giữ phần còn lại polyrepo. Không cần quyết định “all-in monorepo” hay “all-in polyrepo” — thực tế hiếm khi cực đoan là lựa chọn tốt nhất.


Migration — chuyển đổi giữa hai mô hình

Chuyển từ polyrepo sang monorepo (hoặc ngược lại) là quyết định lớn, cần plan kỹ. Vài điểm thực tế cần lưu ý.

Khi merge nhiều repo vào monorepo, giữ git history bằng git subtree add hoặc tool như tomono. Mất history là mất context cho git blamegit log — developer cần history để hiểu “tại sao code viết thế này”. Tuy nhiên, history merge không bao giờ hoàn hảo — commit hash thay đổi, merge commit nhân tạo xuất hiện. Chấp nhận trade-off này thay vì cố tìm giải pháp perfect.

Trước khi bắt đầu migration, document rõ ràng lý do chuyển đổi và metric đo thành công. Ví dụ: “giảm thời gian coordinated change từ 3 ngày xuống 1 ngày” hay “giảm CI time trung bình 50% nhờ caching”. Không có metric rõ ràng thì sau 3 tháng không ai biết migration thành công hay thất bại, và quyết định rollback hay tiếp tục trở thành tranh luận chủ quan.

CI migration thường là phần tốn effort nhất. Polyrepo pipeline đơn giản (build everything trong repo) không hoạt động trong monorepo — cần affected detection, path filter, hoặc monorepo-aware tool. Setup Turborepo hay Nx trước khi merge repo, không phải sau — merge repo trước rồi mới tính chuyện tooling sẽ có giai đoạn CI chạy cực chậm vì build everything, developer phàn nàn, và momentum migration mất nhanh chóng.

Developer workflow thay đổi đáng kể. Trong polyrepo, git pull nhanh vì repo nhỏ. Trong monorepo, git pull kéo thay đổi của mọi team — có thể hàng chục commit mỗi ngày từ team khác. git stash, rebase, và merge conflict xảy ra thường xuyên hơn ở các file chung như lock file hay root config.

Cần training team về sparse checkout (git sparse-checkout set packages/service-a packages/shared), path filter trong log (git log -- packages/service-a/), và cách dùng tool monorepo (turbo run, nx affected). Những kỹ năng này không tự nhiên mà có — developer quen polyrepo sẽ mất vài tuần adapt. Đừng đánh giá thấp chi phí thay đổi thói quen — kỹ thuật giải quyết được, nhưng cần thời gian và documentation rõ ràng để team adapt.

Migrate incremental nếu có thể — bắt đầu merge 2-3 repo coupling cao nhất vào monorepo, giữ phần còn lại polyrepo. Đánh giá kết quả sau 1-2 tháng trước khi merge thêm. Migrate big-bang toàn bộ cùng lúc rủi ro cao và dễ thất bại vì quá nhiều thứ thay đổi cùng lúc.

Chiều ngược lại — tách monorepo thành polyrepo — ít phổ biến hơn nhưng cũng xảy ra khi team scale lên và cần autonomy. git filter-branch hoặc git subtree split tách thư mục thành repo mới kèm history. Nhưng hậu quả lớn nhất không phải kỹ thuật mà là workflow: shared code giữa repo cũ và mới phải chuyển sang publish qua registry, CI phải tách ra, và team phải quen với coordination qua version thay vì atomic commit.


Cấu trúc thư mục monorepo

Không có chuẩn duy nhất nhưng pattern phổ biến nhất mà hầu hết tool monorepo đều hiểu là tách thành apps/ (ứng dụng deployable) và packages/ (thư viện nội bộ). Ứng dụng trong apps/ import từ packages/ nhưng không import lẫn nhau — đây là invariant quan trọng để giữ dependency graph dạng DAG (directed acyclic graph), tránh circular dependency.

monorepo/
├── apps/
│   ├── web/           # Next.js frontend
│   ├── api/           # Express backend
│   └── admin/         # Admin dashboard
├── packages/
│   ├── ui/            # Shared React components
│   ├── validators/    # Shared validation logic
│   ├── config-eslint/ # Shared ESLint config
│   └── tsconfig/      # Shared TypeScript config
├── turbo.json
├── package.json       # Root workspace config
└── pnpm-workspace.yaml

Config package (config-eslint, tsconfig) là pattern hay gặp — thay vì mỗi app duplicate config, tất cả extend từ package chung. Thay đổi config một chỗ, mọi app áp dụng. Đây là một trong những lợi ích “nhỏ nhưng tích luỹ” của monorepo mà polyrepo khó replicate.

Tránh đặt code trực tiếp ở root — root chỉ chứa workspace config và tooling config. Mọi code production phải nằm trong apps/ hoặc packages/. Quy tắc này giữ cho cấu trúc rõ ràng khi repo scale lên hàng chục project.


Tóm tắt

Monorepo và polyrepo là hai chiến lược quản lý source code với tradeoff khác nhau, không có “đáp án đúng” phổ quát. Monorepo cho atomic cross-project changes, code sharing tự nhiên, và convention thống nhất — đổi lại cần tooling (Turborepo, Nx, Bazel) để giữ CI nhanh, cần CODEOWNERS và module boundary lint để giữ ownership rõ ràng, và cần discipline để repo lớn không trở thành hỗn loạn.

Polyrepo cho ownership rõ ràng, CI/CD độc lập, team autonomy, và access control granular theo repo — đổi lại chịu dependency versioning hell, code duplication, tooling fragmentation, và coordination overhead cho thay đổi xuyên service. Diamond dependency và coordinated deploy là hai bài toán polyrepo giải quyết vất vả nhất.

Task caching (local và remote) là yếu tố quyết định monorepo có scale được hay không — không có cache thì monorepo 20 project chậm hơn 20 repo riêng lẻ. Affected detection (chỉ build/test project bị ảnh hưởng) là yếu tố thứ hai — CI naive build everything là anti-pattern cần tránh. Remote cache cần infra và budget riêng — tính vào tổng chi phí adopt monorepo.

Cấu trúc thư mục monorepo nên tách rõ apps/ (deployable) và packages/ (libraries), dùng workspace protocol để link nội bộ. Config package (eslint-config, tsconfig) giảm duplication đáng kể so với polyrepo mỗi repo tự maintain config riêng.

Chọn monorepo khi project coupling cao, team cùng tech stack, và team size nhỏ-trung bình. Chọn polyrepo khi team cần autonomy, tech stack đa dạng, deploy independence nghiêm ngặt, hoặc access control cần scope theo repo. Hybrid — vài monorepo nhóm theo domain — thường là lựa chọn thực tế nhất cho tổ chức đang phát triển. Bắt đầu từ cấu trúc đơn giản nhất phù hợp với quy mô hiện tại, migrate khi thấy pain point rõ ràng — không thiết kế cho scale chưa có.