pnpmのモノレポで依存を制限する[クリーンアーキテクチャ]
この記事は
Chatwork Advent Calendar 2022
9日目の記事です。
はじめに
レイヤードアーキテクチャが好きです。
ヘキサゴナルアーキテクチャが好きです。
コードをルールで縛ることが好きです。
ですが、人間がコードを書く以上、ルールは破られるものです。
- 依存してはいけない層で、依存してはいけないライブラリに依存してしまう
- 依存してはいけない層で、依存してはいけない層に依存してしまう
コードに関わる人が多ければ多いほど、その問題は多く発生します。
この記事では、設計ルールを踏み外さないために、依存を制限をする方法を紹介します。
コード
環境
ツールの概要 | ツールの名前 | バージョン |
---|---|---|
パッケージマネージャ | pnpm | 7.15.0 |
ビルドツール | vite | 3.2.3 |
UIライブラリ | react | 18.2.0 |
ビルドツールとUIライブラリは今回はほぼ関係なく、pnpmの機能で実現しています。
アーキテクチャ
以下のような構成にしています。
ディレクトリ | 説明 |
---|---|
clients/** | domainとusecaseに依存することができます。 |
usecase | domainとadapterに依存することができます。 |
adapter/** | domainに依存することができます。 adapter同士が依存することはできません。 |
domain | どこにも依存せず、純粋なビジネスロジックと代数的データ型を持ちます。 |
また、依存性逆転の原則に関しては今回採用していません。
モノレポとは
モノリスリポジトリの略。
複数のサービスを1つのリポジトリ(git)で管理する手法を表します。
マイクロサービス化したものを、1つのリポジトリで管理し、運用や保守コストなどを減らすための技術だと認識しています。
pnpmは複数のJavaScriptリポジトリをモノレポとして管理する機能があるため、それを活用します。
おそらく、本来pnpmが想定している利用ケースとは異なるため注意してください。
モノレポの定義
pnpmのモノレポを利用するには、pnpm-workspace.yaml
をルートに作成する必要があります。
packages:
- 'packages/**'
- 'clients/**'
ディレクトリ/**
と記述することで、ディレクトリ以下の全てを、モノレポ管理の対象とすることができます。
今回はpackages
以下と、clients
以下をモノレポの対象としたかったため、このような設定としました。
リポジトリの定義
usecaseを例に解説します。
私が定義した/packaege/usecase
は、モノレポにおける、1つのリポジトリです。
1つのリポジトリに対し、1つのpackage.json
を必要をします。
以下は、usecaseで利用しているpackage.jsonです
{
"private": true,
"name": "usecase",
"dependencies": {
"domain": "workspace:^",
"adapter.great-api": "workspace:^",
"adapter.nice-api": "workspace:^"
}
}
name
にモノレポのパッケージ名を書き、
dependencies
に依存するパッケージ、リポジトリを記述します。
usecaseはdomainとadapterに依存することができるので、
以下の3つに依存する構成にしています。
- domain
- adapter.great-api
- adapter.nice-api
この実装をすることによって、依存を可能にします。
裏を返せば、dependencies
に追記しなければ、依存は不可能となります。
以下のようにnice-apiの依存を消した場合、usecaseからは依存できなくなるため、importができない旨のエラーが出力されます。
{
"private": true,
"name": "usecase",
"dependencies": {
"domain": "workspace:^",
"adapter.great-api": "workspace:^",
- "adapter.nice-api": "workspace:^"
}
}
domainやadapterなども同じように設定をしています。
参考: https://pnpm.io/ja/workspaces
TypeScriptについて
各リポジトリにpackage.json
を用意するということは、各package.json
でTypeScriptに依存する必要があります。
各リポジトリでTypeScriptに依存したり、バージョンを揃えるのは大変なため、pnpmのマクロ機能によって問題を解決します。
ルートにpnpmfile.cjs
を用意します。
function readPackage(pkg) {
pkg.devDependencies = {
...pkg.devDependencies,
typescript: '^4.7.4',
}
return pkg
}
module.exports = {
hooks: {
readPackage,
},
}
readPackage
は、pnpm i
を叩いた後、package.json
を解析したその後に呼び出される関数です。
流れとしては以下です
pnpm iが叩かれる
-> package.jsonが読み込まれる
-> pnpmfile.cjsのreadPackageが実行される
この関数を用いることで、package.json
を改変することができます。
pkg.devDependencies = {
...pkg.devDependencies,
typescript: '^4.7.4',
}
上記の処理によって、package.json
のdevDependencies
に、追記することができます
そして、この処理は、モノレポで管理している全てのpackage.json
で行われるため、全てのリポジトリにTypeScriptの同バージョンを挿入することが可能になります。
参考: https://pnpm.io/ja/pnpmfile
tsconfig.jsonについて
各package.json
でTypeScriptに依存しているため、
各リポジトリでtsconfig.json
を用意する必要があります。
ですが、多くの場合tsconfig.json
を使い分けたいことはないはずです。
ルートに、以下のような元となるjsonを用意し、
usecaseや、その他のtsconfig.json
で継承するようにしています。
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"useDefineForClassFields": true,
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}
{
"extends": "../../tsconfig-base.json"
}
クライアントでは、reactなどで別途tsの設定をしたいため、必要なオプションのみ追記しています。
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"jsx": "react-jsx"
},
"include": ["../../tsconfig-base.json"]
}
まとめ
pnpmのモノレポを利用して、依存の方向性を制限することができました。
筆者の感想としては、この構成で開発しても問題がなさそうなレベルだと思いましたので、
TypeScriptで個人開発する際は、この構成を利用しようと思います。
再三になりますが、本来pnpmが想定している利用ケースとは異なるので、みなさんもほどほどに活用いただければと思います。
ほなね〜👋
Discussion