package by feature なファイルの依存関係をルールで守る(eslint-plugin-boundaries)
package by feature と呼ばれるディレクトリ構成が一般的になってきました。[1]
キカガクでも、新規に作成するプロジェクトでは package by feature なディレクトリ構成を採用したり、既存のプロジェクトを段階的に移行させたりしています。
今回は、この package by feature のディレクトリ分割を ESLint でルール化する方法を紹介します。
package by feature とは
詳しい説明は他の記事に譲りますが、ざっくり言うと、ファイルの種別ではなく機能を基準にディレクトリを分けていくようなディレクトリ構成の方法です。
これにより、1 つの機能に関わるファイルがまとまる(=コロケーション)ため、読むのも書くのも楽になります。
import ルールを設けたい
package by feature でディレクトリを構成する場合、基本的に同じ機能の中で import が完結し、別の機能のファイルとは独立するはずです。
本来独立しているべき機能が依存関係を持ってしまうと、コードの理解が難しくなったり、変更時に意図しない影響を生んでしまったりします。
個人的に意識しているのは、「その機能(ディレクトリ)を消したいとなったときに、他の機能に影響がないことを担保する」という考え方です。
これを人間の目だけでチェックするには限界があるため、今回はこれを機械的にチェックするための ESLint ルールを設けます。
想定するディレクトリ構成
package by feature を前提としたディレクトリ構成にもいくつかの選択肢はあると思いますが、今回は以下のようなディレクトリ構成を考えます。
📁src/features
├──🗃️_components
│ └──📁Button
│ ├──📄index.tsx
│ └──📄index.stories.tsx
├──📁user
│ ├──🗃️_components
│ │ └──📁UserList
│ │ ├──📄index.tsx
│ │ └──📄index.stories.tsx
│ ├──🗃️_hooks
│ │ └──📄useUser.ts
│ └──📁profile
│ └──🗃️_components
│ └──📁Edit
│ ├──📄index.tsx
│ └──📄index.stories.tsx
└──📁post
├──🗃️_components
│ └──📁PostList
│ ├──📄index.tsx
│ └──📄index.stories.tsx
└──🗃️_hooks
└──📄usePagination.ts
記事投稿サイトのようなイメージで、ユーザー関連の機能(user
)と記事関連の機能(post
)があるという想定です。
features
ディレクトリを作成し、その中に機能ごとのディレクトリを作成しています。
Next.js で App Router を採用している場合、app
ディレクトリ自体を features
ディレクトリの役割として利用できます(余談ですが、Next.js のドキュメントにはコロケーションについて解説しているページもあり、参考になります)。
ここで、それぞれの絵文字は以下のような意味を示しています。
- 📁 は feature ベースで分けた機能ディレクトリ
- 🗃️(
_
から始まるディレクトリ)は layer ベースで分けたディレクトリ - 📄 はファイル
_
の役割
この構成では、_
(アンダースコア)に特殊な役割を持たせています。
_
で始まるディレクトリは、その機能ディレクトリの持ち物として扱います。
例えば、以下のような説明ができます。
-
src/features/post/_hooks
は、post
の機能で使うためのカスタムフックを入れるディレクトリ -
src/features/user/profile/_components
は、user/profile
の機能で使うためのコンポーネントを入れるディレクトリ -
src/features/_components
は、全体で共通して使うコンポーネントを入れるディレクトリ
なお、 _
を利用するアイデアは、Next.js でプライベートディレクトリとして扱われる記号であることから採用しています。
import ルール
ここでは import ルールを以下のようにしたいです。
- ✅ 同じ機能のディレクトリ内では import OK
- ✅ 親のディレクトリからは import OK
- ❌ 異なる機能のディレクトリからは import NG
- ❌ 子のディレクトリからは import NG
これらを守ることで、「このディレクトリの中だけで使っているつもりだったのに、実は他のディレクトリでも使っていた!」という問題を避けられます。
eslint-plugin-boundaries
eslint-plugin-boundaries
の boundaries/element-types
ルールを使って、前述の import ルールを設定します。
他にもいくつかのプラグインを検討しましたが、最終的に目的の動作を実現できるものとしてこれを選びました。
(参考)試したもののうまくいかなかったプラグイン
上記 2 つは、 package by layer のディレクトリ構成であればルールを書きやすそうでしたが、feature ベースの分け方だとうまくルールを構成できず断念しました。
TypeScript の import 補完も制御してくれる点も有用で、使いたい候補でした。しかし、これで検出できる形にしようとすると、フォルダごとに index.ts
(エントリーポイントとなるファイル)を作る必要があり、導入のコストが高いため断念しました。
実際の設定
動作を確認したバージョン
- eslint: 8.54.0
- eslint-plugin-boundaries: 4.2.0
module.exports = {
plugins: ["boundaries"],
overrides: [
{
files: ["src/features/**/*"],
settings: {
"boundaries/elements": [
{
type: "features",
pattern: "src/features/*/*",
mode: "full",
capture: ["dir1"],
},
{
type: "features",
pattern: "src/features/*/*/*",
mode: "full",
capture: ["dir1", "dir2"],
},
{
type: "features",
pattern: "src/features/*/*/*/*",
mode: "full",
capture: ["dir1", "dir2", "dir3"],
},
{
type: "features",
pattern: "src/features/*/*/*/*/**",
capture: ["dir1", "dir2", "dir3", "dir4"],
},
],
},
rules: {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
{
from: "features",
allow: [
[
"features",
{ dir1: "_*" },
],
[
"features",
{ dir1: "${from.dir1}", dir2: "_*" },
],
[
"features",
{ dir1: "${from.dir1}", dir2: "${from.dir2}", dir3: "_*" },
],
[
"features",
{ dir1: "${from.dir1}", dir2: "${from.dir2}", dir3: "${from.dir3}", dir4: "_*" },
],
],
},
],
message: "features配下のファイルは同じ機能内でのみimport可能です",
},
],
},
},
],
};
以降で設定の内容を補足します。
settings
まずは settings
でそれぞれのファイルパスのキャプチャパターンを定義します。
例えば、以下の設定を抜き出して考えてみます。
{
type: "features",
pattern: "src/features/*/*/*/*",
mode: "full",
capture: ["dir1", "dir2", "dir3"],
},
例えば、 src/features/user/_components/UserList/index.tsx
というファイルはこのパターンにマッチします。type
に指定した features
という名前で扱われ、 dir1
に user
、dir2
に _components
、dir3
に UserList
がキャプチャされます(以下の表のように対応しています)。
capture | dir1 |
dir2 |
dir3 |
|||
---|---|---|---|---|---|---|
pattern | src |
features |
* |
* |
* |
* |
ファイルパス | src |
features |
user |
_components |
UserList |
index.tsx |
これをあとのルールで参照します。
rules
settings
で定義したキャプチャパターンを元に、 rules
でルールを設定します。
default: "disallow"
によって一旦すべての import を禁止し、allow
で許可するパターンを設定しています。
{
from: "features",
allow: [
["features", { dir1: "_*" }],
["features", { dir1: "${from.dir1}", dir2: "_*" }],
[
"features",
{ dir1: "${from.dir1}", dir2: "${from.dir2}", dir3: "_*" },
],
[
"features",
{ dir1: "${from.dir1}", dir2: "${from.dir2}", dir3: "${from.dir3}", dir4: "_*" },
],
],
},
例えば、以下のように import を記述した場合の挙動を考えてみます。
import { Button } from "features/_components/Button"; // ✅ 親からのimportはOK
import { useUser } from "features/user/_hooks/useUser"; // ✅ 同じ機能内からのimportはOK
import { usePagination } from "features/post/_hooks/usePagination"; // ❌ 異なる機能からのimportはNG
import { Edit } from "features/user/profile/_components/Edit"; // ❌ 子からのimportはNG
対象ファイルである src/features/user/_components/UserList/index.tsx
のパスは settings
の項目で説明した通り、 dir1
に user
、dir2
に _components
、dir3
に UserList
がキャプチャされています。
この情報を from.dir1
などで参照し、それぞれの import が許可されるかどうかを判定します。
dir1 |
dir2 |
dir3 |
dir4 |
結果 | |
---|---|---|---|---|---|
対象ファイル | user |
_components |
UserList |
index.tsx |
- |
"(略)/_components/Button/index.tsx" (親からの import) |
_components |
Button |
index.tsx |
✅ { dir1: "_*" } にマッチ |
|
"(略)/user/_hooks/useUser.ts" (同じ機能の import) |
user |
_hooks |
useUser.ts |
✅ { dir1: "${from.dir1}", dir2: "_*" } にマッチ |
|
"(略)/post/_hooks/usePagination.ts" (別の機能の import) |
post |
_hooks |
usePagination.ts |
❌ dir1 が異なるため、いずれにもマッチしない |
|
"(略)/user/profile/_components/Edit/index.tsx" (子からの import) |
user |
profile |
_components |
Edit |
❌ いずれにもマッチしない |
エラーになる import をしたくなったときの対応
例えば、 post
だけで使用していた usePagination.tsx
を user
でも使いたくなった場合を考えみましょう。
以下のように import を書いてみると、設定したルールによりエラーになります。
import { usePagination } from "features/post/_hooks/usePagination"; // ❌ ESLint エラー
このような import を許してしまうと、本来独立しているはずの src/features/user
と src/features/post
が依存関係を持ってしまうため避けるべきです。
これを回避するには、以下のいずれかの方法を取ります。
-
usePagination.tsx
をsrc/features/_hooks
に移動する -
usePagination.tsx
をsrc/features/user/_hooks
にも別で作成する
vscode-icons
を使っている場合)
おまけ TIPS (VSCode の vscode-icons
拡張機能を使っている場合、 components
や hooks
という名前のディレクトリに対して固有のアイコンが表示されます。しかし、 _components
や _hooks
のようにアンダースコアを付けてしまうと判定から外れてしまい、デフォルトのアイコンになってしまいます。
これらのディレクトリにも固有のアイコンを表示させるためには、settings.json
に以下のように設定を追加するとよいです。
"vsicons.associations.folders": [
{
"icon": "component",
"extensions": ["_components", "_component"]
},
{
"icon": "hook",
"extensions": ["_hooks", "_hook"]
},
{
"icon": "tools",
"extensions": ["_utils", "_util"]
}
],
まとめ
eslint-plugin-boundaries
を使って、package by feature なディレクトリ構成での import ルールを設定しました。
これにより、ファイル間での import のルールを機械的にチェックでき、実装やレビューの負荷が下がります。
これを目視でチェックしようとすると脳のリソースを無駄に使うことになってしまい、見落としやすくもある内容であるため、ぜひ導入してみてください。
参考記事
Discussion