はじめに
仕事でモノレポ構成のプロジェクトに参加することになった。
pnpm workspaces や Turborepo という言葉は聞いたことがあったが、なぜこの構成になっているのか、どんな仕組みで動いているのかを理解できていなかった。
この記事では、モノレポの概念から、管理ツールの仕組み、型の伝わり方まで調べたことをまとめる。
モノレポとは
複数のアプリ・パッケージを 1つのリポジトリ でまとめて管理する手法。
それぞれ独立したリポジトリで管理する「マルチレポ」の対義語。
メリット:
- 型やコードを複数アプリで共有できる
- 依存関係のバージョンを一元管理できる
- 変更の影響範囲がリポジトリをまたがない
ディレクトリ構成
典型的なモノレポのディレクトリ構成は以下のような形になる。
my-monorepo/
├── apps/ # 実際に動くアプリケーション
│ ├── web/ # フロントエンド(例: Next.js)
│ ├── admin/ # 管理画面(例: Next.js)
│ └── api/ # API サーバー(例: Hono)
│
└── packages/ # アプリ間で共有するパッケージ
├── ui/ # 共有 UI コンポーネント
├── lib/ # 共有ビジネスロジック
└── utils/ # 汎用ユーティリティ
apps/ は「実際に動くもの」、packages/ は「共有するもの」という役割分担になっている。
pnpm workspaces
workspace:* とは
モノレポ内の共有パッケージ(packages/ui など)を複数のアプリ(apps/client・apps/admin など)から使いたい場合、通常以下の手順がアプリごとに必要になる。
① パッケージを npm に publish(アップロード)
② 使う側で npm install(ダウンロード)
③ コードを変更するたびに ① → ② を繰り返す
pnpm workspaces を使うと、この手順を丸ごとスキップできる。
# pnpm-workspace.yaml
packages:
- apps/*
- packages/*
この設定を書くことで、apps/ と packages/ 配下のディレクトリがすべて「ワークスペース(workspace)」として認識される。
ワークスペースとして認識されたパッケージは、workspace:* で相互参照できるようになる。
// apps/web/package.json
{
"dependencies": {
"@myapp/ui": "workspace:*",
"@myapp/lib": "workspace:*"
}
}
workspace:* の意味:
-
workspace:→ npm レジストリではなく、このリポジトリ内のパッケージを参照する -
*→ 同一ワークスペース内の同名パッケージを参照する(npm publish 時は通常のバージョン範囲に自動置換される)
workspace: がない場合は npmjs.com を探しに行くが、workspace: を付けるとローカルのパッケージを優先して参照する。
| 指定方法 | 参照先 |
|---|---|
"^1.2.0" |
npmjs.com からダウンロード(外部パッケージ) |
"workspace:*" |
リポジトリ内のパッケージを直接参照(publish 不要) |
コードを変更しても publish → install のサイクルが不要なため、変更をすぐ確認できる。
"dependencies" の @myapp/ui の @スコープ名について
@myapp/ui の @myapp/ の部分をスコープと呼ぶ。
npm に公開されている外部パッケージと、このリポジトリ内で自分たちが作ったパッケージを区別するための名前のプレフィックス。
// スコープなしだと紛らわしい
import { Button } from "ui" ← npm のパッケージ?自分たちのもの?
// スコープがあると一目瞭然
import { Button } from "@myapp/ui" ← 自分たちのパッケージとわかる
react ← スコープなし(npm からダウンロードした外部ライブラリ)
@myapp/ui ← @myapp スコープ(このリポジトリ内にあるパッケージ)
@myapp/lib ← @myapp スコープ(このリポジトリ内にあるパッケージ)
@myapp の部分は何でもよく、プロジェクトごとに自由に決める。
@repo、@acme、@company など、チームやプロジェクトの名前をつけるのが一般的。
各パッケージの package.json に名前を定義する。
// packages/ui/package.json
{ "name": "@myapp/ui" }
これで import { Button } from "@myapp/ui" のように呼び出せるようになる。
なぜ import できるのか
pnpm install を実行すると、pnpm が workspace:* の設定を読んで node_modules/ にシンボリックリンク(ショートカット)を自動で作る。
node_modules/
@myapp/
ui/ → ../../packages/ui/ ← シンボリックリンク(実体は packages/ui/)
lib/ → ../../packages/lib/ ← シンボリックリンク(実体は packages/lib/)
react/ ← npmjs.com からダウンロードした実体
import { Button } from "@myapp/ui" と書いたとき、Node.js は node_modules/@myapp/ui/ を探し、シンボリックリンクをたどって packages/ui/ のコードを読み込む。
外部パッケージ(react など)と見た目は同じだが、実体はリポジトリ内にある。
git clone 後に pnpm install を実行することで、このリンクが作られる。
packages/ui/ の中身はどこから来たのか
react のような通常の npm パッケージは pnpm install でダウンロードされるが、
packages/ui/ の中身(button.tsx など)はダウンロードではなくリポジトリにあるソースファイル。
これは shadcn/ui の仕組みによるもの。
shadcn/ui は「コンポーネントのソースコードをプロジェクトにコピーする」という方式をとっている。
# shadcn の初期セットアップ時に一括追加
npx shadcn init
npx shadcn add --all
→ packages/ui/src/components/ に全コンポーネントが一括生成される
→ git 管理対象になる(自分たちのコード扱い)
| パッケージ | 中身の場所 | git 管理 | 更新方法 |
|---|---|---|---|
| react | node_modules/react/ | 管理外 | pnpm install |
| @myapp/ui | packages/ui/src/ | 管理対象 | shadcn コマンドで上書き or 直接編集 |
packages/ui/ のコンポーネントは自分たちで自由に編集できる。
これが shadcn/ui の特徴で、「デザインを自分たちでカスタマイズできる」利点になっている。
Turborepo
なぜ必要か
複数パッケージを持つモノレポでは、以下の問題が生じる。
-
@myapp/libを変更したとき、api→webの順にビルドしないと型が古いまま - 変更していないパッケージまで毎回ビルドし直すと時間がかかる
Turborepo はこれらを解決するタスクオーケストレーター(複数のタスクの実行順序・並列化・キャッシュを自動で管理するツール)。
| 機能 | 内容 |
|---|---|
| 依存順序の制御 |
lib → api → web/admin の順で自動実行 |
| 並列実行 | 依存関係のないパッケージのタスクは同時実行 |
| キャッシュ | 入力ファイルが変わっていなければ再実行せずキャッシュ使用 |
キャッシュの仕組み
ハッシュ とは「ファイルの内容を要約した指紋」のようなもの。ファイルが 1 バイトでも変わると値が変わる。
Turborepo はタスク実行のたびに「キャッシュキー」を記録する。
キャッシュキーは入力ファイルだけでなく、タスク定義・環境変数・ロックファイル等も含めて算出される。
次回実行時に「前回と同じキャッシュキー = 何も変わっていない」なら、再実行せずに前回の結果を使う。
前回の実行
@myapp/utils のファイル群 → ハッシュ: abc123
→ typecheck を実行 → 結果をキャッシュに保存
今回の実行(utils を変更していない場合)
@myapp/utils のファイル群 → ハッシュ: abc123(同じ)
→ cache hit → 前回の結果を再利用(スキップ)
今回の実行(utils を変更した場合)
@myapp/utils のファイル群 → ハッシュ: xyz999(変わった)
→ cache miss → typecheck を実行
キャッシュの連鎖
あるパッケージを変更すると、それに依存するすべてのパッケージのキャッシュが無効化される。
以下は @myapp/lib を変更した場合の例。
@myapp/lib を変更
→ lib:typecheck: cache miss(lib が変わった)
→ api:typecheck: cache miss(lib の依存物が変わった)
→ web:typecheck: cache miss(api の依存物が変わった)
→ admin:typecheck: cache miss(api の依存物が変わった)
変更していないつもりのパッケージが再実行される場合、依存チェーンの上流が変わっている可能性がある。
型依存チェーン(Hono RPC の場合)
Hono + Prisma を使ったプロジェクトでは、型が以下の順に伝わる。
Prisma schema(schema.prisma)
↓ prisma generate ※1
@prisma/client(型定義)
↓ import
apps/api/src/(Hono ルート定義・レスポンス型)
↓ pnpm build
apps/api/dist/hc.d.ts(RPC 型定義)※2
↓ import "@myapp/api/hc"
apps/web/、apps/admin/
※1 prisma generate とは:schema.prisma に書いたテーブル定義をもとに、TypeScript の型定義を自動生成するコマンド。
実行すると @prisma/client パッケージに型が書き出され、サーバーのコードから型安全に DB を操作できるようになる。
※2 dist/ と .d.ts とは:dist/ はビルド(コンパイル)の成果物が出力されるディレクトリ。
.d.ts は TypeScript の型定義ファイルで、実行コードは含まず「この関数はこの型を返す」という情報だけが書かれている。
pnpm build を実行すると apps/api/src/ のコードがコンパイルされ、dist/hc.d.ts に型情報が書き出される。
フロントエンドはこのファイルを参照することでサーバーの型を知ることができる。
RPC とは
別のサーバーにある関数を、まるでローカルの関数を呼ぶように実行する仕組み。
デプロイ環境では apps/web(Vercel)と apps/api(Cloud Run)は物理的に別のサーバーになる。
フロントエンドから API を呼ぶとき、実態はネットワーク越しの HTTP リクエストだが、Hono RPC を使うとローカルの関数を呼ぶような書き方ができる。
ブラウザ(Vercel 上の Next.js)
↓ HTTP リクエスト(ネットワーク越し)
Cloud Run(Hono API サーバー)
Hono RPC を使うとどう変わるか
// 普通の fetch — res.json() は any 型になりやすく、手で型定義が必要
const res = await fetch('/api/users/1')
const user = await res.json() as User // any のため手動でキャストが必要
// Hono RPC — サーバーの戻り値の型がそのまま使える
const res = await client.api.users[':id'].$get(...)
const user = await res.json() // { id: number, name: string } と型がわかる
| 比較 | fetch | Hono RPC |
|---|---|---|
| 戻り値の型 | any(手動キャストが必要) | サーバーの型がそのまま来る |
| URL の指定 | 文字列を手書き | オブジェクトをたどる |
| 存在しないルートへのアクセス | 実行時エラー | コンパイルエラー |
よくある罠:dist/ が古い問題
フロントエンドで型エラーが出たとき、「コードが壊れた」と勘違いしがちだが、API サーバーの dist/ が古いだけのことが多い。
API のルートを変更
→ dist/ を再生成しないと...
→ web / admin 側は「古い型」を見る
→ 型エラーや実行時のズレが発生
まず dist/ の更新日時を確認し、古ければビルドを再実行する。
| 変更内容 | 必要な操作 |
|---|---|
| schema.prisma を変更 |
prisma generate → API サーバービルド |
| Hono のルート・レスポンス型を変更 | API サーバービルド |
| フロントエンドのみ変更 | ビルド不要 |
まとめ
| 技術 | 役割 |
|---|---|
| pnpm workspaces | モノレポ内のパッケージを publish なしで相互参照 |
| @スコープ | リポジトリ専用パッケージの名前空間 |
| Turborepo | 依存順序制御・並列実行・キャッシュ |
| Hono RPC | サーバーで定義した型を、クライアントで手書きせずそのまま使える |
モノレポは「コードを共有するための仕組み」だが、その恩恵を受けるには型の流れを理解して正しい順番でビルドする必要がある。
最初は複雑に見えるが、各ツールの役割を把握すると「なぜこうなっているか」が納得できる。