概要
- 【参考】親記事
私が今開発しているモバイルアプリでは FSD(Feature-Sliced Design)アーキテクチャを採用し、GitHubで管理しています。FSDには特有のレイヤーが存在しており、それぞれ依存関係があるのでそれをコミット時にチェックする仕組みを作っています。今回はそれを記事にしました。
背景
リファクタリング中にシステム設定のフォルダ(features/settings)がリマインダーのフォルダ(features/reminder)を横断 import していたことがわかりました。この発見が遅れた原因はコードレビューで目視確認していただけで自動チェックがなかったためです。そこで、依存関係のルール違反を自動で発見するために、eslint-plugin-boundaries + husky + lint-staged でコミット前にチェックするようにしました。
想定するフォルダ構成
本記事のセットアップに必要な最小構成です。
※リポジトリのルートにappNameフォルダがあるものとします。
appName/
├── .husky/
│ └── pre-commit ✅ 新規作成
├── src/ ← FSD ソースルート(tsconfig.json の @/ エイリアスが指す先)
│ ├── app/
│ ├── pages/
│ ├── features/
│ │ ├── feature-a/ ← スライス例(例: reminder)
│ │ ├── feature-b/ ← スライス例(例: settings)
│ │ └── ...
│ ├── entities/
│ │ ├── entity-a/ ← スライス例(例: user)
│ │ └── ...
│ └── shared/
│ │ ├── lib/
│ │ └── ui/
├── eslint.config.js ✅ 新規作成
├── package.json ✅ 追加変更
└── tsconfig.json
アーキテクチャの依存方向ルール
FSDベースのアーキテクチャで、以下の依存方向のみ許可しています。特に重要なのが 「pages/features/entities 間の横断 import 禁止」 です。例えば features/reminder が features/settings を import するのは違反になります。
ゴール: FSD 依存ルール違反をコミット前に自動検出し、違反を含むコミットをブロックする。
FSDではWidgetsと呼ばれるレイヤーもあるのですが、私の開発プロジェクトでは特段使う理由がないため作っていません。そのため、Widgetsレイヤーを除いた、上記の5つのレイヤーを使っています。
どうやって解決したのか
以下の構成で説明していきます。
- パッケージインストール(4つ)
- ファイルの追加・変更(3つ)
- 動作確認
①パッケージインストール(4つ)
npm install -D eslint-plugin-boundaries eslint-import-resolver-typescript husky lint-staged
| パッケージ | 役割 |
|---|---|
eslint-plugin-boundaries |
FSD 依存ルールを ESLint で宣言的に定義 |
eslint-import-resolver-typescript |
@/ などの TypeScript パスエイリアスを ESLint に解決させる |
husky |
Git フックを管理(pre-commit フックの登録) |
lint-staged |
ステージ済みファイルのみに ESLint を実行し、チェックを高速化 |
②ファイルの追加・変更(3つ)
| ファイル | 変更内容 |
|---|---|
eslint.config.js |
eslint-plugin-boundaries プラグインと FSD ルールを追加 |
package.json |
prepare スクリプトと lint-staged 設定を追加 |
.husky/pre-commit |
新規作成(lint-staged 実行 + 結果メッセージ出力) |
②-1. ESLintの設定(依存関係ルールの設定)
// eslint.config.js
const boundaries = require('eslint-plugin-boundaries');
module.exports = defineConfig([
expoConfig,
{
plugins: { boundaries },
settings: {
'import/resolver': {
typescript: { alwaysTryTypes: true, project: './tsconfig.json' },
node: { extensions: ['.ts', '.tsx'] },
},
'boundaries/root-path': './src',
'boundaries/elements': [
{ type: 'app', pattern: 'app' },
{ type: 'pages', pattern: 'pages' },
{ type: 'features', pattern: 'features/(*)', capture: ['slice'] },
{ type: 'entities', pattern: 'entities/(*)', capture: ['slice'] },
{ type: 'shared', pattern: 'shared' },
],
},
rules: {
// FSD レイヤー依存方向を強制: app → pages → features → entities → shared
// feature 間横断依存は eslint-disable-next-line で明示的に許容すること
'boundaries/dependencies': ['error', {
default: 'disallow',
rules: [
{ from: 'app', allow: ['pages', 'features', 'entities', 'shared'] },
{ from: 'pages', allow: ['features', 'entities', 'shared'] },
{ from: 'features', allow: ['entities', 'shared'] },
// 同一 feature スライス内の import を許容
{ from: [['features', { slice: '${from.slice}' }]], allow: [['features', { slice: '${from.slice}' }]] },
// shared だけでなく entities 間の import (クロスインポート)も許容
{ from: 'entities', allow: ['shared', 'entities'] },
// shared 内の相互 import を許容
{ from: 'shared', allow: ['shared'] },
],
}],
},
},
]);
【補足1】なぜ (*) を使うのか
(*) でキャプチャした値は capture: ['slice'] と組み合わせることで slice という変数名で参照できます。これにより「同じ feature スライス内のファイル同士の import は許可する」という条件付きルールを書けます。
{ type: 'features', pattern: 'features/(*)', capture: ['slice'] }
// ↓ キャプチャした slice を使って「同一スライス内のみ許可」を表現できる
{ from: [['features', { slice: '${from.slice}' }]], allow: [['features', { slice: '${from.slice}' }]] }
// → features/reminder 内のファイルが features/reminder 内の別ファイルを import するのは ✅ OK ✅
// → features/reminder が features/settings を import するのは ❌ NG ❌
【補足2】クロスインポートの許可について
私の開発環境では切り離せない親子関係がドメインモデル間であるため、entitiesレイヤーにおいてクロスインポートを許可していますが、公式では原則として禁止されています。しかし実際の開発では「どうしても避けられない場面」もあると思いますので、状況に応じて柔軟に対応した方が良いかと思います。
もし厳格にクロスインポートも禁止するのであれば、以下のように変更してください。これはentitiesレイヤーのスライス間のインポートは許容し、entitiesレイヤー間のクロスインポートを禁止する設定になります。
// shared だけでなく entities 間の import (クロスインポート)も許容
{ from: 'entities', allow: ['shared', 'entities'] },
↓
// 同一 entities スライス内の import を許容
{ from: [['entities', { slice: '${from.slice}' }]], allow: [['entities', { slice: '${from.slice}' }]] },
{ from: 'entities', allow: ['shared'] },
②-2. husky + lint-staged のセットアップ
今回のプロジェクトは git ルートと package.json の場所が異なる構成です。
appName/package.json
{
// ...
"scripts": {
// ...
"prepare": "husky appName/.husky", // gitフックの追加
// ...
},
"lint-staged": {
"src/**/*.{ts,tsx}": "eslint"
}
// ...
}
appName/.husky/pre-commit
echo "\n#################################"
echo "🔍FSDアーキテクチャの依存違反チェック🔍"
echo "#################################\n"
cd appName
if npx lint-staged; then
echo "\n✅ FSD依存チェック: 問題なし✅\n"
else
echo "\n❌ FSD依存チェック: 違反が検出されました。コミットをブロックします。❌\n"
exit 1
fi
③動作確認
【成功パターン】違反がないケース
2026-05-29 14:56:55.592 [info]
#################################
🔍FSDアーキテクチャの依存違反チェック🔍
#################################
[STARTED] Backing up original state...
[COMPLETED] Backed up original state in git stash (9a2119d)
[STARTED] Running tasks for staged files...
[STARTED] package.json — 1 file
[STARTED] src/**/*.{ts,tsx} — 1 file
[STARTED] eslint
[COMPLETED] eslint
[COMPLETED] src/**/*.{ts,tsx} — 1 file
[COMPLETED] package.json — 1 file
[COMPLETED] Running tasks for staged files...
[STARTED] Updating Git index again...
[COMPLETED] Updating Git index again...
[STARTED] Cleaning up temporary files...
[COMPLETED] Cleaning up temporary files...
✅ FSD依存チェック: 問題なし✅
【失敗パターン】違反があるケース
FSD 違反のあるファイルをステージしてコミットしようとすると以下のようにブロックされます。
#################################
🔍FSDアーキテクチャの依存違反チェック🔍
#################################
[STARTED] Backing up original state...
[COMPLETED] Backed up original state in git stash (f629538)
[STARTED] Running tasks for staged files...
[STARTED] package.json — 1 file
[STARTED] src/**/*.{ts,tsx} — 1 file
[STARTED] eslint
[FAILED] eslint [FAILED]
[FAILED] eslint [FAILED]
[COMPLETED] Running tasks for staged files...
[STARTED] Updating Git index again...
[SKIPPED] Skipped because of errors from tasks.
[STARTED] Reverting to original state because of errors...
[COMPLETED] Reverting to original state because of errors...
[STARTED] Cleaning up temporary files...
[COMPLETED] Cleaning up temporary files...
✖ eslint:
[boundaries][warning]: [boundaries/dependencies] Detected legacy selector syntax in 6 rule(s) at indices: 0, 1, 2, 3, 4, 5.
Consider migrating to object-based selectors. More info: https://www.jsboundaries.dev/docs/releases/migration-guides/v5-to-v6/
[boundaries][warning]: [boundaries/dependencies] Detected legacy template syntax ${...} in 1 rule(s) at indices: 3.
Consider migrating to {{...}} syntax. More info: https://www.jsboundaries.dev/docs/releases/migration-guides/v5-to-v6/#new-template-syntax
appName/src/features/settings/ui/import_violation_sample.tsx
5:41 error There is no rule allowing dependencies from elements of type "features" and slice "settings" to elements of type "features" and slice "subscription-management" boundaries/dependencies
✖ 1 problem (1 error, 0 warnings)
❌ FSD依存チェック: 違反が検出されました。コミットをブロックします。❌
husky - pre-commit script failed (code 1)
【非推奨】違反を無視する方法
本当に「やむを得ない暫定対応」として一時的に違反を許可するとき、以下のeslint-disable-next-lineを使って違反を残したまま通すことも可能です。
// @/features/settings/ui/SystemSettingScreen.tsx
// eslint-disable-next-line boundaries/dependencies -- 【許可理由】〜〜
import { useProStore } from '@/features/subscription-management';
これは「意図的に残した違反」であり、クロスインポートが増えると構造が汚れ、いつか管理しきれなくなるため、まず shared/ または entities/ レイヤーへの切り出しを検討すべきです。
AIを使った開発の場合は禁止事項として使わないように明記しておいても良いかもしれません。
感想
eslint-plugin-boundaries の導入自体はシンプルで、一度設定してしまえばコミットのたびに自動で FSD 違反をチェックしてくれるため、アーキテクチャの健全性を継続的に保てるようになりました。
生成AIで開発作業をしていると知らない間に構造が不自然になってしまいます。私の場合はClaudeを使って開発しており、今回のような依存関係違反であったり、類似したフォルダ名が作成されていたりします。このようなことを回避するために、構造設計力が必要になってくると思っています。開発フロー標準化、フォルダ構成や開発体制などのルール策定、各種ドキュメントのテンプレート化などを固めつつ、またAIが想定外の動きをしないように「適度な禁止事項を定義すること」もエンジニアの素養として求められている気がしています。
以上になります。
最後までお読みいただきありがとうございました。