はじめに
最近 Node.js の開発プロジェクトに Monorepo/Turborepoを導入して開発・運用してみたところ、かなりソースコードが整理され、開発効率が上がりました。
開発を進めていく中で迷った点や、自分なりにベストプラクティスを模索したので共有させていただきます。
Monorepo(モノレポ)とは
Monorepo とは、複数のプロジェクトを 1 つのリポジトリで管理することです。
ある程度規模の大きい開発の場合、Node.js のパッケージを複数準備したくなるシーンが多々あります。
これまでは2つのアプローチをトライしたことがあります
完全分離
polyrepo/
├── client/ # クライアントアプリケーションのリポジトリ
│ ├── client-app/ # クライアントアプリケーションのルート
│ │ ├── package.json
│ │ ├── ...
│ │ └── src/
│ │ └── utils/ # client の utils
│ │ └── ...
│ └── ... # その他設定ファイルなど
├── server/ # サーバーアプリケーションのリポジトリ
│ ├── server-app/ # サーバーアプリケーションのルート
│ │ ├── package.json
│ │ ├── ...
│ │ └── src/
│ │ └── utils/ # server の utils
│ │ └── ...
│ └── ... # その他設定ファイルなど
└── batch/ # バッチアプリケーションのリポジトリ
├── batch-app/ # バッチアプリケーションのルート
│ ├── package.json
│ ├── ...
│ └── src/
│ └── utils/ # batch の utils
│ └── ...
└── ... # その他設定ファイルなど
無理やり統合
singlepackage/
├── package.json
├── packages/
│ ├── client-app/
│ │ ├── src/
│ │ │ └── utils/
│ │ │ └── ...
│ │ └── ...
│ ├── server-app/
│ │ ├── src/
│ │ │ └── utils/
│ │ │ └── ...
│ │ └── ...
│ └── batch-app/
│ ├── src/
│ │ └── utils/
│ │ └── ...
│ └── ...
├── ...
これらの方法のデメリットとしては、
- コードが共有できない(しづらい)
- フォルダの行き来する必要がある
- package.jsonにすべての依存パッケージを記述するので、個別のアプリ(client-app等)が依存しているパッケージを特定することができない
などがあります。
Monorepoは上記二つの中間的なソリューションになります。
Monorepo(workspace機能)
monorepo/
├── apps/
│ ├── client/
│ │ ├── src/
│ │ │ └── ...
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── server/
│ │ ├── src/
│ │ │ └── ...
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── batch/
│ ├── src/
│ │ └── ...
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ └── utils/
│ ├── src/
│ │ └── ... # 全アプリ共通のユーティリティを定義できる
│ ├── package.json
│ └── tsconfig.json
├── package.json
├── tsconfig.json
package.json
はアプリケーションと内部パッケージという単位で作成しますが、ルートディレクトリでnpm install
すればルートディレクトリのnode_modules
に全ての依存パッケージが格納されるので、作業ディレクトリを行き来する必要がないです。
アプリケーションは複数apps/
配下に作成できますし、アプリケーション間で共通利用したいものは内部パッケージとしてpackages/
配下に作成することができます。
※フォルダの構成はプロジェクトに合わせて自由に設定することが可能です
apps
の例
- client/server: WEBアプリのフロントとバック
- batch: バッチ処理
- admin/dashboard: 管理画面・ダッシュボード
- docs/storybook: ドキュメント
packages
の例
- utils: 標準的な共通関数
- services: DB 操作等のサービス
- shared: クライアントとサーバーで共有するコード
- ui: UI コンポーネント
- plugin: Vite のカスタムプラグイン
例えばfrontend
とadmin
のアプリケーションで共通で使えるような UI コンポーネントを作成できるなど、小さな単位でパッケージを作成し、それをアプリケーションで利用することができます。
これらのアプリ、内部パッケージそれぞれに対してpackage.json
が存在し、依存するパッケージを定義することができます。
Turborepo とは
workspace
機能は各種パッケージマネージャー(npm/yarn/pnpm)でサポートされていますが、それらをさらに拡張したライブラリがTurborepo
です。
Turborepoを使うことで以下が実現できます
-
client
/server
のnpm run dev
をワンコマンドで並列実行 - アプリケーションと内部パッケージの依存関係を定義し、必要に応じて順番にビルドする
- すべてのアプリ・内部パッケージの
test
/check-types
を一括実行 - ビルド内容のキャッシュし、変更がないパッケージについてはスキップ
- デプロイ先に応じて必要なパッケージのみを抽出(turbo prune)
実装例+解説
ここからは以下に公開した実装例を参考に解説、紹介していきます。
https://github.com/yoshida-valuesccg/turborepo-sample
本記事で使用した環境は以下の通りです:
エディタ: Visual Studio Code
npm: v10.9.0
Node.js: v22.12.0
TypeScript: v5.7.2
Turborepo: v2
フレームワーク: (solid-js / tRPC)※あんまり関係なし
デプロイ先: ECS / Lambda
フォルダ構成
まずフォルダ構成は以下の通りです。
基本にならって、apps/
と packages/
に分けて管理しています。
.
├── apps
│ ├── batch-hello-world
│ │ ├── package.json
│ │ ├── package.runtime.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── web-client
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ ├── src
│ │ │ ├── App.tsx
│ │ │ ├── index.tsx
│ │ │ └── trpc.ts
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── web-server
│ ├── package.json
│ ├── package.runtime.json
│ ├── scripts
│ │ └── dev.ts
│ ├── src
│ │ ├── index.ts
│ │ └── trpc.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── dockerfiles
│ ├── Dockerfile.hello-world
│ └── Dockerfile.web
├── package-lock.json
├── package.json
├── packages
│ ├── env
│ │ ├── env.d.ts
│ │ └── package.json
│ ├── env-plugin
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── runtime-package-plugin
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── services
│ │ ├── package.json
│ │ ├── src
│ │ │ └── user
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ └── user
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── typescript-config
│ │ ├── base.json
│ │ ├── package.json
│ │ └── solidjs.json
│ └── ui
│ ├── package.json
│ ├── src
│ │ └── Button.tsx
│ ├── tsconfig.json
│ └── vite.config.ts
├── scripts
│ ├── build-hello-world.sh
│ └── build-web.sh
└── turbo.json
ルート package.json
ルートの package.json の書き方のポイントは以下です。
- まず全体の名称を決める。ここでは
repo
としています -
workspaces
には、apps/と packages/を指定します- フォルダの構成を変更する場合には内部パッケージを配置するフォルダを指定します
-
dependencies
/devDependencies
は最小限に抑える-
turbo
はマスト
-
- 利用しているpackageManager を指定する
{
"name": "repo",
"version": "1.0.0",
"packageManager": "npm@10.9.2",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "turbo run dev",
"build": "turbo run build",
"check-types": "turbo run check-types"
},
"private": true,
"devDependencies": {
"@types/node": "^22.10.2",
"turbo": "^2.3.3",
"typescript": "^5.7.2"
},
"workspaces": ["apps/**/*", "packages/**/*"],
"volta": {
"node": "22.12.0"
}
}
ワークスペース内のpackage.json
各ワークスペースには package.json
が必要です。
ポイントは以下です。
-
name
は@repo/
で始まるようにしています -
private: true
にしています(内部パッケージなので) -
type: module
にすることで ES Modules を使えるようにしています -
exports
には、他のパッケージから参照されるファイルを指定しています- 後述しますが、これは
tsconfig
のmoduleResolution
がnodeNext
になっているためです。
- 後述しますが、これは
- 他のパッケージに依存する場合には必ず、
dependencies
/devDependencies
に追加します- これにより後述する
turbo.json
で依存関係を解決できます - バージョンの指定は"*"
- これにより後述する
{
"name": "@repo/web-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx ./scripts/dev.ts",
"build": "vite build",
"check-types": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
...
},
"devDependencies": {
"@repo/env-plugin": "*",
"@repo/runtime-package-plugin": "*",
"@repo/typescript-config": "*",
...
}
}
tsconfig.json
各ワークスペースに必ずtsconfig.json
を設定します。
ただ独立した設定を持たせると管理が煩雑になるので、packages/typescript-config/
というのを作成しています。
この中で base.json
と solidjs.json
を作成し、状況に応じたものを拡張して利用しています。
{
"extends": "@repo/typescript-config/base.json",
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
moduleResoution: Nodenext
またmoduleResolution
はnodeNext
に設定しています。
これにより以下の注意点が必要です。
- ローカルファイルの参照は必ず拡張子(.js/.jsx)をつける
import { Button } from "./Button.jsx";
- 外部から参照する場合は、
package.json
のexports
に明示的に出力するファイルをしていする
{
"exports": {
".": "./src/index.ts",
"./Button": "./src/Button.tsx"
}
}
これにより、以下のようにインポートが可能になります。
import { Button } from "@repo/ui/Button";
内部パッケージのコンパイル手段
内部パッケージはいずれも、アプリケーション(app/
)または他の内部パッケージ(packages/
)から参照されることを前提としています。
つまり最終的には、全てのパッケージはアプリケーションに組み込まれることとなります。
内部パッケージを作成する際は、必要に応じて2種類のコンパイル方式を選択できます。
JIT(Just-In-Time)パッケージ
内部パッケージの中でコンパイルを行わず、TypeScript
として扱われ、アプリケーションのビルド時にコンパイルを行う方式です。
この方式では、ビルドの必要がないのでpackage.json
にbuild
スクリプトを設定してはいけません。
私のサンプルだと、@repo/ui
や@repo/services
は JIT パッケージにあたります。
Compiled パッケージ
内部パッケージでコンパイルを行い、アプリケーションにはビルド済みの JavaScript +型定義ファイルを出力する方法です。
何かしらの事情で参照したいアプリケーションが外部パッケージをコンパイル出来ない場合などに使用します。
この方式を利用する場合は、
-
package.json
のmain
にdist/index.js
を指定する -
package.json
のscripts
にbuild
を設定する
私のサンプルだと、@repo/runtime-package-plugin
は Compiled パッケージにあたります。
このパッケージは、アプリケーションのvite.config.ts
で参照されているため、ビルド時にコンパイルされている必要があったのでこのようにしました。
他にも何かしらの都合で、内部パッケージの中でビルドしてしまった方が都合の良い場合に使用します。
後述しますが、turborepo
の設定により、アプリケーションのビルド時に依存する内部パッケージのビルドを事前に実行するようできるので、いずれの方式を選択肢てもさほど手間は変わりません。
アプリケーション(apps/)のビルド
(これは個人的にそうしているだけではありますが、)
アプリケーションのビルドはサーバーサイドであれ、必ずvite
を使用しています。
vite
の設定を工夫し、以下が出力されるようにしています。
- index.js
- トランスパイル済み
- 内部パッケージバンドル済み
- 外部パッケージは未バンドル(node_modules から参照できる状態)
- 環境変数解決済み(process.env.xxx 置換済み)
- package.runtime.json
- 実行時に必要なパッケージ(内部パッケージの依存も含む)のみを記載したpackage.json
こうすることで、どんな実行環境であれ上記2ファイルがあれば実行できるようにしています。
Tips の方に記述しましたが、上記を実現するための viteのプラグイン(runtime-package-plugin) を自作しています。
turbo.json
turborepo を利用することで、アプリケーションの開発環境立ち上げ(dev)やビルド(build)が簡単に行えます。
まず以下を実行することで、全ての内部パッケージのdev
スクリプトを実行できます。
turbo run dev
特定のアプリケーションのみを実行する場合には、以下のように実行します。
turbo run @repo/web-server#dev
turbo.json
を定義することで、各コマンドについて詳細に設定を定義することが可能です。
-
persistent
は、コマンドが終了した後もプロセスを維持するかどうかを指定します -
cache
は、コマンドの結果をキャッシュするかどうかを指定します -
dependsOn
は、他のコマンドが実行される前に実行されるコマンドを指定します -
outputs
は、build コマンド等の出力先の配列を指定します。(こちらの結果がキャッシュされます)
アプリケーションが内部の Compiled パッケージに依存している場合、dev
スクリプト実行前に内部パッケージのビルドを行う必要があります。
これはturbo.json
で設定することができます。
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"dev": {
"persistent": true,
"cache": false,
"dependsOn": ["^build"]
},
...
}
}
上記のようにすることで、dev
コマンドを実行する前には、そのパッケージが依存している内部パッケージのbuild
スクリプトを実行するようになります。
開発方法
npm install
アプリケーション・内部パッケージで新しくパッケージを追加したい場合は、npm の workspace 機能を利用して追加します。
npm install -S dayjs -w @repo/web-server
デプロイ方法(ECS)
例えば ECS にデプロイする際は、以下のような Dockerfile からビルドすることが可能です。
FROM node:22 AS builder
WORKDIR /usr/app
COPY . .
RUN npm i -g turbo
RUN turbo prune @repo/client @repo/server
RUN cp ./.env.production ./out
WORKDIR /usr/app/out
RUN npm i
RUN npm run build
FROM node:22 AS runtime
WORKDIR /usr/app
ENV TZ=Asia/Tokyo
EXPOSE 80
RUN mkdir ./public
COPY --from=builder /usr/app/out/apps/web-client/dist/ ./public/
COPY --from=builder /usr/app/out/apps/web-server/dist/ ./
COPY --from=builder /usr/app/out/apps/web-server/package.runtime.json ./package.json
RUN npm i --omit=dev
CMD ["node", "index.js"]
ポイントとしては、
-
turbo prune
で不要なパッケージを削除する -
npm run build
(またはturbo run build
)でindex.js
とpackage.runtime.json
を出力する - runtime の方で
npm install
し、エントリーポイント(node index.js
)を設定する
turbo.json で依存関係を解決しているため、turbo prune
で不要なパッケージを削除することができ、ビルドのコマンドもnpm run build
一文で実行できます。
(内部的には、@repo/env-plugin
などの内部パッケージのビルドも行われています。)
Tips
vite-runtime-package-plugin
デプロイをシンプルにするため、vite
で内部パッケージの依存ライブラリもexternal
として扱い、実行に必要なパッケージのみをまとめて出力するプラグインを作成しました。
これにより、内部パッケージだけがバンドルされ、外部モジュールはnode_modules
から参照されるようになったindex.js
とpackage.runtime.json
を出力することができます。
非常に便利だと思うので、是非参考にしてみてください。
client vs. server
注意点として、うっかりサーバーサイドのみで使われるパッケージをクライアント側のアプリケーションから参照してしまうと、ビルド時にエラーが発生します。
内部パッケージの中でも、サーバーサイドのみで使われるパッケージ、クライアントサイドでも利用可能なパッケージを分けて管理することが重要です。
私のサンプルでは、@repo/services
はサーバーサイドのみで利用されるパッケージ、@repo/shared
はクライアントサイドでも利用可能なパッケージとしています。
導入してみて
- どう管理するのがべスプラなのか、模索するのに時間がかかった
- マイクロサービスを分離しつつ、依存関係も明確に出来るので効率よくきれいに開発が進められるようにはなった
-
node_modules
がひとつに統合されているので、どこかのpackage.json
のdependencies
に記述されていれば全体にインストールされてまう- それによって
package.json
の記述漏れがあっても発見しにくい - 全体でライブラリのバージョンを統一しないといけない
- それによって
使っていない機能
-
turborepo
はクラウドキャッシュにも対応しており、ビルドなどのさらなる高速化が期待できるらしいです- ビルド速度がそこまで気にならなかったので未検証