Monorepo エンジニアリング実践:PNPM Workspaces、Turborepoパイプライン、200パッケージへのスケーリング
15のリポジトリを1つに統合した週
月曜日:15の独立したリポジトリ、それぞれ独自のCIパイプライン、依存関係バージョン、lintルール、リリースプロセス。共有型を更新するクロスカッティングな変更に15のPR、15回のCI実行、3日間の調整が必要だった。金曜日:1つのMonorepo。1回のpnpm install。1回のturbo run build。8パッケージにまたがる変更をまとめてコミット、テスト、リリース。
この移行には6週間かかり、その年の最良のインフラ投資となった。しかし失敗もあった——キャッシュを追加する前にCIが45分実行され、node_modulesがPNPMを適切に設定する前に12GBのディスクを消費し、パッケージ間の循環依存関係の解消に1週間かかった。以下が全ての学びだ。
Monorepo vs Multi-Repo:意思決定フレームワーク
| 要素 | Multi-Repo | Monorepo |
|---|---|---|
| クロスパッケージ変更 | 複数PR、調整されたマージ | 単一PR、アトミックな変更 |
| 依存関係バージョニング | リポジトリ間でドリフト | 単一の信頼できる情報源(catalog) |
| CI速度 | リポジトリ単位では速い | キャッシュなしでは遅い、ありでは速い |
| ローカル開発セットアップ | 1リポジトリのクローン、npm install |
1リポジトリのクローン、pnpm install |
| コード共有 | npmパッケージ、バージョン不一致リスク | 直接インポート、常に同期 |
| アクセス制御 | リポジトリ単位の権限(シンプル) | CODEOWNERSアプローチ(より複雑) |
| 学習曲線 | なし | 大規模チームでは無視できない |
Monorepoを使うべきでない場合
- チームが互いのコードに触れない:GoバックエンドチームとReactフロントエンドチームが型やロジックを共有しないなら不要。
- 厳格なアクセス制御要件:特定のパッケージを特定チームから隠す必要がある場合、Multi-Repoの方がシンプル。
- 超大規模リポジトリ(>10GB):Git操作が遅くなる。部分クローンや必要な分割で対応。
PNPMワークスペース詳細
PNPMは2026年のMonorepoにおけるデファクトスタンダード。厳格な依存関係解決により、最も一般的なMonorepoのミス——依存関係として宣言していないパッケージの誤ったインポート——を防止する。
ワークスペース設定
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
// package.json(ルート)
{
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.5.0"
},
"packageManager": "pnpm@9.1.0",
"engines": { "node": ">=20", "pnpm": ">=9" }
}
ワークスペースプロトコル(workspace:*)
// packages/ui/package.json
{
"name": "@myorg/ui",
"dependencies": {
"@myorg/utils": "workspace:*", // 常にローカルの最新版を使用
"@myorg/types": "workspace:^", // semver互換範囲
"react": "catalog:" // カタログから共有バージョンを取得
}
}
workspace:*プロトコルは、開発中にパッケージが常にローカルバージョンを参照することを保証。公開時にはPNPMが実際のバージョンに置き換える。workspace:^はセマンティックバージョニング互換範囲を許可し、より柔軟な依存関係宣言に適する。
PNPMカタログでバージョン統一
# pnpm-workspace.yaml
catalog:
react: ^19.0.0
react-dom: ^19.0.0
next: ^15.0.0
typescript: ^5.5.0
zod: ^3.23.0
1箇所でReactを更新すれば、200パッケージが追随。バージョンドリフトを解消。
.npmrc 重要な設定
# .npmrc
shamefully-hoist=false # 厳格モード、宣言されていない依存関係への誤ったアクセスを禁止
strict-peer-dependencies=true # 不足しているpeer依存関係でエラー、サイレント無視しない
node-linker=isolated # PNPM 9+デフォルト:パッケージごとのシンボリックリンク
パッケージ構造:200パッケージの整理
monorepo/
├── apps/
│ ├── web/ # Next.jsフロントエンド
│ ├── docs/ # ドキュメントサイト
│ └── admin/ # 管理画面
├── packages/
│ ├── ui/ # 共有Reactコンポーネント
│ ├── utils/ # 純粋なユーティリティ関数
│ ├── types/ # 共有TypeScript型
│ ├── config-eslint/ # 共有ESLint設定
│ ├── config-tailwind/ # 共有Tailwindプリセット
│ └── config-ts/ # 共有tsconfigベース
├── tooling/
│ ├── scripts/ # ビルド/デプロイスクリプト
│ └── generators/ # コードジェネレーター(plop/hygen)
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
パッケージ境界ルール
// ✅ 正しい:apps/webがpackages/uiをインポート
import { Button } from "@myorg/ui";
// ❌ 誤り:apps/webがapps/adminをインポート
import { AdminPanel } from "@myorg/admin"; // アプリは他のアプリに依存すべきでない
// ❌ 誤り:packages/uiがapps/webをインポート
import { router } from "@myorg/web"; // パッケージはアプリに依存すべきでない
// ✅ 正しい:packages/uiがpackages/utilsをインポート
import { cn } from "@myorg/utils"; // パッケージ間は相互依存可能(循環なしが前提)
この依存方向ルールが健全な依存関係グラフを保証する:アプリ→パッケージ、パッケージ→パッケージ、アプリ↛アプリ、パッケージ↛アプリ。
Turborepoパイプライン設定
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "build/**"],
"inputs": ["src/**", "tsconfig.json", "package.json"]
},
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["^build"]
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", "eslint.config.mjs"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**", "test/**", "vitest.config.ts"]
},
"check-types": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"]
}
}
}
Turborepo主要概念
| 設定 | 機能 | 重要性 |
|---|---|---|
dependsOn: ["^build"] |
依存関係を先にビルド(^ = 上流) |
消費者の前に依存がビルドされることを保証 |
dependsOn: ["build"] |
自身を先にビルド | 現在のパッケージのbuildが先に完了してからこのタスクを実行 |
outputs |
キャッシュ可能な出力ディレクトリ | 出力が存在すれば再実行をスキップ |
inputs |
キャッシュ有効性に影響するファイル | 非入力ファイルの変更でキャッシュ無効化しない |
cache: false |
このタスクをキャッシュしない | devサーバーなどの長期実行プロセス |
persistent: true |
長期実行プロセス、終了しない | turbo devは1つのアプリが終了しても全てを停止しない |
リモートキャッシュ
# Vercelにログイン(自前キャッシュサーバーも可)
npx turbo login
npx turbo link
リモートキャッシュはCI実行とチームメンバー間でビルド結果を共有。同じブランチを2人目がビルドする際、即座にキャッシュヒット。
依存関係管理の応用
循環依存関係の防止
// ❌ 循環!
// packages/utils/src/format.ts
import { Button } from "@myorg/ui";
// packages/ui/src/Button.tsx
import { formatDate } from "@myorg/utils"; // 循環形成
修正:第3のパッケージに共通関心事を抽出:
// packages/dates/src/format.ts —— 新パッケージ、uiやutilsに依存しない
export function formatDate(date: Date): string { ... }
// packages/utils/src/format.ts —— datesからインポート
import { formatDate } from "@myorg/dates";
// packages/ui/src/Button.tsx —— datesからインポート
import { formatDate } from "@myorg/dates";
// 依存関係グラフ:dates ← utils, dates ← ui(循環なし!)
循環依存関係の検出:
npx madge --circular --extensions ts,tsx packages/
依存関係アップグレード戦略
# 全パッケージのアップグレード可能な依存関係を確認
pnpm update -r --latest
# インタラクティブアップグレード(推奨)
pnpm up -r -i --latest
# 特定パッケージの依存関係のみアップグレード
pnpm --filter @myorg/ui update react react-dom
ゴースト依存関係の検出
// packages/uiのpackage.jsonにlodashが宣言されていない
// しかしコード内で使用 —— 「ゴースト依存関係」
import { debounce } from "lodash"; // ❌ PNPM厳格モードではエラー
// 正しい方法:依存関係を明示的に宣言
// pnpm --filter @myorg/ui add lodash
共有設定:一度定義、グローバル使用
TypeScriptプロジェクト参照
// tsconfig.json(ルート)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "packages/types" },
{ "path": "packages/utils" },
{ "path": "packages/ui" },
{ "path": "apps/web" }
]
}
共有ESLint設定
// tooling/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/consistent-type-imports": "error",
"import/no-cycle": "error",
},
}
);
// apps/web/eslint.config.mjs —— 基本設定を継承
import baseConfig from "@myorg/config-eslint/base.js";
export default [
...baseConfig,
{ rules: { "no-console": "warn" } },
];
Monorepo CI/CD実践
GitHub Actions + Turbo 完全設定
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Turbo変更検出に完全なgit履歴が必要
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm turbo lint check-types test build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
変更ベースの増分実行
# 変更の影響を受けるパッケージのみタスクを実行(PRシナリオ)
pnpm turbo run build test lint --filter=[origin/main...HEAD]
# webアプリとその全依存関係のみをビルド
pnpm turbo run build --filter=@myorg/web...
# utilsパッケージとその全消費者をビルド
pnpm turbo run build --filter=...@myorg/utils
Dockerマルチステージビルド + Turbo Prune
# Stage 1: Prune(最小依存関係サブセットを抽出)
FROM node:20-alpine AS pruner
WORKDIR /app
RUN npm install -g turbo@latest
COPY . .
RUN turbo prune --scope=@myorg/web --docker
# Stage 2: 依存関係インストールとビルド
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN corepack enable && pnpm install --frozen-lockfile --prod=false
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@myorg/web
# Stage 3: ランタイム最小イメージ
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
turbo pruneはターゲットアプリとその推移的依存関係のみを含む最小サブセットを作成。200パッケージ全てをDockerイメージにコピーする必要なし。
移行ロードマップ:15リポジトリ → 1 Monorepo
フェーズ1:依存関係の監査(1週目)
4つの異なるバージョンのdate-fns、3つのlodash、2つのzodを発見。全てカタログに統一。同時にパッケージ間の実際の依存関係グラフを整理。
フェーズ2:スケルトン作成(2週目)
apps/、packages/、tooling/ディレクトリ構造を確立し、pnpm-workspace.yamlとturbo.jsonを初期化。
フェーズ3:共有パッケージ抽出(3-4週目)
ボトムアップ抽出:types → utils → ui → 設定パッケージ。各抽出パッケージは独立したビルドとテストを維持。
フェーズ4:アプリケーション移行(5週目)
アプリを1つずつ移行、各移行後にビルドとデプロイを検証。Turboの--filterで変更パッケージのみビルド。
フェーズ5:最適化(6週目)
キャッシュ有効化、CI最適化、Dockerビルド更新、設定統一。
実パフォーマンスデータ
| 指標 | 移行前(15リポジトリ) | 移行後(Monorepo) | 変化 |
|---|---|---|---|
pnpm install時間(コールド) |
11分15秒 | 28秒 | -95.8% |
| フルCIビルド | 48分 | 3.8分(キャッシュ:12秒) | -92.1% |
| クロスパッケージ変更PR | 6-8個 | 1個 | -85% |
| ディスク使用量(node_modules) | 12.75GB | 2.1GB | -83.5% |
| 新規開発者オンボーディング | ~2時間 | 8分 | -93.3% |
| 依存関係バージョン衝突 | スプリントあたり4-6回 | 0 | -100% |
PNPMのコンテンツアドレス可能ストアと単一グローバルストアへのハードリンクにより、重複するnode_modulesを排除。TurborepoキャッシュによりCI増分ビルドがほぼ瞬時に。
よくあるトラブルと解決策
トラブル1:workspace:*が公開時に置き換えられない
現象:npmに公開されたパッケージでworkspace:*プロトコルが実際のバージョン番号に置き換えられていない。
原因:pnpm publishではなくnpm publishを使用。
解決:pnpm publishを統一使用するか、publishConfigを設定。
トラブル2:Turborepoキャッシュヒットしたがコードが反映されない
現象:ソースコードを修正したがビルド結果が変わらない。
原因:inputs設定が不完全で、修正ファイルがキャッシュキーに含まれていない。
解決:turbo.jsonのinputsが全関連ファイルをカバーしているか確認。
トラブル3:依存関係がインストール済みなのにCannot find module
現象:PNPM厳格モードで依存モジュールが見つからない。
原因:package.jsonに宣言されていない依存関係(ゴースト依存関係)をコード内で使用。
解決:pnpm --filter <package> add <pkg>で依存関係を明示的に追加。
まとめ:Monorepoは全てを1つのフォルダに詰め込むことではなく、クロスパッケージ変更を安価で安全にすることだ。3つの柱:PNPM workspacesによる厳格な依存関係分離、Turborepoによるビルドオーケストレーションとキャッシュ、共有設定によるパッケージ間の一貫性。共有型とユーティリティの抽出から始める。開発者を増やす前にキャッシュを追加する。全てを測定する。数字は嘘をつかない:移行後、チームのクロスパッケージ変更速度は5倍に向上した。
オンラインツール
- JSONフォーマッター — Monorepoセットアップ時の
package.jsonとturbo.json設定の検査 - ハッシュ計算機 — ビルドキャッシュのためのクロスパッケージファイル整合性検証
ブラウザローカルツールを無料で試す →