モノレポで月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 scriptsnode_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の型定義が変わったときの更新速度が全然違うから。webcomponentsapihandlersで独立した設計の方が、変更の爆発範囲が限定されます。

{
  "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.jsonscriptsを統一したのが大きい。全パッケージで同じスクリプト名を使うことで、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-importno-cycleルールを厳しくしてます。

Turborepo v2.0への移行

つい最近v2系へ移行したんですが、キャッシング戦略が細粒度化されて、さらに高速化しました。ただ、移行は結構地雷が多い。古いバージョンのタスク定義が互換性なく変わったり、turbo.jsonの仕様が変わったり。

段階的な移行がおすすめです。いきなり全パッケージを新バージョンに持ち上げると、ビルドが壊れて3日ハマります。まずはローカルで検証して、テストが全部通るのを確認してからCI環境に反映するくらいの慎重さがちょうどいい。

まとめ

モノレポ化で月30時間削減できたのは、単なるツール導入ではなく、依存管理と開発プロセスの整理があったからです。

要点:

  1. Turborepo + pnpm は組み合わせ必須 — ファントムデペンデンシーを根絶し、キャッシング効率を最大化できる

  2. 共有パッケージはミニマルに — 型定義とビジネスロジックだけに。UI や設定まで共有化しようとすると、かえって複雑になる

  3. CI/CDの差分検出が9割 — GitHub Actionsのキャッシュ設定とTurboのフィルタリングで、無駄なビルド・テストを劇的に削減できる

  4. 段階的リファクタリング — 型安全性は守りつつ、Feature Flagで本番リスクを減らす

  5. デプロイメントの自動化は後で — 最初はローカルやステージングで十分に検証してから、本番パイプラインを構築する

モノレポ導入を検討してるなら、まずは小さいパッケージから試してみることをおすすめします。うちのチームも最初はsharedwebだけでスタート。そこで学んだパターンをapiに適用したら、すんなり移行できました。

あと、依存グラフを定期的に可視化するツール(depcheckなど)を導入しておくと、無駄な依存関係が増殖する前に気づけます。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事