← 返回部落格
·9 min 閱讀·系統架構

Monorepo 全端開發實踐:用 Turborepo 管理 Next.js + NestJS 專案

分享使用 Turborepo + pnpm workspace 管理全端 Monorepo 的實際經驗,涵蓋目錄結構設計、共享套件策略、建置管線優化與 Docker 部署。

MonorepoTurborepoNext.jsNestJS架構設計
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

為什麼用 Monorepo

在開發一個全端專案(前端 Next.js + 後端 NestJS + 共用型別/配置)時,最常見的選擇是:

Multi-repo:前端和後端各自一個 repository

  • 優點:職責清晰、各自獨立部署
  • 缺點:共用程式碼(型別定義、utils)難以同步;跨 repo 的改動需要多個 PR

Monorepo:所有程式碼在同一個 repository

  • 優點:共用程式碼直接 import、原子性的跨模組改動、統一的工具鏈
  • 缺點:需要額外的工具管理建置順序和快取

對我們的場景——前後端共用大量型別定義、且團隊人數不多——Monorepo 是更好的選擇。

目錄結構設計

project-root/
├── apps/
│   ├── web/          # Next.js 前端
│   └── api/          # NestJS 後端
├── packages/
│   ├── database/     # Drizzle schema + migrations
│   ├── config/       # 共用設定(ESLint, TypeScript)
│   └── types/        # 共用型別定義
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

為什麼用這種結構?

  • apps/ 放可獨立部署的應用
  • packages/ 放被多個 app 共用的程式碼
  • 清楚的依賴方向:apps/ 依賴 packages/,反過來不行

pnpm workspace 設定

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

每個 package 的 package.json

// packages/database/package.json
{
  "name": "@project/database",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "generate": "drizzle-kit generate",
    "migrate": "drizzle-kit migrate"
  }
}
// apps/web/package.json
{
  "name": "@project/web",
  "dependencies": {
    "@project/database": "workspace:*",
    "@project/types": "workspace:*"
  }
}

workspace:* 告訴 pnpm:這個依賴來自同一個 workspace,不要去 npm registry 找。

Turborepo:建置管線與快取

turbo.json 設定

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "dev": {
      "persistent": true,
      "cache": false
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"]
    },
    "db:generate": {
      "cache": false
    }
  }
}

關鍵是 "dependsOn": ["^build"]——^ 代表「先建置我依賴的 packages」。所以當你 build apps/web 時,Turborepo 會自動先 build packages/databasepackages/types

快取的威力

Turborepo 會對每個 task 做 content-hash based 快取。如果你只改了 apps/web 的程式碼:

  • packages/database 的 build → 命中快取(跳過)
  • packages/types 的 build → 命中快取(跳過)
  • apps/web 的 build → 重新建置

在大型 monorepo 中,這能把 CI 時間從 10 分鐘降到 2 分鐘。

共用 Database Package

這是 Monorepo 最有價值的地方之一——前後端共用同一份資料庫 schema 定義:

// packages/database/src/schema.ts
import { pgTable, varchar, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';

export const cards = pgTable('cards', {
  id: varchar('id').primaryKey(),
  name: varchar('name').notNull(),
  setCode: varchar('set_code').notNull(),
  rarity: varchar('rarity'),
  imageUrl: varchar('image_url'),
  prices: jsonb('prices'), // { nm: 120, lp: 100, mp: 80 }
  updatedAt: timestamp('updated_at').defaultNow(),
});

export const listings = pgTable('listings', {
  id: integer('id').generatedAlwaysAsIdentity().primaryKey(),
  cardId: varchar('card_id').references(() => cards.id),
  sellerId: integer('seller_id').references(() => users.id),
  condition: varchar('condition').notNull(), // NM, LP, MP, HP, DMG
  price: integer('price').notNull(),
  quantity: integer('quantity').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
});

// ... 更多 table
// packages/database/src/index.ts
export * from './schema';
export * from './client';
export type { InferSelectModel, InferInsertModel } from 'drizzle-orm';

前後端各自使用

// apps/api/src/listings/listings.service.ts
import { db, listings, cards } from '@project/database';
import { eq, and, gte, lte } from 'drizzle-orm';

async function searchListings(filters: SearchFilters) {
  return db.select()
    .from(listings)
    .innerJoin(cards, eq(listings.cardId, cards.id))
    .where(and(
      filters.minPrice ? gte(listings.price, filters.minPrice) : undefined,
      filters.maxPrice ? lte(listings.price, filters.maxPrice) : undefined,
    ));
}
// apps/web/src/lib/api.ts
import type { InferSelectModel } from '@project/database';
import type { listings, cards } from '@project/database';

// 前端用 schema 推導出的型別,確保與 DB 同步
type Listing = InferSelectModel<typeof listings>;
type Card = InferSelectModel<typeof cards>;

改了 schema → 前後端同時得到型別更新 → 不一致的地方立刻報錯。

共用型別 Package

除了 DB schema 衍生的型別,還有 API request/response 的型別:

// packages/types/src/api.ts
export interface SearchRequest {
  cardName?: string;
  setCode?: string;
  minPrice?: number;
  maxPrice?: number;
  condition?: CardCondition[];
  page?: number;
  pageSize?: number;
}

export interface SearchResponse {
  items: ListingWithCard[];
  pagination: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

export type CardCondition = 'NM' | 'LP' | 'MP' | 'HP' | 'DMG';

前後端都從 @project/types import,任何 API 格式的變動都會被 TypeScript 即時捕捉。

開發體驗

一行指令啟動所有服務

// root package.json
{
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint"
  }
}

pnpm dev 同時啟動 Next.js dev server + NestJS watch mode + 任何其他需要的服務。Turborepo 會在一個整合的終端機中顯示所有服務的 log。

TypeScript Project References

為了讓 IDE 的跳轉和自動完成正確運作,需要在 tsconfig 中設定 project references:

// apps/web/tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@project/database": ["../../packages/database/src"],
      "@project/types": ["../../packages/types/src"]
    }
  },
  "references": [
    { "path": "../../packages/database" },
    { "path": "../../packages/types" }
  ]
}

這樣在 VS Code 中 Ctrl+Click 就能直接跳到 package 的原始碼。

Docker 部署策略

Monorepo 的 Docker 化比較需要技巧——你不想每個 app 的映像都包含整個 monorepo。

使用 Turborepo prune

# Stage 1: 用 turbo prune 提取只與 web app 相關的檔案
FROM node:20-alpine AS pruner
WORKDIR /app
COPY . .
RUN npx turbo prune @project/web --docker

# Stage 2: 安裝依賴
FROM node:20-alpine AS deps
WORKDIR /app
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile

# Stage 3: 建置
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@project/web

# Stage 4: 執行
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./.next/static
COPY --from=builder /app/apps/web/public ./public
CMD ["node", "server.js"]

turbo prune 會只提取目標 app 和它的依賴 packages,排除不相關的程式碼。最終映像乾淨且精簡。

踩過的坑

坑 1:Workspace 依賴的版本解析

// 不好:用 "*" 可能匹配到 npm registry 的套件
"@project/database": "*"

// 好:明確指定是 workspace 來源
"@project/database": "workspace:*"

坑 2:循環依賴

如果 packages/a import 了 packages/bpackages/b 又 import 了 packages/a,Turborepo 的 build 會報錯。

解法:把被雙方共用的東西抽到第三個 package(如 packages/shared)。

坑 3:Root dependencies vs Package dependencies

共用的開發工具(ESLint、TypeScript、Prettier)裝在 root。業務邏輯的依賴裝在各自的 package/app 中。

# 裝在 root(-w flag)
pnpm add -Dw eslint typescript prettier

# 裝在特定 app
pnpm add react next --filter @project/web

坑 4:Next.js 的 transpilePackages

Next.js 預設不會 transpile node_modules 中的套件。但 workspace packages 是 TypeScript 原始碼,需要告訴 Next.js 處理它們:

// apps/web/next.config.ts
const nextConfig = {
  transpilePackages: ['@project/database', '@project/types'],
};

何時不該用 Monorepo

Monorepo 不是銀彈。以下情況可能不適合:

  • 團隊很大(50+ 人):不同團隊可能有不同的 release 節奏,monorepo 的「全部一起」可能拖慢速度
  • 技術棧差異太大:如果前端是 React + TypeScript、後端是 Go 或 Rust,共享程式碼的價值有限
  • 需要獨立的存取權限:monorepo 中所有人都能看到所有程式碼
  • 已有成熟的 CI/CD:如果現有的 multi-repo CI 已經很順,遷移成本可能大於收益

結語

Monorepo 的核心價值不是「把程式碼放在一起」,而是「消除跨模組的同步成本」。型別不一致、版本不同步、跨 repo PR 的協調——這些在 multi-repo 中常見的痛點,在 Monorepo 中幾乎不存在。

Turborepo + pnpm workspace 是目前 JavaScript/TypeScript 生態中最成熟的組合。初始設定需要花一些時間,但一旦跑起來,開發體驗會明顯提升。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →