Home / Fundamentals / Monorepo với Bun Workspaces và Turborepo: Hướng Dẫn Thực Tế
Fundamentals

Monorepo với Bun Workspaces và Turborepo: Hướng Dẫn Thực Tế

Dựng monorepo với Bun Workspaces và Turborepo: workspace protocol, dependency graph, cache build và FULL TURBO thực tế.

Bạn có hai app cùng dùng một component Button, một DataTable, vài cái type. Mỗi repo giữ một bản copy, rồi tới lúc sửa một chỗ mà quên chỗ kia — hai bản lệch nhau, bug xuất hiện ở đúng cái app bạn ít để ý nhất. Đó là version drift, và nó là lý do người ta gom nhiều package vào chung một repo: monorepo.

Bài này dựng một monorepo thật bằng Bun WorkspacesTurborepo — đi qua cách tổ chức code, import package nội bộ, và cache build để lần chạy thứ hai gần như tức thì. Toàn bộ ví dụ lấy từ một repo công khai bạn clone về chạy thử được.

1. Monorepo là gì và khi nào cần

Monorepo là một repository chứa nhiều package (app và thư viện) cùng version-control chung, cùng một lần install. Nó không phải là monolith: monolith là một khối code chạy chung một process, còn monorepo vẫn là nhiều package tách bạch, chỉ khác là chúng sống chung một repo và tham chiếu nhau trực tiếp.

Bạn nên cân nhắc monorepo khi:

  • Nhiều app dùng chung component, type, hoặc util — và bạn không muốn copy/paste rồi để chúng trôi lệch nhau.
  • Một thay đổi thường kéo theo nhiều package (sửa type ở shared là phải cập nhật cả nơi consume nó).
  • Bạn muốn một lần install, một lần config lint/test/build, thay vì mỗi repo một bộ.

Ngược lại, nếu các app hoàn toàn độc lập, không share gì, thì monorepo chỉ thêm overhead. Monorepo giải quyết bài toán chia sẻ code có kiểm soát, không phải bài toán “gom tất cả vào một chỗ cho gọn”.

Repo ví dụ có cấu trúc như sau — hai app dưới apps/, sáu package dưới packages/:

1
2
3
4
5
6
7
8
9
10
11
12
13
jsiovn-monorepo/
├── apps/
│   ├── dashboard/     # @jsiovn/dashboard — app chính (:5173)
│   └── legacy/        # @jsiovn/legacy — app cũ (:5174)
├── packages/
│   ├── analytics/     # @jsiovn/analytics
│   ├── codemods/      # @jsiovn/codemods
│   ├── mocks/         # @jsiovn/mocks — MSW handlers
│   ├── shared/        # @jsiovn/shared — types & utilities
│   ├── ui/            # @jsiovn/ui — component library
│   └── users/         # @jsiovn/users
├── turbo.json         # Turborepo task graph
└── package.json       # Workspaces + root scripts

2. Bun workspaces và workspace protocol

Bun đọc danh sách workspace từ đúng một field trong package.json ở root — field workspaces. Mỗi entry là một glob pattern trỏ tới thư mục chứa package:

1
2
3
4
5
6
{
  "name": "jsiovn-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "packageManager": "bun@1.3.11"
}

Với config này, mọi thư mục có package.json dưới apps/packages/ đều trở thành một workspace. Chạy một lệnh bun install ở root là Bun install dependencies cho toàn bộ workspace, dùng chung một lockfile bun.lock:

1
bun install
1
Checked 370 installs across 462 packages (no changes) [99.00ms]

2.1. workspace:* — trỏ package nội bộ, không phải bản trên npm

Khi một package phụ thuộc package khác trong cùng repo, bạn khai báo dependency với protocol workspace:*:

1
2
3
4
5
6
{
  "name": "@jsiovn/ui",
  "dependencies": {
    "@jsiovn/shared": "workspace:*"
  }
}

workspace:* nói với Bun: “@jsiovn/shared là một workspace ở ngay trong repo này, đừng đi tải từ npm registry.” Bun tạo symlink trong node_modules của chính package consume nó: packages/ui/node_modules/@jsiovn/shared trỏ thẳng vào packages/shared. Từ Bun 1.3.2, workspace có lockfile configVersion: 1 mặc định dùng linker isolated, nên symlink nằm cạnh package cần nó chứ không hoist lên node_modules/ ở root. Nhờ vậy, sửa code trong sharedui thấy ngay, không cần publish, không cần bump version. Đó chính là cơ chế chống version drift: chỉ có một bản shared trong toàn repo, mọi package đều dùng chung nó.

3. Tổ chức code trong monorepo

Quy ước phổ biến — và repo ví dụ đi theo — là chia làm hai nhóm:

  • apps/ — thứ bạn deploy: dashboard, legacy. App là “lá” của graph, không package nào import ngược vào chúng.
  • packages/ — thứ được share và import: shared, ui, analytics, users, mocks, codemods. Package càng ở gốc graph (như shared) thì càng nhiều nơi phụ thuộc vào nó.

Các package nối với nhau qua workspace:* tạo thành một dependency graph. Mũi tên A → B nghĩa là “B phụ thuộc A”, nên thứ tự build chảy từ trái sang phải:

View Mermaid diagram code
flowchart LR
    shared["@jsiovn/shared"] --> ui["@jsiovn/ui"]
    shared --> mocks["@jsiovn/mocks"]
    ui --> analytics["@jsiovn/analytics"]
    ui --> users["@jsiovn/users"]
    analytics --> dashboard["@jsiovn/dashboard"]
    users --> dashboard
    mocks --> dashboard
    mocks --> legacy["@jsiovn/legacy"]

    style shared fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
    style ui fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style analytics fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style users fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style mocks fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style dashboard fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff
    style legacy fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff

Đọc graph này ra:

  • shared không phụ thuộc package nội bộ nào — nó là gốc, phải build trước.
  • ui phụ thuộc shared; analyticsusers phụ thuộc ui.
  • mocks phụ thuộc shared; cả dashboardlegacy đều phụ thuộc mocks.

Cái graph này không phải để trang trí — ở phần Turborepo bên dưới, nó chính là thứ quyết định build cái gì trước, và cache lại cái gì khi bạn chỉ sửa một package.

4. Import package nội bộ

Một điểm hay gây bối rối: import @jsiovn/shared thì đang import cái gì — source .ts hay bản đã compile? Trong repo ví dụ, câu trả lời là source trực tiếp. Các package được import trực tiếp — shared, ui, users, analytics, mocks — đều trỏ maintypes thẳng vào entry TypeScript của mình (mocks trỏ vào ./src/browser.ts, còn lại trỏ vào ./src/index.ts):

1
2
3
4
5
{
  "name": "@jsiovn/shared",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

Không có field exports, không có bước bundle trước khi publish. App import source, và Vite lo phần bundle khi build app. Cách này giữ mọi thứ đơn giản cho một repo nội bộ: không có “khoảng cách” giữa code bạn viết và code được consume.

index.ts chỉ re-export những gì package muốn cho bên ngoài dùng — đây là “public API” của package:

1
2
3
4
5
6
7
8
export type {
  User,
  AuthState,
  SummaryStats,
  // ... các type khác
} from "./types";
export { apiClient, createSuspenseResource } from "./api-client";
export { AuthContext, useAuth } from "./auth";

Bên consume import bằng đúng tên package, y như một package bình thường trên npm:

1
2
import type { User } from "@jsiovn/shared";
import { DataTable, LoadingSkeleton } from "@jsiovn/ui";

Vì import trỏ thẳng vào source TypeScript, type-safety xuyên suốt qua ranh giới package: sửa interface User trong @jsiovn/shared, thì @jsiovn/users báo lỗi type ngay tại chỗ dùng sai — không cần rebuild, không cần publish version mới. Đây là lợi ích lớn nhất của monorepo mà multi-repo khó có được: một type sai lan tới đâu, compiler chỉ cho bạn tới đó, ngay lập tức.

5. Turborepo — build theo graph và cache

bun install giải quyết chuyện link package. Nhưng chạy build, typecheck, test theo đúng thứ tự dependencycache lại kết quả là việc của Turborepo. Bạn khai báo các task và quan hệ giữa chúng trong turbo.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "$schema": "https://turborepo.dev/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {},
    "test": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Chú ý ký hiệu ^build trong dependsOn. Dấu ^ nghĩa là “chạy build của các dependency trước đã”. Nên khi build ui, Turborepo tự build shared trước; khi build dashboard, nó build hết shared, ui, analytics, users trước. Bạn không phải tự xếp thứ tự — cái graph ở mục 3 chính là thứ Turborepo đọc để suy ra thứ tự đó.

5.1. Cache miss và FULL TURBO

Lần build đầu, chưa có gì trong cache — mọi task đều chạy thật:

1
bun run build
1
2
3
 Tasks:    6 successful, 6 total
Cached:    0 cached, 6 total
  Time:    4.287s

0 cached, 6 total — sáu task build đều là cache miss, Turborepo chạy hết và lưu output lại. Bây giờ chạy đúng lệnh đó lần nữa, không sửa gì:

1
bun run build
1
2
3
 Tasks:    6 successful, 6 total
Cached:    6 cached, 6 total
  Time:    17ms >>> FULL TURBO

6 cached và dòng >>> FULL TURBO: input không đổi, nên Turborepo không build lại gì cả — nó replay output đã lưu. 4.287s xuống còn 17ms. Turborepo hash input của mỗi task (source, config, và hash của các task dependency); input trùng thì kết quả trùng, khỏi chạy lại.

5.2. Sửa một package, build lại đúng phần bị ảnh hưởng

Sức mạnh thật nằm ở chỗ build một phần. Filter --filter=...@jsiovn/ui (dấu ... đứng trước tên) nghĩa là “@jsiovn/ui và mọi package phụ thuộc vào nó”. Sau khi sửa một dòng trong packages/ui/src/button.tsx:

1
bun turbo build --filter=...@jsiovn/ui
1
2
3
4
5
6
7
8
9
10
11
12
 Packages in scope: @jsiovn/analytics, @jsiovn/dashboard, @jsiovn/ui, @jsiovn/users
 Running build in 4 packages

@jsiovn/shared:build: cache hit, replaying logs 9e3527b5d68a5daf
@jsiovn/ui:build: cache miss, executing 9ed2035953be21ad
@jsiovn/analytics:build: cache miss, executing bc311443d55f7b8c
@jsiovn/users:build: cache miss, executing efff44ba47eab605
@jsiovn/dashboard:build: cache miss, executing c066415dcc97b47e

 Tasks:    5 successful, 5 total
Cached:    1 cached, 5 total
  Time:    3.375s

Đọc kỹ dòng cache:

  • sharedcache hit. Nó là dependency của ui, không phải nơi bị sửa, input không đổi nên replay từ cache.
  • ui, analytics, users, dashboardcache miss. ui bị sửa; analytics, users, dashboard phụ thuộc ui (trực tiếp hoặc gián tiếp) nên input của chúng đổi theo, phải build lại.

Turborepo chỉ build lại những package bị ảnh hưởng bởi thay đổi, và bỏ qua phần còn lại. Trên một repo lớn, đây là khác biệt giữa build 30 giây và build 5 phút.

Lưu ý hướng của dấu ba chấm

Dấu ... đứng trước hay sau tên package cho ra hai kết quả ngược nhau:

  • --filter=...@jsiovn/ui@jsiovn/ui cùng các package phụ thuộc nó (dependents) — dùng khi bạn sửa ui và muốn kiểm tra mọi nơi consume nó không bị vỡ.
  • --filter=@jsiovn/ui...@jsiovn/ui cùng các dependency của nó (như shared) — dùng khi bạn chỉ muốn dựng đủ những gì ui cần để chạy.

Nhớ nhầm hướng là build sai tập package: sau khi sửa ui mà gõ @jsiovn/ui..., Turborepo chỉ dựng uishared, bỏ sót đúng analytics, users, dashboard — những package có thể vỡ vì thay đổi đó.

6. CI với Bun và Turborepo

Trên CI, cache của Turborepo tiếp tục có ích: chỉ những package thực sự thay đổi mới phải build lại. Pipeline GitHub Actions của repo cài Bun, cài Node (các tool như tsc, ESLint, Vite chạy trên Node), cache thư mục ~/.bun/install/cache của Bun, rồi chạy toàn bộ task qua Turborepo trong một lệnh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version-file: package.json
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
      - uses: actions/cache@v6
        with:
          path: ~/.bun/install/cache
          key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
      - run: bun install --frozen-lockfile
      - run: bun turbo run typecheck lint test build

Vài điểm đáng chú ý:

  • bun-version-file: package.json đọc version Bun từ field packageManager, node-version-file: .nvmrc đọc version Node — một nguồn version duy nhất, không hard-code trong workflow.
  • bun install --frozen-lockfile bắt CI dùng đúng bun.lock đã commit, fail nếu lockfile lệch — tránh chuyện “chạy máy mình thì ok”.
  • bun turbo run typecheck lint test build chạy cả bốn task trong một lệnh; Turborepo tự xếp thứ tự theo graph và chạy song song những task không phụ thuộc nhau.

Vậy là từ bun install tới build có cache tới CI, cùng một graph điều phối mọi thứ. Điểm mấu chốt của monorepo không phải là “gom code vào một chỗ”, mà là để công cụ hiểu quan hệ giữa các package rồi tự làm phần việc lặp lại: link đúng bản, build đúng thứ tự, và bỏ qua thứ không đổi.

Repo ví dụ đầy đủ ở đây để bạn clone và nghịch thử: github.com/jsiovn/monorepo. Thử chạy bun run build hai lần để thấy FULL TURBO, rồi sửa một component và chạy filter để xem Turborepo chọn build lại đúng phần nào. Ở bài sau mình sẽ đi sâu hơn vào TypeScript project references (build incremental xuyên workspace) và cách đặt module boundaries để các package không import lộn xộn vào nhau.

← Previous
Micro Frontend: Tách App React Lớn Với Module Federation
Table of Contents
  1. 1. Monorepo là gì và khi nào cần
  2. 2. Bun workspaces và workspace protocol
    1. 2.1. workspace:* — trỏ package nội bộ, không phải bản trên npm
  3. 3. Tổ chức code trong monorepo
  4. 4. Import package nội bộ
  5. 5. Turborepo — build theo graph và cache
    1. 5.1. Cache miss và FULL TURBO
    2. 5.2. Sửa một package, build lại đúng phần bị ảnh hưởng
  6. 6. CI với Bun và Turborepo