# pnpm + Turborepo で型安全なモノレポを設計する：catalog でドリフトを消し、共有ドメイン型を単一真実源にする

> pnpm workspaces + Turborepo で本番TypeScriptモノレポを設計する実装ガイド。catalogによる依存バージョンのピン留め、turbo.jsonのタスクパイプラインとキャッシュ、Zod共有ドメインパッケージの単一真実源化、型カバレッジのCI強制、スキーマdrift検出までを実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: モノレポ, TypeScript, アーキテクチャ設計, CI/CD, 型安全
- URL: https://tomodahinata.com/blog/pnpm-turborepo-monorepo-architecture-type-coverage-guide

## 要点

- モノレポの価値は複数アプリが同じドメイン語彙を共有し、語彙を変えたとき一括で型チェックできることにある
- pnpm catalog で zod・react 等の共有外部依存のバージョンを1か所に集約し、サイレントなドリフトを構造的に殺す
- Turborepo の tasks で dependsOn の依存順序を宣言し、outputs を必ず書く（書かないとキャッシュされない）
- packages/domain に Zod スキーマを集約して型は z.infer で導出し、境界は parse、内部は as を使わず NeverError で網羅を強制する
- 型カバレッジ閾値とスキーマ drift 検出を CI ゲートにし、規律を人間の善意でなく赤信号で機械強制する

---

「アプリが2つになった」——たいてい、ここからモノレポを検討し始めます。受講者向けのフロント、運営の管理画面、共通のドメイン定義、DB型、Edge Function。それぞれが別リポジトリに散らばると、**同じ「ユーザー」「コース」「サブスクリプション」という概念が、リポジトリごとに少しずつズレた型で定義される**ようになります。片方を直してもう片方を直し忘れる。バージョンがいつの間にかバラける。そして本番で「型は通ったのに壊れる」が起きます。

モノレポはこれを解く道具です。ただし**無秩序なモノレポは、ポリレポより地獄です**。1つの巨大な依存グラフに全部が押し込まれ、ビルドは遅く、循環依存が絡まり、どのパッケージを直すと何が壊れるか誰も把握できなくなる。

この記事は、**pnpm workspaces + Turborepo + Zod 共有ドメインパッケージ**で「型安全に育つモノレポ」を設計する実装ガイドです。題材として、私が構築した[金融リテラシー教育のサブスク学習プラットフォーム](/case-studies/subscription-learning-platform)（受講者アプリ・運営管理・共有14パッケージのモノレポ）と、別案件のリアルタイムスポーツ採点アプリで実際に効いた設計判断を交えます。公式ドキュメントに忠実な設定を示しつつ、**「なぜそうするか」**まで踏み込みます。

> **この記事のルール**：設定キー・プロトコルの仕様は **pnpm 公式ドキュメント／Turborepo 公式ドキュメント（2026年6月時点）** に基づきます。設定キー名は版によって変わります（Turborepo は旧 `pipeline` → 現 `tasks` のように改名された前例があります）。本番投入前に必ず公式の最新版で確認してください。コードは実運用で使える形に整えていますが、シークレットは環境変数前提です（ハードコード厳禁）。

---

## 0. メンタルモデル：モノレポの価値は「共有語彙 × 一括型チェック」

最初に、この記事を貫く考え方を1つだけ固定します。

**モノレポの価値＝「複数のアプリが同じドメイン語彙を共有し、その語彙を変えたとき一括で型チェックできる」こと。**

`Subscription` の状態に `paused` を追加したら、受講者アプリ・管理画面・DB型・Edge Function の**すべてのスイッチ文がコンパイルエラーになって「ここも直せ」と教えてくれる**——これがモノレポで得たい体験です。逆に言うと、この体験が得られないモノレポは、ただの「フォルダをまとめただけ」で、価値の大半を取りこぼしています。

この体験を成立させる鍵は4つです。本記事はこの順で進みます。

1. **依存のドリフトを消す** → pnpm **catalog**（共有依存のバージョンを1か所にピン留め）
2. **ビルド/テストをキャッシュで速くする** → **Turborepo**（タスクパイプライン＋キャッシュ）
3. **共有型を単一真実源にする** → **domain package**（Zod スキーマを1か所に集約）
4. **規律をCIで機械強制する** → 型カバレッジゲート・スキーマdrift検出（人間の意志に頼らない）

この4つが揃って初めて、モノレポは「育てられる資産」になります。1つでも欠けると、規模が増えるほど崩れます。

---

## 1. pnpm workspaces：土台を作る

### 1.1 `pnpm-workspace.yaml`：どれがパッケージか

pnpm のワークスペースは、リポジトリ直下の `pnpm-workspace.yaml` に**「どのディレクトリがパッケージか」**をグロブで宣言するところから始まります。公式の形式はこうです。

```yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"        # 受講者アプリ・管理画面など「最終成果物」
  - "packages/*"    # domain / ui / config など「共有部品」
  - "!**/test/**"   # テストディレクトリは除外
```

公式ドキュメントは `packages` フィールドにグロブパターン（`packages/*`・`components/**`・除外用の `!**/test/**`）を列挙する形式を示しています。ディレクトリ構成として私が標準にしているのは、この2層です。

```
my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json          # ルート（private: true、共通scriptのみ）
├── apps/
│   ├── learner/          # 受講者アプリ（Next.js）
│   └── admin/            # 運営管理（Next.js）
└── packages/
    ├── domain/           # Zod ドメインスキーマ（単一真実源）
    ├── ui/               # 共有UIコンポーネント
    ├── config-eslint/    # 共有ESLint設定
    └── config-ts/        # 共有tsconfig
```

**`apps/` は「依存される側にならない最終成果物」、`packages/` は「複数のアプリから依存される共有部品」**という区別を最初に決めておきます。この境界が後の循環依存対策（第6章）の前提になります。

### 1.2 `workspace:*`：ローカル参照を明示する

アプリから共有パッケージを使うときは、`package.json` の依存に **workspace プロトコル**を書きます。これが「npm レジストリではなく、このワークスペース内のパッケージを使え」という明示です。

```json
// apps/learner/package.json
{
  "name": "@app/learner",
  "private": true,
  "dependencies": {
    "@repo/domain": "workspace:*",
    "@repo/ui": "workspace:*"
  }
}
```

公式は `workspace:` に複数のバリアントがあると明記しています。

| 書き方 | 意味 |
| --- | --- |
| `workspace:*` | ワークスペース内の任意のバージョンに一致（モノレポ内ではこれが基本） |
| `workspace:^` | キャレット範囲として扱う |
| `workspace:~` | チルダ範囲として扱う |
| `workspace:2.0.0` | 厳密なバージョン指定 |

> **公開時の自動変換**：公式によれば、`pnpm publish` / `pnpm pack` で**アーカイブ化する瞬間に `workspace:` は実際のバージョンへ動的に置換**されます。`workspace:*` → `1.5.0`、`workspace:~` → `~1.5.0`、`workspace:^` → `^1.5.0` のように。つまり**社内では `workspace:*` のまま開発し、外部公開時だけ実バージョンになる**——内部の利便性と、公開後の semver 互換を両立できます。社内専用パッケージ（`private: true`）なら公開自体しないので、`workspace:*` で迷う必要はありません。

ここで `as`・`any` の前にひとつ規律を入れておきます。私の案件では**共有パッケージはすべて `private: true`**にします（社外公開しないものを誤って publish しない安全弁）。公開する予定がないものに publish フローの考慮を持ち込まないのは YAGNI です。

---

## 2. pnpm catalog：依存ドリフトを「構造的に」殺す

ここがモノレポ運用で**最も静かに事故る**ポイントです。

### 2.1 何が問題か：サイレントなバージョンドリフト

`workspace:*` で**社内パッケージ**のバージョンは揃います。しかし **`zod`・`react`・`typescript` のような外部依存**は、各 `package.json` が個別に書きます。受講者アプリは `zod@3.23`、管理画面は `zod@3.22`、domain パッケージは `zod@3.24`……と、誰も意図しないうちにバラけていく。これが**サイレントなドリフト**です。

何が起きるか。`zod` がメジャー間で型の振る舞いを変えたとき、**domain パッケージが返す型と、それを受けるアプリ側の `zod` が解釈する型がズレ**ます。「単一真実源のつもりの domain 型が、消費側で別物として解釈される」——モノレポの価値そのものが崩れる事故です。バンドルにも複数バージョンの `zod` が同梱され、肥大化します。

### 2.2 catalog：バージョンを1か所に集約する

pnpm の **catalog** は、この共有依存のバージョンを `pnpm-workspace.yaml` の1か所に集約する仕組みです。公式の形式はこうです。

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

# デフォルトカタログ（単数形 catalog）
catalog:
  zod: ^3.24.1
  react: ^19.2.0
  react-dom: ^19.2.0
  typescript: ^5.6.3

# 名前付きカタログ（複数形 catalogs）— 移行期に複数バージョンを共存させたいとき
catalogs:
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0
```

各パッケージの `package.json` は、バージョンを**直接書かず** `catalog:` プロトコルで参照します。

```json
// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "dependencies": {
    "zod": "catalog:"
  }
}
```

```json
// apps/learner/package.json
{
  "dependencies": {
    "@repo/domain": "workspace:*",
    "react": "catalog:",
    "react-dom": "catalog:",
    "zod": "catalog:"
  }
}
```

名前付きカタログを使うときは `catalog:<名前>` と書きます。

```json
{
  "dependencies": {
    "react": "catalog:react18"
  }
}
```

公式によれば、`catalog:` プロトコルは `dependencies`・`devDependencies`・`peerDependencies`・`optionalDependencies`、そして `pnpm-workspace.yaml` の `overrides` で使えます。`pnpm publish` / `pnpm pack` の際には**実際のバージョン範囲へ自動置換**されるため、公開パッケージでも安全に使えます。

### 2.3 catalog がもたらすもの

公式は catalog の効能を「**単一真実源（single source of truth）**」と表現しています。私の言葉で、設計原則に紐づけて整理するとこうです。

- **DRY（単一真実源）**：`zod` のバージョンという「知識」が、リポジトリ内でただ1か所に存在する。3つの `package.json` に同じ `^3.24.1` が散らばらない。
- **アップグレードの一撃化**：`zod` を上げたいとき、`pnpm-workspace.yaml` の**1行を変えるだけ**。全パッケージが追従する。「あのアプリの package.json を直し忘れた」が**構造的に起きえなくなる**。
- **マージコンフリクトの削減**：依存更新で `package.json` が書き換わらないので、複数人の依存更新がぶつからない。

> **私の実運用**：別案件のリアルタイムスポーツ採点アプリでは、`zod`・`react` などの主要依存を **pnpm catalog で一元ピン留め**し、サイレントなドリフトを根絶しました。catalog は「気をつければ防げる」を「気をつけなくても防げる」に変える——**規律を人間の注意力から、ツールの構造へ移す**典型です。モノレポでまずやるべきはこれです。

---

## 3. Turborepo：タスクパイプラインとキャッシュ

ワークスペースが整い依存が揃ったら、次は**ビルド/テスト/Lint を速く・正しい順序で**回す番です。ここで Turborepo を使います。

### 3.1 Turborepo は何者か

公式は Turborepo を「**JavaScript / TypeScript コードベース向けの高性能ビルドシステム。モノレポをスケールさせるために設計されている**」と定義します。モノレポが抱える問題——「各ワークスペースが独自のテスト・Lint・ビルドを持ち、結果として数千のタスクになる」——に対し、Turborepo は**タスクを並列化し、実行を最適化**します。公式の言葉を借りれば「**Turborepo はタスクを最大速度でスケジュールし、利用可能な全コアに作業を並列化する**」。

そして核心がキャッシュです。「**同じ作業を二度しない（never do the same work twice）**」。

### 3.2 `turbo.json` の `tasks`：依存順序を宣言する

設定ファイルは `turbo.json` です。**現行ドキュメントのタスク設定キーは `tasks`** です（注：以前のバージョンでは `pipeline` という名前でした。古い記事や旧 turbo.json を見たら読み替えてください。版差はここで踏み抜きやすい）。

公式の例に忠実な、実用的な `turbo.json` を示します。

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

> **注記（要確認）**：`$schema` の値 `https://turbo.build/schema.json` は広く使われている定番値ですが、ドメイン移行（turborepo.com → turborepo.dev）の経緯もあり、スキーマURLは版で変わり得ます。生成された `turbo.json` の値を正としてください。

各キーの意味を、公式の定義どおりに押さえます。

- **`dependsOn`**：そのタスクの前に完了していなければならないタスク。
- **`^build`** の **`^`（キャレット）**：公式いわく「**`^` は、自分の直接の依存パッケージで先にそのタスクを実行せよ、という指示**」。`"dependsOn": ["^build"]` は「**自分をビルドする前に、依存している全パッケージを先にビルドせよ**」。`@repo/domain` をビルドしてから `@app/learner` をビルドする——この**トポロジカル順序**を、依存グラフから自動で導きます。
- **`^` なしの `"dependsOn": ["build"]`**：同一パッケージ内の別タスク依存。`test` は同じパッケージの `build` の後、という意味。
- **`pkg#task` 形式**（例：`"dependsOn": ["utils#build"]`）：特定パッケージの特定タスクを名指し。

### 3.3 `outputs` と `inputs`：キャッシュの境界を定義する

キャッシュの正しさは、この2つで決まります。

- **`outputs`**：タスクが生成し、キャッシュすべきファイル/ディレクトリ。公式は明確に警告します——「**タスクのファイル出力を宣言しなければ、Turborepo はそれをキャッシュしない**」。Next.js なら `.next/**` から `!.next/cache/**` を除外する、というのが定番です。
- **`inputs`**：タスクのハッシュに含めるファイル。これが変わると「キャッシュミス＝再実行」になります。例えば spell-check を Markdown 変更時だけ再実行したいなら：

```json
{
  "tasks": {
    "spell-check": {
      "inputs": ["**/*.md", "**/*.mdx"]
    }
  }
}
```

デフォルト入力を保ちつつ一部を除外したいときは `$TURBO_DEFAULT$` を併用します。

```json
{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", "!README.md"]
    }
  }
}
```

`README.md` を変えてもビルドキャッシュを無効化しない——「**実質的にビルド結果に影響しない変更でキャッシュを捨てない**」というコスト最適化です。

### 3.4 副作用タスクは `cache: false`

デプロイのような**副作用を持つタスク**は、キャッシュしてはいけません（キャッシュヒットで「デプロイしたつもりが何も起きない」事故になる）。公式どおり `cache: false` を付けます。`dev` のような常駐タスクは `persistent: true`。

```json
{
  "tasks": {
    "deploy": { "cache": false },
    "dev": { "cache": false, "persistent": true }
  }
}
```

---

## 4. キャッシュの仕組みと、CIを劇的に速くする

### 4.1 ローカルキャッシュ：二度同じ仕事をしない

公式によれば、Turborepo は**初回実行時にタスク入力からフィンガープリント（ハッシュ）を作り**、同じ入力での再実行時には `.turbo/cache` から結果を復元します。ターミナル出力（ログ）も常に捕捉され、キャッシュから再生されます。

ハッシュは2層で構成されます（公式の分類）。

- **グローバルハッシュ**：ルート/パッケージの `turbo.json`、ルート `package.json` とロックファイルの変化、`globalDependencies` のファイル、`globalEnv` の変数、`--cache-dir` のような挙動を変えるフラグ。
- **パッケージハッシュ**：そのパッケージの `turbo.json` 変更、`package.json` の依存、`inputs` で設定したソース管理下のファイル。

ここで第2章の catalog が効いてきます。**ロックファイルの変化がグローバルハッシュに含まれる**ため、catalog で依存を集約しておくと「無関係な依存ドリフトでキャッシュが無効化される」ノイズが減り、**キャッシュヒット率が上がる**。catalog は型安全だけでなく、キャッシュ効率にも効くわけです。

### 4.2 リモートキャッシュ：CIとチームでキャッシュを共有する

ローカルキャッシュは「自分のマシンで二度同じ仕事をしない」止まりです。本領は**リモートキャッシュ**です。公式いわく「**Remote Cache は全タスクの結果を保存し、CIが二度同じ仕事をする必要をなくす**」。有効化は2コマンド。

```bash
npx turbo login
npx turbo link
```

これで、タスク結果が自動的にリモートキャッシュにアップロードされ、認証済みの別マシン（＝CIや他メンバー）が**即座にキャッシュヒット**できます。公式の言葉では「**チーム全体とCIでキャッシュを共有する**」。

効果は具体的です。あるPRが `packages/ui` しか触っていなければ、`@repo/domain` のビルド・テストは**前回の結果をそのまま再利用**し、CIは差分だけを計算します。**触っていないものを再ビルドしない**——これがモノレポのCIを「全部やり直し」から「差分だけ」に変える正体で、**コスト効率（CI時間＝課金時間）**に直結します。

> **設計の含意**：CIを速くしたいなら、まず `outputs` を正しく宣言することです。`outputs` 未宣言のタスクはキャッシュされず、リモートキャッシュの恩恵をまるごと取りこぼします。「CIが遅い」の相当数が、実は `outputs` の書き忘れです。

---

## 5. 共有ドメインパッケージ：Zod を単一真実源にする

ここがモノレポ設計の**心臓部**です。catalog でバージョンを揃え、Turborepo で速くしても、肝心の**型がアプリごとにバラバラに定義されていたら**、モノレポにした意味の大半は失われます。

### 5.1 なぜ「型定義」ではなく「Zod スキーマ」なのか

選択肢は2つあります。

| アプローチ | 内容 | 問題 |
| --- | --- | --- |
| ① TypeScript の `type`/`interface` だけ共有 | コンパイル時の型のみ | **実行時に検証できない**。API応答・DB行・フォーム入力が型どおりか保証されない |
| ② **Zod スキーマを共有**し、型はそこから導出 | スキーマ1つから「実行時バリデータ」と「静的型」の両方を得る | （これが本命） |

**境界（API・DB・Edge Function・ユーザー入力）はすべて「外部からの未検証データ」**です。ここを `as Course` でキャストして通すのは、型システムに嘘をつく行為で、本番で必ず破綻します。Zod なら、**1つのスキーマから実行時バリデーションと静的型を同時に得て**、境界で `parse` して初めて型を信じる、という規律が作れます。これが私の案件の鉄則「**境界は Zod 検証**」です。

### 5.2 `packages/domain`：実装

```ts
// packages/domain/src/subscription.ts
import { z } from "zod";

/**
 * サブスクリプションの状態。
 * ここが全スタックの単一真実源。値を増やすと、消費側の網羅スイッチが
 * すべてコンパイルエラーになって「ここも直せ」と教えてくれる（後述の NeverError）。
 */
export const SubscriptionStatus = z.enum([
  "trialing",
  "active",
  "past_due",
  "canceled",
]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;

export const Subscription = z.object({
  id: z.string().uuid(),
  userId: z.string().uuid(),
  status: SubscriptionStatus,
  currentPeriodEnd: z.coerce.date(),
});
// スキーマから静的型を「導出」する。型を別途手書きしない（DRY）。
export type Subscription = z.infer<typeof Subscription>;
```

```json
// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "zod": "catalog:" }
}
```

ポイントは **`z.infer` で型を導出する**こと。スキーマと型を別々に手書きすると、片方を直してもう片方を直し忘れる——まさに避けたいドリフトが、パッケージ内部で再発します。**スキーマ1つを真実源にし、型はそこから機械的に導く**（DRY＝単一真実源）。

### 5.3 各アプリから使う：境界で `parse`、内部は型を信じる

```ts
// apps/learner/src/api/subscription.ts
import { Subscription, type Subscription as TSubscription } from "@repo/domain";

/**
 * 外部（API/DB）からの未検証データは、必ず境界で parse する。
 * parse を通った後の値だけが TSubscription として信頼できる。
 * ここで as は使わない——型システムへの嘘は本番で破綻する。
 */
export async function fetchSubscription(userId: string): Promise<TSubscription> {
  const res = await fetch(`/api/subscriptions/${userId}`);
  const json: unknown = await res.json();      // 入口は unknown
  return Subscription.parse(json);             // 境界で検証 → ここから型を信じる
}
```

`@repo/domain` を `workspace:*` で参照しているので、**domain のスキーマを変えた瞬間に、受講者アプリ・管理画面・Edge Function の全消費箇所が同じ型変更を受ける**。これがモノレポの本懐です。

### 5.4 網羅性を `NeverError` で機械強制する

`SubscriptionStatus` に `paused` を足したとき、状態を分岐する全スイッチが「`paused` を処理し忘れている」とコンパイルエラーになってほしい。これを成立させるのが、私の案件の規律「**`enum` 禁止・non-null 禁止・`as`/`any` 禁止 ＋ `NeverError`**」です。

```ts
// packages/domain/src/never-error.ts
/** 網羅し損ねた case を「コンパイル時に」検出する番人。 */
export class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}
```

```ts
// 消費側：status の分岐
import { NeverError } from "@repo/domain";

function label(status: SubscriptionStatus): string {
  switch (status) {
    case "trialing":  return "トライアル中";
    case "active":    return "有効";
    case "past_due":  return "支払い遅延";
    case "canceled":  return "解約済み";
    // paused を domain に足すと、ここで `never` ではなくなり型エラー → 直し漏れゼロ
    default:          throw new NeverError(status);
  }
}
```

> **なぜ TS の `enum` を禁じるか**：TS の `enum` は実行時に余計なオブジェクトを生成し、`const enum` は分離コンパイルやバンドラと相性が悪く、数値 enum は型安全性に穴があります。**Zod の `z.enum([...])` から `z.infer` した文字列リテラルユニオン**なら、実行時検証（Zod）と静的網羅（`never`）の両方が手に入り、余計なランタイムも増えません。「言語機能だから使う」ではなく「単一真実源と網羅性に資するか」で選ぶ——これが型安全規律の核です。

---

## 6. CIで規律を機械強制する：型カバレッジとスキーマdrift検出

ここまでの設計は、**人間が守らなければ意味がありません**。そして人間は必ず守り忘れます。だから**CIで機械強制**します。「レビューで気をつける」は規律ではありません。

### 6.1 tsconfig：strict をさらに締める

共有 tsconfig（`packages/config-ts`）で、`strict` の上にさらに2つの締め金具を入れます。これが私の標準です。

```json
// packages/config-ts/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,    // arr[i] を T ではなく T | undefined に
    "exactOptionalPropertyStrict": true,  // ※キー名は版確認。下の注を参照
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true
  }
}
```

> **注（要確認）**：「省略可能プロパティに `undefined` を明示代入させない」オプションの正式キーは **`exactOptionalPropertyTypes`** です（上記の `exactOptionalPropertyStrict` は誤記しやすい例として置いています——必ず `exactOptionalPropertyTypes` を使ってください）。`noUncheckedIndexedAccess` と合わせ、**配列アクセスとオプショナルの「ありそうで無い」を型に出す**のが狙いです。

- **`noUncheckedIndexedAccess`**：`array[0]` を `T | undefined` 扱いにする。「インデックスアクセスは外れ得る」という現実を型に反映し、`!`（non-null）で握りつぶす癖を断つ。
- **`exactOptionalPropertyTypes`**：`{ name?: string }` に `undefined` を**明示代入できなくする**。「未指定」と「`undefined` を指定」を区別し、境界の曖昧さを消す。

### 6.2 型カバレッジをCIゲートにする

`strict` でも `any` は染み込みます（外部ライブラリの戻り、`JSON.parse`、雑なキャスト）。これを定量化するのが**型カバレッジ**——「コードベースのうち型が付いている割合」です。`type-coverage` などのツールで測り、**閾値を下回ったらCIを落とす**。

```jsonc
// 各パッケージの package.json（domain は最も厳しく）
{
  "scripts": {
    "type-coverage": "type-coverage --detail --strict --at-least 99.5"
  }
}
```

> **私の実運用**：別案件では **TypeScript strict ＋ 型カバレッジをCI強制**し、`noUncheckedIndexedAccess`・`exactOptionalPropertyTypes` を有効化、**パッケージ単位で型カバレッジ閾値を 96.7%〜100% に設定**しました。`domain` のような真実源パッケージは 100%、UIの末端は少し緩める——**パッケージごとに「型の厳しさ」を変えられる**のがモノレポ＋共有設定の利点です。閾値はコミットされた数字なので、誰かが `any` を1つ足すと**CIが赤くなって可視化**されます。

### 6.3 スキーマdrift検出：「マージ済みなのに未デプロイ」を捕まえる

最後に、見落とされがちな規律です。**コードはマージされたが、対応するスキーマ（DB型・APIスキーマ）がデプロイ環境に反映されていない**——この乖離（drift）が、本番だけで起きるバグの温床です。

私は **GitHub Actions でスキーマdriftを検出**する仕組みを組みます。考え方はシンプルで、**「真実源（domain / マイグレーション）から生成されるはずの型/スキーマ」を再生成し、コミット済みのものと diff を取る**。差分が出たら「生成物が古い＝drift」としてCIを落とします。

```yaml
# .github/workflows/schema-drift.yml（要点のみ）
name: schema-drift
on: [pull_request]
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      # domain スキーマ / DB 型を「あるべき姿」に再生成
      - run: pnpm --filter @repo/domain generate
      # 生成結果がコミット済みと一致するか。ズレていれば drift → 失敗
      - run: git diff --exit-code -- packages/domain/generated
```

> **私の実運用**：別案件では **11本の GitHub Actions** で、型チェック・Lint・型カバレッジ・**スキーマdrift検出**などを自動化しました。狙いは「**マージ済みなのに未デプロイ**」のような、**人間の目では追いきれない状態の食い違いを機械が検知する**こと。第6章全体に共通するのは——**規律を人間の善意ではなく、CIの赤信号で担保する**という思想です。catalog（依存ドリフト）も、型カバレッジ（型ドリフト）も、schema-drift（スキーマドリフト）も、すべて「ドリフトを機械が殺す」という1つの原則の現れです。

### 6.4 依存境界：循環依存を防ぐ

モノレポが腐る最大の原因が**循環依存**です。`apps/learner → packages/ui → apps/learner` のような輪ができると、ビルド順序が決まらず、Turborepo のトポロジカルキャッシュも壊れます。

規律はシンプルです——**依存は一方向に流す**。第1章の `apps/` と `packages/` の分離がここで効きます。

- `apps/*` は `packages/*` に依存してよい。**逆は禁止**（共有部品がアプリを知ってはいけない）。
- `packages/*` 同士の依存も**階層を保つ**：`ui → domain` は可、`domain → ui` は不可。**`domain` は何にも依存しない最下層**（Zod 以外）。

ESLint の `import/no-cycle` ルールや、依存方向を宣言的に縛るツールでCIに組み込み、**循環が生まれた瞬間にPRを落とす**。「あとで直す」では絶対に直らないので、**入口で止める**のが唯一の解です。

---

## 7. モノレポ vs ポリレポ：いつモノレポが勝つか

ここまで読むと「モノレポ最高」に見えますが、**常に正解ではありません**。判断軸で選びます。

| 判断軸 | モノレポが有利 | ポリレポが有利 |
| --- | --- | --- |
| **共有ドメイン** | 複数アプリが**同じ型/語彙を密に共有**する（今回のような受講者＋管理画面） | 各サービスのドメインが**ほぼ独立**している |
| **横断変更** | 1つの変更を**全アプリに一括で型チェック**したい | 変更が1サービスに閉じることが多い |
| **チーム構成** | 少人数〜中規模で、**全体を見渡せる** | 多数の独立チームが**別々のリリースサイクル**で動く |
| **リリース** | **同期リリース**（一緒に出したい）が多い | サービスごとに**独立デプロイ**したい |
| **技術スタック** | TS中心で**ツールチェーンを統一**できる | 言語/ランタイムが**バラバラ**（Go・Rust・Node混在） |
| **CI** | Turborepo の**差分キャッシュで高速化**できる | モノレポCIの複雑さを**避けたい** |

**今回のサブスク学習プラットフォームがモノレポに向いた理由**は明快です。受講者アプリと管理画面が**同じ `Course`・`Subscription`・`User` を共有**し、ドメインを変えたら両方を一括で型チェックしたかった。スタックは TS で統一され、少人数で全体を見渡せた。**「共有ドメイン × 横断型チェック × 統一スタック」が3つ揃ったら、モノレポが強い**。逆にこれが揃わないなら、ポリレポの単純さを捨てる理由はありません。

### 何を共有パッケージに切り出すか

モノレポにしても、**何でもかんでもパッケージに割るのは間違い**です（過剰分割はビルドグラフを無駄に複雑化する＝YAGNI）。基準はこうです。

| 切り出す | 切り出さない |
| --- | --- |
| **2つ以上のアプリが実際に共有**しているドメイン型（`domain`） | 1アプリしか使わないロジック（そのアプリ内に置く） |
| 横断する設定（`config-eslint`・`config-ts`） | 「いつか共有するかも」な投機的部品 |
| 共有UIで**3回以上**繰り返し現れた部品 | 2回しか出ていない部品（偶然の一致かもしれない） |

DRYの原則どおり——**「2回は偶然、3回でパターン」**。1〜2回の重複で慌ててパッケージ化すると、誤った抽象に縛られます。**実際の共有が観測されてから切り出す**（ETC＝変更しやすさを保つため、抽象は遅らせる）。`domain` だけは例外で、最初から切り出します。それが**モノレポの存在理由そのもの**だからです。

---

## 8. まとめ：型安全モノレポ・チートシート

迷ったときの早見表です。

- **土台**：`pnpm-workspace.yaml` で `apps/*` と `packages/*` を分離。社内参照は `workspace:*`、共有部品は `private: true`。
- **ドリフト撲滅**：共有外部依存（`zod`・`react`・`typescript`）は **catalog** に集約し、各 `package.json` は `catalog:` 参照。バージョン更新は**1行**。
- **速度**：`turbo.json` の **`tasks`**（旧 `pipeline`）で `dependsOn: ["^build"]` のトポロジカル順序を宣言。`outputs` を**必ず**書く（書かないとキャッシュされない）。副作用タスクは `cache: false`。
- **CI高速化**：`turbo login` + `turbo link` でリモートキャッシュ。**触っていないものは再ビルドしない**＝CI課金時間が減る。
- **単一真実源**：`packages/domain` に **Zod スキーマ**を集約し、型は `z.infer` で導出。境界は必ず `parse`、内部で `as` を使わない。網羅は `NeverError` で機械強制。
- **規律の機械強制**：`strict` ＋ `noUncheckedIndexedAccess` ＋ `exactOptionalPropertyTypes`。**型カバレッジ閾値**をCIゲートに。**スキーマdrift検出**で「マージ済み未デプロイ」を捕まえる。**循環依存**は入口で止める。
- **やめどき**：共有ドメインが薄く、チームが独立リリースしたいなら、無理にモノレポにしない。

モノレポは「フォルダをまとめる技術」ではなく、**「複数アプリが同じドメイン語彙を共有し、変更を一括で型チェックし、規律をCIで機械強制する」設計**です。catalog でドリフトを消し、Turborepo で速くし、Zod で型を単一真実源にし、CIで守る——この4本柱が揃って初めて、モノレポは規模に耐えて育ちます。

私は実際に、**受講者アプリ・運営管理・共有14パッケージ**のモノレポを、**`as`/`any`/`enum`/non-null を禁じた型安全規律**と、**型カバレッジ・スキーマdrift検出を含む複数のCIゲート**で運用してきました。生成AI（Claude Code）を実装の加速に使いつつ、**型とCIという機械的な検証ゲートで品質を担保する**——これが、一人でも複数サーフェスのモノレポを安全に回す私のやり方です。

**「アプリが増えてきて型がバラけ始めた」「モノレポ化したいが地獄にしたくない」——その設計から、catalog・Turborepo・共有ドメインパッケージ・CIゲートの構築まで、一気通貫で伴走できます。** 要件の整理段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [pnpm Workspaces](https://pnpm.io/workspaces) — `pnpm-workspace.yaml`・`workspace:*` プロトコルと公開時の変換
- [pnpm Catalogs](https://pnpm.io/catalogs) — `catalog:` / `catalogs:` による依存バージョンの単一真実源化
- [pnpm pnpm-workspace.yaml](https://pnpm.io/pnpm-workspace_yaml) — `packages` グロブと catalog の記法
- [Turborepo Docs（Overview）](https://turborepo.dev/docs) — 高性能ビルドシステムの全体像・並列化・キャッシュ
- [Turborepo Caching](https://turborepo.dev/docs/core-concepts/caching) — ローカル/リモートキャッシュ・ハッシュ・`turbo login` / `turbo link`
- [Turborepo Configuring Tasks](https://turborepo.dev/docs/crafting-your-repository/configuring-tasks) — `tasks`・`dependsOn`・`^build`・`outputs`・`inputs`・`cache`・`persistent`
