Những cạm bẫy monorepo chúng tôi học được qua thực tiễn
5 vấn đề production thường gặp nhất sau 3–12 tháng chạy monorepo Turborepo + pnpm workspaces trong team vừa — kèm cách chẩn đoán và xử lý cụ thể từng cái.
Bởi toolchew
2.321 từ · 12 phút đọc
Giai đoạn setup lúc nào cũng êm. Bạn chạy npx create-turbo@latest, các package trong monorepo cài xong xuôi, turbo build hoàn thành trong 12 giây. Bạn thông báo migration cho cả team.
Ba tháng sau, CI mất 4 phút mỗi PR. TypeScript bỏ sót lỗi ở package này nhưng lại báo 47 cảnh báo sai ở package kia. Lockfile sinh ra conflict ở một trong năm lần merge. Không có vấn đề nào tự báo hiệu — chúng âm thầm tích lũy, rồi ập đến cùng một lúc.
Đây không phải những sai lầm của người mới. Đây là những vấn đề về thời điểm — cần đến một lượng code nhất định và một số thói quen nhất định của team để kích hoạt. Sau đây là năm vấn đề nhất quán gây khó khăn nhất cho các team, cùng cách xử lý cụ thể cho từng cái.
Đối tượng bài viết hướng đến
Team từ 5–20 developer đã chạy monorepo với Turborepo và pnpm workspaces được 3–12 tháng. Nếu bạn đang cân nhắc có nên dùng monorepo không, đó là một câu hỏi khác. Nếu bạn đang chạy setup tùy chỉnh với Bazel hay Pants, những chi tiết này không áp dụng được.
Nếu bạn đang bắt đầu với monorepo, Cách thiết lập monorepo pnpm + Turborepo từ đầu là điểm khởi đầu tốt trước khi các vấn đề này có cơ hội xuất hiện.
Tất cả version number dưới đây được gắn với tháng 5 năm 2026:
- Turborepo 2.x (rename
pipeline→taskstrong 2.0, ngày 4 tháng 6 năm 2024) - pnpm 10.x (Catalogs từ v9.5 tháng 7 năm 2024;
catalogModetrong v10.12.1;cleanupUnusedCatalogstrong v10.15.0) - TypeScript 5.8.x
Cạm bẫy 1: Cache invalidation bóng ma
Remote cache của Turborepo chính là điểm cốt lõi của công cụ này đối với CI. Khi hoạt động đúng, một cold build trên package chưa thay đổi giảm từ 8 phút xuống còn 15 giây. Khi nó âm thầm thất bại, bạn đang trả tiền cho thời gian CI mà mình tưởng đã tối ưu xong.
Biểu hiện lỗi: turbo build hiển thị FULL TURBO — dấu tích xác nhận cache hit — nhưng các task downstream không tìm thấy output artifacts. Build thành công vì Turborepo không restore gì cả và chạy lại toàn bộ, vốn cũng “thành công”. Không có lỗi. Không có cảnh báo. Bạn chỉ nhận ra khi nhìn vào thời gian build.
Nguyên nhân gần như luôn là trường outputs trong turbo.json.
Turborepo dùng content hash của inputs để tạo cache key. Khi restore một cache hit, nó dựa vào outputs để biết cần ghi file nào về disk. Nếu outputs sai hoặc thiếu, quá trình restore không ghi gì. Task tiếp theo trong graph tiếp tục với bất kỳ trạng thái cũ nào đang có trên disk.
Cách chẩn đoán: chạy với --summarize:
turbo build --summarize
Lệnh này ghi .turbo/runs/<run-id>.json cho mỗi lần chạy — một bản ghi machine-readable gồm input hash, output paths, và trạng thái cache của từng task. Mở file này và xác minh rằng các glob trong outputs khớp với các file mà build tool thực sự tạo ra.
Cấu hình sai phổ biến nhất:
{
"tasks": {
"build": {
"outputs": ["dist"]
}
}
}
["dist"] hash entry của thư mục, không phải nội dung bên trong. Nó khớp với sự tồn tại của thư mục dist, chứ không phải những gì chứa trong đó. Thay bằng ["dist/**"].
Cấu hình đúng cho một TypeScript library package:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
}
}
Lưu ý tasks, không phải pipeline. Turborepo 2.0 (ngày 4 tháng 6 năm 2024) đã đổi tên key này. Nếu config của bạn vẫn dùng pipeline, bạn đang chạy phiên bản cũ và các tối ưu cache trong 2.0 chưa được áp dụng.
Nếu cache đúng nhưng cold build vẫn chậm: đó là vấn đề khác — thời gian khởi động runner, không phải cache miss. Depot cung cấp arm64 CI runners. Đáng đo lường so với baseline của bạn, nhưng chỉ sau khi cache hit rate đã ổn định — nếu không bạn đang trả tiền cho phần cứng nhanh để chạy những rebuild chậm không cần thiết.
Cạm bẫy 2: Phiên bản dependency phân kỳ
Sáu tháng sau, react tồn tại ở ba phiên bản khác nhau trong 12 package của bạn. typescript ở hai phiên bản. zod ở bốn phiên bản vì một package cũ đã ghim 3.20.0 trước khi có breaking changes ở 3.22.0 và không ai chú ý khi phần còn lại nâng cấp. Bạn có các module instance song song trong bundle và TypeScript errors chỉ tái hiện ở một số package nhất định.
Giải pháp quen thuộc — resolutions trong package.json ở root — ép về một phiên bản duy nhất tại thời điểm resolution. Nó không ngăn được ai thêm "react": "^18.3.1" vào package mới. Sự phân kỳ lại quay trở lại.
Giải pháp đúng là pnpm Catalogs, được giới thiệu từ pnpm v9.5 (tháng 7 năm 2024).
Thiết lập: định nghĩa tất cả phiên bản dùng chung một lần trong pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*'
catalog:
react: ^19.1.0
react-dom: ^19.1.0
typescript: ^5.8.0
zod: ^3.24.0
vitest: ^3.1.0
'@types/react': ^19.1.0
Tham chiếu trong từng package:
{
"name": "@acme/ui",
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:",
"@types/react": "catalog:"
}
}
catalog: không có suffix kéo từ catalog: mặc định. Bạn có thể định nghĩa named catalogs cho các nhánh phiên bản song song trong quá trình migration:
catalog:
react: ^19.1.0
catalogs:
react18:
react: ^18.3.1
react-dom: ^18.3.1
Khi đó, các package ở tier cũ tham chiếu catalog:react18 cho đến khi migration hoàn tất.
Bắt buộc tuân thủ: nếu bạn đang dùng pnpm v10.12.1+, thêm catalogMode: strict vào pnpm-workspace.yaml:
catalogMode: strict
Với strict, pnpm install sẽ thất bại khi có package nào khai báo phiên bản ghim thay vì dùng catalog. Bạn phát hiện sự phân kỳ ngay lúc install. Trước khi có catalogMode, catalog chỉ mang tính khuyến nghị — thường bị bỏ qua trong code review.
Nếu bạn đang cân nhắc có nên chuyển sang pnpm không, pnpm vs npm giải thích cụ thể những gì thay đổi khi bạn chuyển đổi.
Từ v10.15.0+, cleanupUnusedCatalogs xóa các catalog entry không còn được tham chiếu, giữ pnpm-workspace.yaml chính xác khi package bị xóa hoặc đổi tên.
Cạm bẫy 3: Vòng phụ thuộc tròn giữa các package
Circular dependency không sinh ra thông báo lỗi. Chúng biểu hiện qua:
- TypeScript project references rebuild từ đầu mỗi lần thay vì dùng incremental output
turbo buildkhông cache được gì giữa hai package import lẫn nhautsc --buildchạy 90 giây chỉ vì thay đổi 5 file
Các package trong một vòng tròn sẽ compile cùng nhau như một đơn vị. Điều đó vô hiệu hóa cả incremental TypeScript compilation lẫn task graph caching của Turborepo. Turborepo không thể build nửa vòng tròn rồi cache kết quả.
Cách phát hiện: dùng dpdm, không phải madge:
pnpm add -D dpdm
dpdm --circular './packages/*/src/index.ts'
madge hoạt động không ổn trong pnpm workspaces vì nó đi theo symlink trong node_modules không nhất quán. dpdm đi theo đồ thị import TypeScript thực tế.
Output khi có vòng tròn:
Circular Dependencies!
[0] packages/auth/src/index.ts
-> packages/utils/src/token.ts
-> packages/auth/src/index.ts
Cách xử lý luôn là thay đổi cấu trúc. Một trong hai package trong vòng tròn đang làm quá nhiều việc. Trong ví dụ trên, packages/utils không nên biết về packages/auth. Cách xử lý là tách token.ts ra một package thứ ba — packages/crypto hoặc packages/primitives — mà cả auth lẫn utils đều có thể phụ thuộc vào mà không tạo ra vòng tròn.
Không có cách xử lý tại chỗ cho vòng tròn. Bạn phải thay đổi dependency graph.
Ngăn tái phát: thêm dpdm --circular vào CI như một required check:
pnpm dpdm --circular './packages/*/src/index.ts'
Vòng tròn không xuất hiện ngay từ đầu. Chúng tích lũy từ những import nhỏ, từng cái một đều có lý. Phát hiện một vòng tròn ở thời điểm PR (khi chỉ có một import mới được thêm vào) tốn 5 giây. Phát hiện ba tháng tích lũy tốn một đợt refactor.
Cạm bẫy 4: Task graph typecheck báo cáo sai
Đây là cạm bẫy khó thấy nhất vì mọi thứ trông có vẻ ổn. TypeScript chạy. Không có lỗi. CI pass. Nhưng các package downstream đang dùng type declarations từ ba tuần trước, và không ai nhận ra vì các type cũ đó tình cờ vẫn tương thích.
Biểu hiện lỗi: packages/ui dùng tsc --noEmit làm task typecheck. --noEmit đúng cho application packages — bạn muốn phát hiện lỗi mà không emit file. Với library packages mà các package khác import vào, đây là cách làm sai. --noEmit không emit gì vào dist/. Khi packages/app import từ packages/ui, TypeScript resolve dựa trên những file .d.ts đang có trong packages/ui/dist/ — có thể là từ lần build local cuối cùng, hoặc không có gì cả.
Incremental compilation và project references của TypeScript đều đòi hỏi file .d.ts output phải có sẵn. Không có chúng, TypeScript phân tích lại source từ đầu mỗi lần thay vì dùng declaration đã compile sẵn. Trên các package lớn, điều này cộng thêm nhiều phút vào tsc.
Xử lý phần một: thay đổi library packages để emit declaration files:
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true
}
}
emitDeclarationOnly ghi file .d.ts vào dist/ mà không ghi .js. Với library package được các TypeScript package khác trong cùng monorepo consume, đây là chế độ đúng. Bundler (Vite, esbuild, tsup) xử lý output .js; TypeScript xử lý types.
Xử lý phần hai: cập nhật turbo.json để typecheck chạy sau các upstream build:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}
"dependsOn": ["^build"] có nghĩa là “build tất cả dependencies của tôi trước khi chạy typecheck của tôi.” Không có điều này, Turborepo có thể chạy typecheck trên packages/app trước khi packages/ui ghi xong file .d.ts.
Về TypeScript Project References: hướng dẫn TypeScript chính thức của Turborepo khuyên không nên kết hợp Project References với task graph của Turborepo. Project References tạo ra một đồ thị phụ thuộc thứ hai có thể mâu thuẫn với dependsOn trong turbo.json. Nếu bạn đã setup composite: true và references: [] trong tsconfig.json, bạn có thể giữ nguyên — chúng không gây hại. Nhưng đừng dựa vào chúng để điều phối thứ tự build. Hãy để turbo.json đảm nhiệm việc đó.
Cạm bẫy 5: Chi phí giải quyết lockfile conflict
Trong một team 10 developer với 6 PR đang mở, bạn đang giải quyết lockfile conflict ở khoảng 20% số lần merge. Mỗi lần giải quyết mất 5–15 phút, đòi hỏi hiểu format pnpm-lock.yaml, và có rủi ro không nhỏ để lại một dependency regression tinh tế. Tính theo quý, đây là một khoản chi phí đáng kể.
Conflict trong pnpm-lock.yaml xảy ra vì mỗi lần pnpm install ghi lại toàn bộ file, và format này không merge sạch khi hai nhánh cùng thêm các package khác nhau.
pnpm đã giới thiệu gitBranchLockfile để giải quyết vấn đề này. Bật nó trong pnpm-workspace.yaml:
gitBranchLockfile: true
Với cài đặt này, pnpm ghi một lockfile riêng theo tên nhánh pnpm-lock.<branch-sanitized>.yaml bên cạnh pnpm-lock.yaml mặc định. Công việc trên feature branch sẽ dùng lockfile của nhánh đó. Main lockfile chỉ được cập nhật khi merge.
Khi bạn pull main vào feature branch:
pnpm install --merge-git-branch-lockfiles
Lệnh này tự động merge lockfile của nhánh bạn với lockfile main đã cập nhật. Trường hợp phổ biến — hai nhánh độc lập thêm các package không liên quan đến nhau — trở thành no-op. Conflict thu hẹp lại chỉ còn những tình huống hai nhánh thực sự thay đổi cùng một phiên bản dependency.
Một lưu ý: gitBranchLockfile thêm file vào repository. Bạn sẽ thấy file pnpm-lock.feature-xyz.yaml trong git status. Thêm rule .gitignore cho chúng nếu bạn không muốn commit lockfile theo từng nhánh, hoặc commit chúng để CI có thể chạy reproducible trên nhánh đó. Cả hai đều ổn; chọn một cách và ghi lại.
Checklist production-readiness cho monorepo
Sau 6–12 tháng với monorepo Turborepo + pnpm, ba kiểm tra này phân biệt setup đang phát triển tốt với setup đang âm thầm tích lũy technical debt.
1. Xác minh outputs cache của bạn đúng
turbo build --summarize
# Mở .turbo/runs/<run-id>.json
# Xác nhận glob trong outputs khớp với build artifacts thực tế trên disk
Nếu FULL TURBO xuất hiện nhưng thời gian build không giảm, cấu hình outputs của bạn sai.
2. Thêm circular dependency detection vào CI
pnpm dpdm --circular './packages/*/src/index.ts'
Chạy như một required CI step. Không tốn gì khi không có vòng tròn; phát hiện các vòng tròn đắt giá trước khi chúng cộng dồn lại.
3. Chuyển library packages sang emitDeclarationOnly và bật gitBranchLockfile
// tsconfig.json trong library packages
{ "compilerOptions": { "emitDeclarationOnly": true } }
# pnpm-workspace.yaml
gitBranchLockfile: true
Hai thay đổi này loại bỏ hai nguồn friction hàng ngày lớn nhất khi team có từ 10 người trở lên.
Không cần đổi tool hay refactor lớn. Chỉ cần đọc đúng tài liệu vào đúng thời điểm — vốn là ba tháng sau khi bạn thực sự cần.