「アプリが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つです。本記事はこの順で進みます。
- 依存のドリフトを消す → pnpm catalog(共有依存のバージョンを1か所にピン留め)
- ビルド/テストをキャッシュで速くする → Turborepo(タスクパイプライン+キャッシュ)
- 共有型を単一真実源にする → domain package(Zod スキーマを1か所に集約)
- 規律を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.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か所に集約する仕組みです。公式の形式はこうです。
# 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: プロトコルは 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 を示します。
{
"$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 変更時だけ再実行したいなら:
{
"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/domain を workspace:* で参照しているので、domain のスキーマを変えた瞬間に、受講者アプリ・管理画面・Edge Function の全消費箇所が同じ型変更を受ける。これがモノレポの本懐です。
5.4 網羅性を NeverError で機械強制する
SubscriptionStatus に paused を足したとき、状態を分岐する全スイッチが「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と合わせ、配列アクセスとオプショナルの「ありそうで無い」を型に出すのが狙いです。
noUncheckedIndexedAccess:array[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強制し、
noUncheckedIndexedAccess・exactOptionalPropertyTypesを有効化、パッケージ単位で型カバレッジ閾値を 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混在) |
| 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 —
pnpm-workspace.yaml・workspace:*プロトコルと公開時の変換 - pnpm Catalogs —
catalog:/catalogs:による依存バージョンの単一真実源化 - pnpm pnpm-workspace.yaml —
packagesグロブと catalog の記法 - Turborepo Docs(Overview) — 高性能ビルドシステムの全体像・並列化・キャッシュ
- Turborepo Caching — ローカル/リモートキャッシュ・ハッシュ・
turbo login/turbo link - Turborepo Configuring Tasks —
tasks・dependsOn・^build・outputs・inputs・cache・persistent