مرکزی مواد پر جائیں
友田 陽大
Type safety & validation
モノレポ
TypeScript
アーキテクチャ設計
CI/CD
型安全

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

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

Published
Reading time
25 min read
Author
友田 陽大
شیئر کریں
فہرست

「アプリが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ゲートの構築まで、一気通貫で伴走できます。 要件の整理段階からでも、お気軽にご相談ください。


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

友田

友田 陽大

وزیر (METI) ایوارڈ یافتہ پروڈکٹ کے ڈویلپر۔ TypeScript + Python + AWS کے ساتھ میں SaaS، صنعتی ڈیجیٹل تبدیلی اور پروڈکشن کے قابل جنریٹو اے آئی (RAG) تنہا ابتدا سے انتہا تک فراہم کرتا ہوں — تقاضوں کی تعیین سے انفراسٹرکچر اور آپریشن تک۔

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

پروجیکٹ کی بنیاد پر کام اور تکنیکی مشاورت دونوں کے لیے دستیاب ہوں۔ 30 منٹ کی مفت مشاورت سے آغاز کریں۔

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

یہ بھی پڑھنے کے قابل