モノレポで月30時間浮いた話|Turborepo・pnpmの地雷と実装パターン
複数リポジを一本化してわかった現実。Turborepo 2.x + pnpmで重複コード削減、CI時間短縮。実装中の失敗パターンと解決策を具体的に紹介します。
うちのチームがモノレポで30時間を取り戻すまで
先日プロジェクトで複数のパッケージ(Web・API・CLI・Shared)が独立したリポジトリで管理されてて、変更が入るたびに「あ、このロジック共通化できるんだけど…」みたいな悔しい思いをしてたんですよ。結局、重複コードが増殖して、バグ修正のたびに複数リポジトリを編集するという地獄。
そこで思い切ってモノレポ化を決断。Turborepo 2.x + pnpm Workspacesで3ヶ月かけて移行した結果、月30時間の開発時間が削減されました。正直最初は「モノレポって複雑そう…」という懸念がありましたが、実装してわかったのは、整理不足で遅いリポジトリより、ちゃんと設計されたモノレポの方が圧倒的に高速ということ。
実装中に踏んだ地雷も多いので、僕たちが学んだパターンを共有します。
Turborepo 2.xで構築した実装パターン
うちのチームではCreate React AppからNext.js 15への移行と同時にモノレポ化を進めたので、フロントエンド・バックエンド・共有ロジックが混在していました。最初はnpm workspacesで試したんですが、ファントムデペンデンシー問題やロック差分が頻発。pnpmに変えたら一気に安定しました。
# pnpm セットアップ
pnpm install
# モノレポの構造
my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── packages/
│ ├── web/ # Next.js フロントエンド
│ ├── api/ # Node.js + Hono API
│ ├── cli/ # CLI ツール
│ └── shared/ # 共有ロジック・型定義
├── apps/
│ └── docs/ # Storybook ドキュメント
└── tools/
└── config/ # 設定ファイル一元化
turbo.jsonで依存関係を明示的に定義するのが重要です。これでキャッシング戦略が効き始めます。
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next", "dist/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"outputs": [],
"cache": true
},
"type-check": {
"outputs": [],
"cache": true
},
"test": {
"outputs": ["coverage/**"],
"cache": true
}
}
}
ここで重要なのはdependsOn。^buildと書くことで、依存しているパッケージのビルドが完了してから自分のビルドを開始します。これで循環依存を防げます。
正直最初、僕たちは全パッケージを並列ビルドしようとして失敗しました。出力順序がバラバラになって、型チェックエラーが後から見つかるという悔しい思いを何度も…。dependsOnを正しく設定してから、ビルド時間は25分→8分に短縮されました。
pnpm Workspacesの地雷を踏み抜いて学んだこと
pnpm-workspace.yamlはシンプルですが、落とし穴があります。
packages:
- 'packages/*'
- 'apps/*'
- 'tools/*'
pnpm:
overrides:
typescript: '5.6.3'
eslint: '9.0.0'
うちが失敗した事例:「あるパッケージだけ古いTypeScriptバージョンを使いたい」って思ったんですよ。そこでpackage.jsonに明示的に指定したら、overridesと競合して怪しい挙動が発生。結局、全パッケージで統一するか、完全な隔離が必要だと気づきました。
ファントムデペンデンシーはpnpmを選べば99%解決します。 ただし、既存のnpm scriptsがnode_modulesの深い階層を仮定していた場合、移行時に壊れます。僕たちのsharedパッケージで古いutilsが../../../node_modules/lodashみたいなパスを硬くコーディングしてたので、削除に苦労しました。
# pnpm の厳密なモード有効化
pnpm config set strict-peer-dependencies true
これを設定しておくと、install時に問題が顕在化するので、後からの地獄が減ります。
共有パッケージの設計で月10時間削減した理由
重要なのは、モノレポは「なんでもかんでも共有化する」ことではないということ。うちのチームでは、本当に共通な型定義とビジネスロジックだけをsharedに抽出しました。
// packages/shared/src/types/index.ts
export type User = {
id: string;
email: string;
createdAt: Date;
role: 'admin' | 'user';
};
export type ApiResponse<T> = {
success: boolean;
data?: T;
error?: string;
};
// packages/shared/src/utils/validation.ts
export const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
export const validateUserId = (id: string): boolean => {
return /^[a-zA-Z0-9_-]{16,}$/.test(id);
};
そして、sharedパッケージはゼロ依存を目指しました。lodashすら入れない。これで、どのパッケージからでも安全にインポートできます。
実装中に気づいたのは、webパッケージが使うUIコンポーネントは共有するべきではないということ。理由は、APIの型定義が変わったときの更新速度が全然違うから。webはcomponents、apiはhandlersで独立した設計の方が、変更の爆発範囲が限定されます。
{
"name": "@monorepo/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
"./types": "./src/types/index.ts",
"./validation": "./src/utils/validation.ts",
"./api": "./src/api/index.ts"
}
}
条件付きエクスポートを使うことで、パッケージの利用側が「何を使いたいのか」を明確にできます。これが実は地味だけど、循環依存の防止と保守性向上に効きます。
GitHub Actionsでビルドキャッシュを活かすまで
Turbo Remoteでクラウドキャッシュを使う選択肢もありますが、うちはプライベートリポジトリなのでGitHub Actionsのキャッシュで十分。ただし、設定を間違えると全キャッシュが無効化される罠があります。
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # turborepo が差分検出に必要
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build --cache-dir=.turbo
# 重要: キャッシュディレクトリを明示的に指定
- run: pnpm turbo lint
- run: pnpm turbo test
- uses: actions/cache@v3
with:
path: .turbo
key: turbo-${{ github.sha }}
restore-keys: |
turbo-
重要なポイント:fetch-depth: 0を指定しないと、Turboが差分を検出できず、全パッケージを再ビルドするハメになります。僕たちは最初これで失敗して、CI時間が12分→45分に跳ね上がるという地獄を経験。
あと、pnpm install --frozen-lockfileは必須。開発者がpnpm-lock.yamlを別バージョンで生成してCI上で競合が起きると、キャッシュが全部無効化されます。
実装してわかったのは、モノレポのCI高速化は9割が依存管理と差分検出。ビルド最適化は2番目の話です。
開発体験の改善:Filtering と Scoped Runs
月30時間削減の大きな要因は、開発時に「必要なパッケージだけ」を動かせるようになったこと。以下のコマンドが大活躍してます。
# 特定パッケージのみビルド
pnpm turbo run build --filter=@monorepo/web
# 変更されたパッケージのみテスト
pnpm turbo run test --filter=[HEAD]
# web パッケージとその依存物をビルド
pnpm turbo run build --filter=@monorepo/web...
# 開発モード(特定パッケージのみ)
pnpm turbo run dev --filter=@monorepo/web --parallel
これで、「API側のバグ修正をしてるときに、フロントエンドの全テストが走る」みたいなムダがなくなりました。
あと地味だけど、package.jsonのscriptsを統一したのが大きい。全パッケージで同じスクリプト名を使うことで、Turboのrunコマンドが効果的に動きます。
{
"scripts": {
"build": "tsc --noEmit && tsup src/index.ts",
"dev": "tsup --watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --fix",
"type-check": "tsc --noEmit"
}
}
失敗した設計と今の現実
正直に言うと、最初は「全ツールの設定も一元化しよう」と思ったんですよ。eslint.config.mjsとかtsconfig.jsonを共有パッケージに。でも、このアプローチは失敗しました。理由は、プロジェクトごとに「少しだけ違うルール」が必要だから。
Web側のESLintルールはReactに最適化したいし、CLI側はNode.js向けにしたい。それを共有パッケージで管理しようとすると、いつもextendで上書きが必要になって、むしろ複雑化しました。
今はtools/configに「推奨設定テンプレート」を置いて、各パッケージが必要に応じてカスタマイズする形に落ち着きました。
tools/config/
├── eslint/
│ ├── base.mjs # 全パッケージ共通
│ ├── react.mjs # React向け
│ └── node.mjs # Node.js向け
├── typescript/
│ ├── base.json
│ ├── strict.json # 厳密モード
│ └── browser.json # ブラウザ向け
└── vitest/
└── base.config.ts
これでようやく「統一性」と「柔軟性」のバランスが取れました。
開発時間の削減を数値で見ると
xychart-beta
title "月別の開発作業時間の推移"
x-axis [1月, 2月, 3月, 4月, 5月]
y-axis "時間 (時間/月)" 0 --> 200
line [150, 140, 95, 85, 75]
1月が既存リポジトリ運用、3月が移行完了。5月時点で、複数リポジトリ時代より月30時間削減されています。
特に効いたのが以下の3点:
| 改善項目 | 削減時間 | 理由 |
|---|---|---|
| 共有ロジック修正が1回で済む | 月15時間 | 複数リポジトリで同じ修正を繰り返さない |
| CI時間の短縮 | 月8時間 | 変更パッケージのみのテスト実行 |
| 型定義の一元化 | 月7時間 | 開発体験向上で手戻りが減少 |
現在進行形で課題なこと
モノレポも完璧ではないですよ。正直、いくつか困ってることがあります。
デプロイメント周りの複雑性が増しました。以前は「webをデプロイ」「apiをデプロイ」と独立してたけど、今は「どのパッケージが変わったから、どれをデプロイするべきか」を判断するロジックが必要。Turborepoのファイル追跡を活用してますが、本番障害になる前に気づけてない時もあります。
あと、大規模なリファクタリングが怖い。型定義を変更すると、複数パッケージの型チェックが一気に壊れる。以前は独立リポジトリだったから、影響範囲が限定されてたけど、今は依存グラフ全体を考慮する必要があります。
その対策として、最近は自動テストを強化したり、Feature Flagで段階的ロールアウトしたりしてます。
Next.jsのような既存モノレポプロジェクトから学んだこと
先日、Next.jsのリポジトリをガッツリ読んでみたんですよ。彼らもTurborepo使ってるけど、徹底してるのはパッケージ境界の厳密性。不要な依存は即座にリファクタリング対象にしてる。
あと依存関係の設計も参考になりました。基本は「外側が内側に依存する」という方向性を守ること。モノレポでも同じです。
packages/
├── shared/ # 最内層:ビジネスロジックと型
├── api/ # 中層:APIハンドラ
├── web/ # 外層:UIコンポーネント
└── cli/ # 外層:CLI実装
この依存方向が逆にならないよう、ESLintでeslint-plugin-importのno-cycleルールを厳しくしてます。
Turborepo v2.0への移行
つい最近v2系へ移行したんですが、キャッシング戦略が細粒度化されて、さらに高速化しました。ただ、移行は結構地雷が多い。古いバージョンのタスク定義が互換性なく変わったり、turbo.jsonの仕様が変わったり。
段階的な移行がおすすめです。いきなり全パッケージを新バージョンに持ち上げると、ビルドが壊れて3日ハマります。まずはローカルで検証して、テストが全部通るのを確認してからCI環境に反映するくらいの慎重さがちょうどいい。
まとめ
モノレポ化で月30時間削減できたのは、単なるツール導入ではなく、依存管理と開発プロセスの整理があったからです。
要点:
-
Turborepo + pnpm は組み合わせ必須 — ファントムデペンデンシーを根絶し、キャッシング効率を最大化できる
-
共有パッケージはミニマルに — 型定義とビジネスロジックだけに。UI や設定まで共有化しようとすると、かえって複雑になる
-
CI/CDの差分検出が9割 — GitHub Actionsのキャッシュ設定とTurboのフィルタリングで、無駄なビルド・テストを劇的に削減できる
-
段階的リファクタリング — 型安全性は守りつつ、Feature Flagで本番リスクを減らす
-
デプロイメントの自動化は後で — 最初はローカルやステージングで十分に検証してから、本番パイプラインを構築する
モノレポ導入を検討してるなら、まずは小さいパッケージから試してみることをおすすめします。うちのチームも最初はsharedとwebだけでスタート。そこで学んだパターンをapiに適用したら、すんなり移行できました。
あと、依存グラフを定期的に可視化するツール(depcheckなど)を導入しておくと、無駄な依存関係が増殖する前に気づけます。