กลับไปที่บทความ
Monorepo DevOps TypeScript Architecture Tooling

กลยุทธ์ Monorepo ในปี 2026: pnpm, Turborepo, Nx และผองเพื่อน

พลากร วรมงคล
11 กุมภาพันธ์ 2569 11 นาที

“เมื่อไหร่ที่ monorepo คุ้มค่า ควรเลือก toolchain ตัวไหน และ layout ที่สมเหตุสมผลสำหรับทีมขนาดเล็กถึงกลางหน้าตาเป็นอย่างไร — pnpm workspaces, Turborepo, Nx และตัวเลือกใหม่ๆ”

คำถามเรื่อง monorepo มักไม่มีคำตอบที่ชัดเจน เพราะคนที่ถามแต่ละคนอยู่ในสถานการณ์ที่ต่างกัน สอง app กับ shared UI kit หนึ่งตัวเป็นคนละปัญหากับ services ห้าสิบตัวข้ามภาษาสามแบบ และคำแนะนำเรื่อง toolchain ที่เหมาะกับอันหนึ่งจะพังเมื่อนำไปใช้กับอีกอัน บทความนี้เขียนสำหรับทีมขนาดเล็กถึงกลางที่อยู่ตรงกลาง: มี shared code มากพอที่จะรู้สึกถึงแรงเสียดทาน แต่ยังไม่ถึงขั้นต้องใช้ Bazel

TL;DR

  • ใช้ monorepo เมื่อมีสอง app ขึ้นไปที่แชร์โค้ดกันจริงๆ การ refactor ข้าม boundary บ่อย หรือต้องการ tooling baseline เดียว — ไม่ใช่เพราะ Google ใช้
  • pnpm workspaces คือ workspace manager default ที่ปลอดภัยในปี 2026 เพิ่ม task runner เมื่อ CI เริ่มเจ็บ
  • Turborepo ชนะที่ความเรียบง่าย Nx ชนะเมื่อต้องการ generators และ enforced module boundaries Moon เป็นตัวเลือกสำหรับ polyglot
  • Layout ให้น่าเบื่อไว้: apps/* กับ packages/*, apps ห้าม import apps, packages ห้าม import apps
  • ใช้ workspace:*, TypeScript project references และ tsconfig.base.json พร้อม path aliases — caching ขึ้นกับ inputs ที่ซื่อสัตย์
  • CI ต้องรัน affected-only พร้อม remote cache ไม่งั้นคุณไม่ได้มี monorepo แต่มี slow repo
  • Migrate ทีละ PR คุณเพิ่ม Nx ทีหลังได้เสมอ แต่จะถอดออกยาก

ทำไมคำถามนี้ถึงกลับมาเรื่อยๆ

ทุกๆ ไม่กี่ปี การถกเถียงเรื่อง monorepo จะเริ่มใหม่ มักเกิดหลังจากใครคนหนึ่งอ่านโพสต์วิศวกรรมของ Google และอีกคนอ่านโพสต์ “เราเลิกใช้ monorepo แล้ว” ในบ่ายวันเดียวกัน ทั้งสองเรื่องเป็นเรื่องจริง และทั้งสองเรื่องเกี่ยวกับทีมที่เฉพาะเจาะจงมากพร้อมข้อจำกัดที่เฉพาะเจาะจงมาก

โพสต์นี้พูดถึงคำถามในเวอร์ชันที่พวกเราส่วนใหญ่เจอจริงๆ: คุณมีสองหรือสาม app, shared library ไม่กี่ตัว และกำลังตัดสินใจว่าจะเก็บไว้ด้วยกันหรือแยกออก — และถ้าจะเก็บด้วยกัน toolchain ตัวไหนทำให้เสียใจน้อยที่สุดในปี 2026

ผมจะไม่ประกาศผู้ชนะ คำตอบที่ซื่อสัตย์คือเครื่องมือที่เหมาะสมขึ้นอยู่กับว่าทีมคุณยินดีจ่ายค่าความซับซ้อนของ caching, code generation และ enforced boundaries มากแค่ไหน สิ่งที่โพสต์นี้จะทำคือพาเดินผ่าน trade-offs แสดง config files จริง และทิ้ง checklist ที่คุณนำไปใช้กับสถานการณ์ของตัวเองได้

เหตุผลที่แท้จริงในการใช้ Monorepo

คำโฆษณาของ monorepo คือ “หนึ่ง repo พลังไร้ขีดจำกัด” เหตุผลจริงๆ ที่สุขุมในการใช้นั้นแคบกว่านั้น:

  • แชร์โค้ดได้โดยไม่ต้อง publish UI package ที่ใช้โดยสาม app สามารถอยู่ข้างๆ app เหล่านั้นและถูก consume ผ่าน workspace:* — ไม่ต้อง npm publish ไม่ต้องเต้น version bump ไม่มีข้อความ Slack ว่า “ช่วยอัปเป็น 2.4.1 ด้วย bug fix แล้วนะ”
  • Refactor ข้ามขอบเขตแบบ atomic เปลี่ยนชื่อ column บน domain type แล้วแก้ caller ทุกที่ใน commit เดียว ใน polyrepo การ rename แบบเดียวกันใช้เวลาหนึ่งสัปดาห์ของ PR ที่ต้อง coordinate กัน
  • CI และ tooling รวมเป็นหนึ่ง หนึ่ง lint config, หนึ่ง tsconfig base, หนึ่งรูปแบบ CI pipeline วิศวกรใหม่เรียนรู้หนึ่ง repo แทนที่จะเจ็ด
  • Visibility grep ข้ามทุกอย่างได้ jump-to-definition ทำงานข้าม packages จริง ไม่มีใครต้องถามว่า “function นี้อยู่ที่ไหน?”

และเหตุผลที่ทีมเสียใจที่ใช้:

  • CI ช้า monorepo แบบไร้เดียงสาจะ rebuild และ retest ทุกอย่างทุก PR ถ้าไม่มี task runner ที่มี caching test suite 90 วินาทีจะกลายเป็นเก้านาที
  • แรงเสียดทาน tooling เพิ่มทวีคูณ Vite quirk ใน app เดียวจะ block การอัปเกรด monorepo ของทุกคน การ bump Node version ต้องการ consensus ของทีม
  • IDE performance ลดลง TypeScript language server บน repo 50 packages ที่ไม่มี project references คือการลงโทษ
  • ความเป็นเจ้าของไม่ชัด โฟลเดอร์ “shared” กลายเป็นปัญหาของทุกคนและจึงไม่เป็นของใครเลย รอบ review PR ยืดเพราะ reviewer คนใดก็อาจต้องดู
  • Git history เริ่มเลอะ Blame และ log ต้องอาศัยวินัย (เช่น pathspecs) เพื่อให้ยังมีประโยชน์

ปัญหาเหล่านี้ส่วนใหญ่มีทางแก้ แต่ทางแก้คือ toolchain ที่คุณกำลังจะเลือก

Decision Tree: ควรใช้หรือไม่?

ข้าม monorepo ถ้า:

  • คุณมีหนึ่ง app กับหนึ่ง library และ library นั้นยังไม่ได้แชร์กับอะไรอื่นเลย
  • App ของคุณอยู่ใน stack ที่ต่างกันจริงๆ (Rust service กับ Next.js app แทบไม่มีอะไรร่วมกันที่เป็นประโยชน์ในระดับ repo)
  • ทีมของคุณเป็น remote ทั้งหมดข้าม time zone ที่ต่างกันมาก และ merge ผ่าน long-lived branches — monorepo จะขยายความเจ็บปวดของการ merge
  • คุณมีขอบเขต compliance ที่เข้มงวดซึ่งต้องการ access control แยกต่อ project Monorepo ทำ path-based CODEOWNERS ได้ แต่ไม่ใช่ทุกทีมความปลอดภัยจะยอมรับ

พิจารณา monorepo ถ้า:

  • สอง app ขึ้นไป consume TypeScript types, UI components หรือ domain logic เดียวกัน
  • คุณทำการเปลี่ยนแปลงแบบ atomic ข้าม app + API + shared schema บ่อย
  • คุณต้องการที่เดียวสำหรับ lint, format, tsconfig และ CI templates
  • คุณกำลัง copy-paste โค้ดระหว่าง repos อยู่แล้วและรู้สึกแย่

ถ้ายังลังเล เริ่มด้วย shallow monorepo: สอง app, หนึ่งโฟลเดอร์ packages/shared, pnpm workspaces, ยังไม่มี task runner คุณเพิ่ม Turborepo หรือ Nx ทีหลังได้เสมอ แต่จะถอด setup Nx สิบ packages ที่คุณไม่ต้องการได้ยาก

Building Blocks

JavaScript/TypeScript monorepo คือสาม layer ที่ซ้อนกัน และเป็นประโยชน์ที่จะแยกแยะมันในใจก่อนเลือกเครื่องมือ:

1. Workspace manager — ใครเป็นคน resolve dependencies และ link local packages ผู้สมัครคือ npm workspaces, yarn workspaces (v1 หรือ berry) และ pnpm workspaces ในปี 2026 pnpm คือตัวเลือก default สำหรับทีมส่วนใหญ่: content-addressable store, strict dependency isolation และ workspace:* protocol ที่ตัวอื่นๆ copy ไปบางส่วน

2. Task runner — ใครเป็นคนตัดสินว่าจะ build อะไร เรียงลำดับอย่างไร และ cache อะไร Turborepo, Nx, Moon, Rush, Bazel หรือไม่มีอะไรเลย (แค่ npm scripts) นี่คือที่ที่ความหลากหลายมากที่สุดอยู่

3. Shared tooling — lint, format, type-check, commit hooks Biome หรือ ESLint + Prettier, tsconfig.base.json, husky หรือ lefthook, changesets สำหรับ versioning เหล่านี้ส่วนใหญ่เป็น orthogonal กับ workspace manager และ task runner

คุณ mix และ match ได้ pnpm workspaces + Turborepo เป็นคู่ที่พบบ่อยที่สุด pnpm + Nx ก็พบมากขึ้นเรื่อยๆ ปัจจุบัน Nx รองรับ pnpm workspaces แบบ native แล้วแทนที่จะต่อสู้กับมัน

เปรียบเทียบ: Feature Matrix อย่างซื่อสัตย์

Featurepnpm-onlypnpm + TurboNxRushMoon
Workspace installpnpm nativepnpm nativepnpm/npm/yarnown installer (rush)pnpm/npm/yarn/bun
Task orchestrationnone (manual)graph + cachegraph + cache + pluginsgraph + cache + phasesgraph + cache
Local cachenoyesyesyesyes
Remote cachenoyes (Vercel/self-host)yes (Nx Cloud/self-host)yes (self-host)yes (Moonbase/self-host)
Affected-only runsnoyesyesyesyes
Code generatorsnono (minimal)extensivebasicbasic
Enforced module boundariesnonoyes (project tags)yes (review categories)yes (tags)
Config stylenonesmall JSONJSON + pluginsJSON, many filesYAML
Learning curvetriviallowmoderate–highmoderatemoderate
Opinions about your codenonefewmany (optional)somesome
Non-JS languagesnonelimitedvia pluginslimitedfirst-class
Primary sponsorcommunityVercelNrwl (now Nx)Microsoftmoonrepo.dev

มีบางสิ่งที่ตารางนี้จับไม่ได้:

  • Nx อาจรู้สึกหนักถ้าคุณต้องการแค่ caching แต่จะคุ้มเมื่อคุณเอนเข้าหา generators และ enforced boundaries ทีมที่ใช้ Nx แบบครึ่งๆ กลางๆ มักเสียใจที่ไม่อยู่กับ Turborepo
  • ความเรียบง่ายของ Turborepo คือ feature คุณอ่าน turbo.json ทั้งไฟล์ได้ในหกสิบวินาที ต้นทุนคือเมื่อคุณต้องการ project tags, codegen หรือ language plugins คุณจะโตเกินมัน
  • Moon เป็นตัวเลือกที่สามที่เงียบๆ มันใส่ใจ polyglot repos (Rust + TS + Python) และน่าดูถ้าตรงกับคุณ
  • Rush เป็น product ของ Microsoft ที่ optimize สำหรับทีมขนาดใหญ่มากที่มี phase-based CI ถ้ายังไม่เคยได้ยินวิศวกรขอ คุณอาจไม่ต้องการมัน
  • Bazel มีอยู่ สำหรับทีม web ส่วนใหญ่มันเป็นเครื่องมือผิด เลือกเฉพาะถ้าคุณเป็น Bazel shop อยู่แล้วหรือ repo ของคุณใหญ่และ polyglot จริงๆ

ตัวอย่าง Folder Structure

Layout ที่ใช้ได้ดีกับทีมขนาดเล็กถึงกลางหลายทีม:

my-monorepo/
├── apps/
│   ├── web/                    # Next.js marketing + app
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # internal dashboard
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/                    # Node/Fastify or NestJS
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/                     # shared React components
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── config/                 # eslint/biome/tsconfig presets
│   │   ├── eslint-preset.js
│   │   ├── tsconfig.base.json
│   │   └── package.json
│   ├── domain/                 # shared business types & logic
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── db/                     # Prisma or Drizzle schema + client
│       ├── schema.prisma
│       ├── src/
│       └── package.json
├── tools/                      # one-off scripts, codegen
│   └── release/
├── .changeset/                 # if publishing anything
├── pnpm-workspace.yaml
├── turbo.json                  # or nx.json
├── tsconfig.base.json
├── package.json                # root, devDependencies only
└── README.md

แนวทางสองข้อที่ทำให้ layout นี้แข็งแรง:

  • Apps ไม่ import จาก app อื่น Apps import จาก packages/* ถ้า web ต้องการอะไรจาก admin คำตอบที่ถูกเกือบทั้งหมดคือ “extract มันไปเป็น package”
  • Packages ไม่ import จาก apps Packages ไม่รู้ว่าใครเป็นคน consume ตัวเอง กฎนี้คือสิ่งที่ทำให้คุณ publish มันทีหลังได้ถ้าต้องการ

Dependency Hygiene

workspace:* ranges

Internal packages ควร depend ซึ่งกันและกันด้วย workspace: protocol:

{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/domain": "workspace:*"
  }
}

ตอน pnpm install สิ่งเหล่านี้ resolve ไปยัง local packages ตอน publish (ถ้าคุณ publish) pnpm จะ rewrite เป็นเลข version จริงตอน pack * หมายถึง “version ปัจจุบันของ workspace อะไรก็ได้” คุณใช้ workspace:^ หรือ workspace:~ ได้ถ้า publish และต้องการ semver ranges ใน manifest ที่ publish

TypeScript project references

ถ้าต้องการให้ tsc compile packages ตามลำดับ dependency, cache ผล type-check และให้ language server มีโอกาสรอดใน repo ขนาดใหญ่ ให้ใช้ project references tsconfig.base.json ที่ root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "incremental": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "baseUrl": ".",
    "paths": {
      "@acme/ui": ["packages/ui/src/index.ts"],
      "@acme/ui/*": ["packages/ui/src/*"],
      "@acme/domain": ["packages/domain/src/index.ts"],
      "@acme/domain/*": ["packages/domain/src/*"]
    }
  }
}

tsconfig.json ของแต่ละ package:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "references": [
    { "path": "../domain" }
  ]
}

และ root tsconfig.json ที่ reference ทุก package สำหรับ tsc --build ทั้ง repo:

{
  "files": [],
  "references": [
    { "path": "packages/config" },
    { "path": "packages/domain" },
    { "path": "packages/ui" },
    { "path": "packages/db" },
    { "path": "apps/web" },
    { "path": "apps/admin" },
    { "path": "apps/api" }
  ]
}

tsc --build ตอนนี้รู้ dependency graph แล้วและจะ type-check ตามลำดับที่ถูกพร้อม incremental caching paths aliases ให้ import ที่สวย (import { Button } from "@acme/ui") และชี้ไปที่ source files เพื่อให้ jump-to-definition ลงที่โค้ดจริง ไม่ใช่ที่ .d.ts ที่ compile แล้ว

Publishing ด้วย changesets

ถ้า package ใดของคุณจะถูก publish ไปยัง npm ให้รับ changesets แต่เนิ่นๆ Workflow คือ: contributors รัน pnpm changeset เพื่อประกาศว่าเปลี่ยนอะไรและที่ semver level ไหน bot เปิด PR “Version Packages” ที่อัปเดต versions และ changelogs การ merge PR นั้น trigger การ publish มันเข้ากันได้ดีกับ Turborepo และ Nx และทำให้ release ของ monorepo ยังมีสติ

Build Caching ที่ใช้ได้จริง

สิ่งที่ทำให้ task runner คุ้มกับน้ำหนักของ config คือ caching ทั้ง Turborepo และ Nx implement แนวคิดพื้นฐานเดียวกัน: hash the inputs, key the outputs

สำหรับแต่ละ task (เช่น build ใน package X) runner คำนวณ hash ของ:

  • Source files ที่ประกาศเป็น inputs
  • Task configuration
  • Hashes ของ dependencies (packages ต้นทางที่ถูก build ไปแล้ว)
  • Environment variables ที่เกี่ยวข้อง
  • Version ของ runner เอง

ถ้า hash นั้นตรงกับ cache entry outputs (และ logs) จะถูก restore จาก cache ถ้าไม่ task จะรันและผลถูกเก็บ

นี่คือเหตุที่การเปลี่ยนหนึ่งบรรทัดใน packages/domain trigger การ rebuild ของ domain และทุกอย่างที่ depend บนมัน แต่ไม่ใช่ packages/ui (ที่ไม่ได้ import domain)

turbo.json แบบ minimal

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", ".env"],
  "globalEnv": ["NODE_ENV", "VERCEL_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "lint": {
      "inputs": ["src/**", ".eslintrc*", "biome.json"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**", "package.json"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

^build หมายถึง “รัน build ใน upstream dependencies ทั้งหมดก่อน” Array inputs คือสิ่งที่ควบคุม cache invalidation — ทำให้มันซื่อสัตย์ ถ้า task อ่านไฟล์ที่ไม่อยู่ใน inputs cache จะโกหกคุณ

nx.json แบบ minimal

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/**/*.test.ts",
      "!{projectRoot}/tsconfig.spec.json"
    ],
    "sharedGlobals": [
      "{workspaceRoot}/tsconfig.base.json",
      "{workspaceRoot}/.env"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true,
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["default", "^production"],
      "cache": true
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
      "cache": true
    }
  },
  "defaultBase": "origin/main"
}

Nx ใช้ namedInputs เพื่อให้คุณนิยาม input sets ครั้งเดียวและ reuse ได้ Mental model เดียวกัน พิธีกรรมสูงกว่าเล็กน้อย แลกกับเครื่องมือที่รวยกว่าเรื่อง graph visualisation, generators และ enforced module boundaries

Remote cache: ข้อดีและข้อเสีย

Local cache ช่วย developer คนเดียว Remote cache ช่วยทั้งทีมและ CI เมื่อ CI รัน turbo build หรือ nx build มันจะดึง cache hits ที่สร้างโดย CI runs อื่น โดย developers อื่น หรือโดย CI run เดียวกันบน commit ก่อนหน้า Remote cache ที่ตั้งดีจะเปลี่ยน cold CI build จากนาทีเป็นวินาทีบน PR ที่แตะแค่หนึ่ง package

Catch คือความไว้วางใจ Remote cache ปลอดภัยเท่ากับ inputs list ของคุณเท่านั้น ถ้า build ของคุณอ่าน process.env.API_KEY แต่ตัวแปรนั้นไม่ได้ประกาศใน env, CI runs ที่ต่างกันด้วย key ต่างกันจะแชร์ cache entry เดียวกันและ build จะผลิต artifacts ที่ผิดอย่างเงียบๆ ทางแก้คือวินัย: ประกาศ env dependencies, ใช้ --dry-run เพื่อตรวจสอบว่าอะไรกำลังถูก hash และระมัดระวังเรื่องสิ่งที่ cache ใน environments ที่ปักหมุด inputs ได้ยาก

CI Strategy: Affected-Only Builds

ชัยชนะที่ใหญ่ที่สุดของ CI จาก task runner คือ flag --filter / affected:

# Turborepo
turbo run build test lint --filter="...[origin/main]"

# Nx
nx affected -t build test lint --base=origin/main

ทั้งสองดู git diff ระหว่าง PR ของคุณกับ main, trace dependency graph และรัน task เฉพาะใน packages ที่เปลี่ยนหรือ depend บน packages ที่เปลี่ยน รวมกับ remote cache PR ที่แตะหนึ่งไฟล์ใน packages/ui จะ rebuild และ retest ui พร้อม direct dependents ไม่ใช่ทั้งโลก

Pattern “hash of inputs” ใช้ทั่วไปได้นอกเหนือจาก task runners Docker builds cache layers ตาม input hash GitHub Actions caches node_modules ตาม lockfile hash วินัยเดียวกัน — ประกาศ inputs ของคุณอย่างซื่อสัตย์ ให้เครื่องมือ hash มัน short-circuit เมื่อ hash ตรง — ใช้ได้ทุกที่

Snippet ของ CI

GitHub Actions job แบบ minimal สำหรับ Turborepo monorepo พร้อม remote cache:

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # need at least 2 for affected diffs

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo run lint typecheck test build --filter="...[origin/main]"

สลับ TURBO_TOKEN/TURBO_TEAM เป็น NX_CLOUD_ACCESS_TOKEN และคำสั่งเป็น nx affected ถ้าคุณใช้ Nx รูปทรงเหมือนกัน

เรื่องราวของการ Migrate

การไปจาก app เดียวไปยัง monorepo โดยไม่แช่แข็งทีมเป็นสัปดาห์ ทำตามสูตรที่ทำซ้ำได้:

  1. แตก branch ของ monorepo skeleton ใหม่ Root package.json ว่าง, pnpm-workspace.yaml, directory apps/ และ packages/
  2. ย้าย app ที่มีอยู่เข้า apps/main พร้อมรักษา history ใช้ git mv หรือ git filter-repo ขึ้นกับว่ารวมกี่ repos git mv พอสำหรับหนึ่ง app
  3. ทำให้มันรันเหมือนเดิมใน layout ใหม่ Dependencies เดิม scripts เดิม ยังไม่ refactor นี่คือ commit ที่คุณอยากย้อนกลับมาได้
  4. แนะนำ pnpm workspaces และ tsconfig.base.json ยังไม่มี task runner ยังไม่มี packages ที่ extract ออกมา พิสูจน์ว่า install ใช้ได้
  5. Extract chunk ที่ shared ที่ชัดเจนหนึ่งตัว — types, utility module หรือ UI kit — ไปยัง packages/domain หรือคล้ายกัน ทำให้ app consume มันผ่าน workspace:* นี่คือ proof-of-concept
  6. เพิ่ม task runner เริ่มด้วย Turborepo ถ้าไม่รู้ว่าต้องการอะไร เพิ่มหนึ่ง turbo.json หนึ่ง task ที่ cache วัดความต่าง
  7. เพิ่งจะ migrate app ที่สอง ถ้ามี repo ที่สองที่จะดูดเข้ามา นี่คือเวลานั้น

แต่ละขั้นเป็น PR ที่ merge ได้ ถ้าขั้นใดระเบิด คุณ revert PR เดียว ไม่ใช่ทั้งการ migration ทีมยังคง ship จาก main ตลอดเวลา

Anti-Patterns ที่ควรหลีกเลี่ยง

Failure modes สองสามแบบที่โผล่ใน monorepos ซ้ำแล้วซ้ำเล่า:

  • Package common / shared / utils ที่โตไม่มีขีดจำกัด ทุกครั้งที่ใครสักคนต้องการ function แล้วไม่รู้ว่าจะใส่ที่ไหน มันไปอยู่ใน shared หลังหนึ่งปี shared มี exports 300 ตัวที่ไม่เกี่ยวข้อง ไม่มีเจ้าของชัดเจน และทุก app depend บนมันทั้งหมด ป้องกัน: แบ่งตาม domain (@acme/billing, @acme/auth, @acme/ui) ไม่ใช่ตาม file type
  • Circular dependencies ระหว่าง packages ui import จาก forms, forms import จาก ui Task runners จะ fail เสียงดัง bundlers อาจไม่ ป้องกัน: dependency graph ทางเดียว enforce ใน review (หรือผ่าน Nx tags / ESLint rules)
  • Shared libs ไร้ version ที่ drift แม้ใน monorepo packages ควรมี version และ changelog ที่มีความหมายถ้ามันไม่ใช่เรื่องเล็กน้อย หรืออย่างน้อย owner comments ไม่เช่นนั้นไม่มีใครรู้ว่าอะไรเปลี่ยนเมื่อ ui package ทำ app พัง
  • แชร์เร็วเกินไป การ extract package “shared” จากโค้ดที่ใช้โดย app เดียวคือการสูญเสียสุทธิ ช่วงเวลาที่ถูกในการ extract คือเมื่อ caller ที่สองโผล่มา — และมักจะหลังจากโค้ดเสถียรใน app แรกแล้ว
  • Copy lockfiles ของทุกคนมาด้วยกัน ใช้หนึ่ง root lockfile Per-package lockfiles ใน workspace เอาชนะจุดประสงค์
  • รันทุกอย่างทุก PR ถ้าไม่ใช้ affected-only builds คุณไม่ได้มี monorepo — คุณมี slow repo

เมื่อใดควรเลื่อนชั้นเป็น Polyrepo

สัญญาณบางอย่างที่ monorepo ไม่ตอบสนองคุณอีกแล้ว:

  • Dependency graph แตกออกจริง สอง app ที่ไม่แชร์อะไรเลยอยู่ใน repo เดียวกันด้วยเหตุผลทางประวัติศาสตร์ การแยกออกต้นทุนน้อย
  • Security หรือ compliance ต้องการ repositories แยก — auditors ต่างกัน access control ต่างกัน contributor sets ต่างกัน
  • App หนึ่งถูกส่งต่อให้ทีมภายนอกหรือลูกค้าที่ไม่ต้องการเข้าถึงส่วนที่เหลือ
  • Open-source บางส่วน มักสะอาดกว่าที่จะ extract ออกมาแทนที่จะ clever-config รอบ monorepo ส่วนตัว

การเลื่อน package ออกจะง่ายกว่าถ้าคุณใช้ workspace:* และ package boundaries จริงอยู่แล้ว package นั้นทำตัวราวกับเป็นอิสระอยู่แล้ว ซึ่งคือครึ่งของงาน

สรุปเช็คลิสต์

ก่อน commit กับโครงสร้าง เดินผ่านสิ่งเหล่านี้:

  • มีอย่างน้อยสอง app ที่แชร์โค้ดวันนี้ หรือพิสูจน์ได้ว่ากำลังจะแชร์?
  • ฉันเตรียมจ่ายต้นทุน tooling — task runner config, การเปลี่ยน CI, วินัย lockfile?
  • ฉันเลือก workspace manager ที่จะอยู่กับมันจริงๆ แล้วหรือยัง? (pnpm คือ default ที่ปลอดภัย)
  • ฉันตัดสินใจแล้วหรือยัง: เริ่มด้วย pnpm workspaces เฉยๆ หรือ commit กับ task runner ตั้งแต่วันแรก?
  • Layout โฟลเดอร์ของฉันชัดเรื่อง apps vs packages และฉันเขียนกฎ “apps ไม่ import apps, packages ไม่ import apps” ลงไว้แล้วหรือยัง?
  • ฉันมี tsconfig.base.json พร้อม path aliases และ packages ของฉันใช้ project references หรือไม่?
  • CI ของฉันใช้ affected-only builds หรือไม่?
  • ฉันประกาศ env variable dependencies สำหรับทุก task ที่ cache แล้วหรือยัง?
  • ฉันมีแผนสำหรับ versioning และ changelogs — changesets หรือการตัดสินใจอย่างจงใจที่จะไม่ publish?
  • มีเจ้าของที่ระบุชื่อสำหรับ shared packages ไม่ใช่ “ทุกคน” ใช่ไหม?

ถ้าคุณติ๊กได้ส่วนใหญ่ monorepo จะตอบแทนคุณ ถ้าส่วนใหญ่ยังไม่มีคำตอบ อยู่ polyrepo อีกหน่อยแล้วกลับมาดูใหม่ในหกเดือน — tooling จะเคลื่อนไปอีก และความต้องการของทีมก็เช่นกัน

Monorepos เป็นเครื่องมือ ไม่ใช่ตัวตน เลือก configuration ที่เล็กที่สุดที่แก้ปัญหาเรื่อง sharing และ CI ที่คุณมีจริงๆ คุณเพิ่มได้เสมอ การถอดออกยากกว่า

อ่านเพิ่มเติม

  • pnpm workspaces documentation — reference มาตรฐานสำหรับ workspace:*, filtering และ recursive commands
  • Turborepo handbook — task pipeline, remote caching และ schema ของ turbo.json
  • Nx documentation — generators, module boundaries และ project graph
  • Moon documentation — ทางเลือก polyglot ที่น่าดูถ้า repo ของคุณไม่ใช่ JS/TS ล้วน
  • Changesets — คำตอบมาตรฐานสำหรับการ versioning และ publish packages ออกจาก monorepo

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง