メインコンテンツへスキップ
友田 陽大
型安全・バリデーション
モノレポ
TypeScript
アーキテクチャ設計
CI/CD
型安全

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

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

公開日
読了時間
25分
著者
友田 陽大
シェア
目次

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

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

この記事は、pnpm workspaces + Turborepo + Zod 共有ドメインパッケージで「型安全に育つモノレポ」を設計する実装ガイドです。題材として、私が構築した金融リテラシー教育のサブスク学習プラットフォーム(受講者アプリ・運営管理・共有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 に**「どのディレクトリがパッケージか」**をグロブで宣言するところから始まります。公式の形式はこうです。

# 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 レジストリではなく、このワークスペース内のパッケージを使え」という明示です。

// 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.0workspace:~~1.5.0workspace:^^1.5.0 のように。つまり社内では workspace:* のまま開発し、外部公開時だけ実バージョンになる——内部の利便性と、公開後の semver 互換を両立できます。社内専用パッケージ(private: true)なら公開自体しないので、workspace:* で迷う必要はありません。

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


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

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

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

workspace:*社内パッケージのバージョンは揃います。しかし zodreacttypescript のような外部依存は、各 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か所に集約する仕組みです。公式の形式はこうです。

# 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: プロトコルで参照します。

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

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

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

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

2.3 catalog がもたらすもの

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

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

私の実運用:別案件のリアルタイムスポーツ採点アプリでは、zodreact などの主要依存を 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.jsontasks:依存順序を宣言する

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

公式の例に忠実な、実用的な turbo.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 outputsinputs:キャッシュの境界を定義する

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

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

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

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

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

3.4 副作用タスクは cache: false

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

{
  "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コマンド。

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:実装

// 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>;
// 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、内部は型を信じる

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

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

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

// packages/domain/src/never-error.ts
/** 網羅し損ねた case を「コンパイル時に」検出する番人。 */
export class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}
// 消費側: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つの締め金具を入れます。これが私の標準です。

// 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 と合わせ、配列アクセスとオプショナルの「ありそうで無い」を型に出すのが狙いです。

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

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

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

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

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

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

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

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

# .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混在)
CITurborepo の差分キャッシュで高速化できるモノレポCIの複雑さを避けたい

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

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

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

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

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


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

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

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

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

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

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


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

金融リテラシー教育のサブスク学習プラットフォーム(pnpm + Turborepo モノレポ・共有14パッケージ)

ケーススタディを見る