アーキテクチャ図を描くだけでは、実装に制約はかかりません。
プロジェクト開始時に「依存は内側へ向ける」と合意しても、半年後にはdomain/からmetrics/を直接importするコードまで入り込むことがあります。
テストが通り、レビューでも見落とされれば、図と実装は静かに乖離します。
そこで、依存関係のルールをESLintのカスタムルールとして実装しました。
この記事の要点は次の3つです。
- アーキテクチャ上の禁止関係をデータとして定義する
- import元とimport先のレイヤーを判定する
- 禁止された組み合わせをAST上で検出する
本記事のコードは、実運用中のルールを説明用に簡略化したものです。
lintルールの判定フロー
実装全体の流れは次のとおりです。
依存グラフ全体を構築するのではありません。
現在のファイルと各importをレイヤーへ正規化し、禁止関係のテーブルと照合します。
対象となるディレクトリ構成
対象のcoreパッケージには、次のレイヤーがあります。
core/src/
├── domain/
├── ports/
├── types/
├── lib/
├── application/
├── metrics/
├── reports/
└── tests/
強制したいルールは次の3種類です。
| import元 | importを禁止するレイヤー |
|---|---|
domain |
application、metrics、reports
|
ports |
application、metrics、reports
|
metrics |
reports |
すべてのレイヤーを一直線の階層として扱うわけではありません。
守りたい禁止関係だけを明示し、それ以外は許可します。
現在の設計に必要な不変条件へ絞り、過剰な制約を避けるためです。
既存ツールで実現できないか検討する
依存方向を検査するために、必ず独自ルールが必要なわけではありません。
同じ問題を扱う既存ルールやパッケージもあります。
| 選択肢 | 得意なこと | 今回の判断 |
|---|---|---|
no-restricted-imports |
importパスやimport名をパターンで制限する | 単純な禁止には十分。import元によって制約を変える場合は、files条件ごとに設定を分ける必要がある |
import/no-restricted-paths |
targetとfromで禁止ゾーンを定義する。ゾーンごとのメッセージも設定できる |
今回の要件を表現できる最有力の代替 |
eslint-plugin-boundaries |
ファイルをアーキテクチャ要素へ分類し、要素間の依存ポリシーを定義する | 複雑なレイヤー構成や複数パッケージへ展開する場合に適している |
dependency-cruiser |
依存関係の検証、循環依存の検出、グラフの可視化 | リポジトリ全体を分析したい場合に強い。ESLintとは別の設定と実行経路が増える |
| 小さな独自ESLintルール | 固定された少数の不変条件を既存のlint基盤へ組み込む | 今回採用 |
import/no-restricted-pathsだけでも、3種類の禁止関係と個別メッセージを表現できます。
独自実装を選んだ理由は、既存パッケージの機能不足ではありません。
決め手は次の2点でした。
-
既存のルール基盤とファイル分類処理を再利用できた
すでに複数の独自ESLintルールがあり、ファイルパスをアーキテクチャ要素へ分類する処理も
共通化されていました。別の設定DSLで同じ分類を再定義するより、既存処理に
禁止テーブルを追加する方が小さな変更で済みます。 -
分類、禁止関係、修正案内を1か所で管理したかった
no-restricted-pathsでもゾーンごとのメッセージは設定できます。
一方、既存の分類関数を利用しながら、domain → metricsやmetrics → reportsごとに
修正案を切り替えるには、独自ルールの方がコードを追いやすいと判断しました。
禁止関係が少なく、頻繁な変更も想定していません。
エディタと既存のlintコマンドから即座に結果を返せる点も、採用を後押ししました。
これは「独自実装の方が優れている」という判断ではありません。
依存ポリシーが増えて設定中心になった段階では、eslint-plugin-boundariesが適しています。
循環依存や依存グラフの可視化まで必要なら、dependency-cruiserへの移行が合理的です。
今回は、既存基盤を再利用しながら修正案まで提示する要件に対して、最小の実装を選びました。
禁止関係をデータとして定義する
ルールの中心は、大量のif文ではなく、禁止関係を表すテーブルです。
type CoreLayer =
| 'domain'
| 'ports'
| 'types'
| 'lib'
| 'application'
| 'metrics'
| 'reports'
| 'tests'
| 'unknown'
const VIOLATION_RULES: Record<CoreLayer, CoreLayer[]> = {
domain: ['application', 'metrics', 'reports'],
ports: ['application', 'metrics', 'reports'],
metrics: ['reports'],
types: [],
lib: [],
application: [],
reports: [],
tests: [],
unknown: [],
}
このオブジェクトが、機械判定できるアーキテクチャ仕様になります。
新しい禁止関係を追加するときも、判定処理の変更は不要です。
対象レイヤーの配列へ要素を追加するだけで済みます。
ファイルパスからimport元のレイヤーを判定する
ESLintのルールでは、context.filenameから解析対象のファイル名を取得できます。
context.filenameはESLint 8.40.0で追加されました。
同時に、従来のcontext.getFilename()は非推奨となっています。
本記事のコードはESLint 9以降のflat config形式を前提にしています。
const getLayerFromPath = (filePath: string): CoreLayer => {
const normalizedPath = filePath.replace(/\\/g, '/')
const match = normalizedPath.match(
/(?:^|\/)core\/src\/(domain|ports|types|lib|application|metrics|reports|tests)(?:\/|$)/,
)
return (match?.[1] as CoreLayer) ?? 'unknown'
}
Windowsではパス区切りが\になるため、最初に/へ正規化します。
たとえば、次のパスはdomainとして判定されます。
/workspace/example/core/src/domain/entities/order.ts
正規表現内で既知のレイヤー名だけを列挙しているため、
core/src/temporary/のような未定義ディレクトリはunknownになります。
import文字列からimport先のレイヤーを判定する
同じレイヤーへのimportでも、コード上の表現は複数あります。
import { calculateValue } from '../metrics/calculate-value.js'
import { calculateValue } from '@example/core/metrics'
どちらもmetricsとして扱う必要があります。
相対importとエイリアスを1つの正規表現で判定します。
const getLayerFromImport = (importPath: string): CoreLayer | null => {
const match = importPath.match(
/^(?:(?:\.\.\/)+|@example\/core\/)(domain|ports|types|lib|application|metrics|reports|tests)(?=\/|$)/,
)
return (match?.[1] as CoreLayer) ?? null
}
先頭の^(?:(?:\.\.\/)+|@example\/core\/)により、次の両方を扱えます。
-
../metrics/...や../../metrics/...のような相対import -
@example/core/metricsのようなエイリアス
末尾の(?=\/|$)も重要です。
これがないと、../metrics-legacy/xや@example/core/metricsUtilsまで
metricsと誤判定する可能性があります。
拡張子そのものは判定対象にしていません。
レイヤー名の後ろにサブパスや.jsが続いても検出できます。
AST上のImportDeclarationを検査する
あとは、ESLintがASTを走査するタイミングで禁止テーブルと照合します。
記事を読みやすくするため、エラーメッセージは1種類に簡略化しています。
import type { Rule } from 'eslint'
export const coreLayerBoundariesRule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Enforce core module layer boundaries',
recommended: true,
},
schema: [],
messages: {
forbiddenImport:
'{{source}}から{{target}}への依存は禁止されています。' +
'共通処理を内側へ移すか、依存性注入を検討してください。',
},
},
create(context) {
const sourceLayer = getLayerFromPath(context.filename)
const forbiddenLayers = VIOLATION_RULES[sourceLayer] ?? []
if (forbiddenLayers.length === 0) {
return {}
}
return {
ImportDeclaration(node) {
const targetLayer = getLayerFromImport(String(node.source.value))
if (!targetLayer || !forbiddenLayers.includes(targetLayer)) {
return
}
context.report({
node,
messageId: 'forbiddenImport',
data: {
source: sourceLayer,
target: targetLayer,
},
})
},
}
},
}
たとえば、domain/内で次のimportを書くと違反になります。
import { calculateValue } from '../metrics/calculate-value.js'
表示されるメッセージは次のとおりです。
domainからmetricsへの依存は禁止されています。
共通処理を内側へ移すか、依存性注入を検討してください。
重要なのは、「禁止されている」と伝えるだけで終わらせないことです。
実運用では違反の組み合わせに応じて、次の行動も案内できます。
- 共通処理を内側のレイヤーへ移動する
- インターフェースを
ports/へ置く - 外側の実装を依存性注入で渡す
lintエラーを、設計ドキュメントへの入口として利用できます。
ルール自体もテストする
アーキテクチャを守るルールに誤判定があると、開発者は警告を信用しなくなります。
そのため、ルール自身もRuleTesterでテストします。
import { RuleTester } from 'eslint'
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
})
ruleTester.run('core-layer-boundaries', coreLayerBoundariesRule, {
valid: [
{
filename: '/workspace/example/core/src/domain/entity.js',
code: "import { Order } from '../types/order.js'",
},
{
filename: '/workspace/example/core/src/domain/entity.js',
code: "import { legacy } from '../metrics-legacy/calc.js'",
},
],
invalid: [
{
filename: '/workspace/example/core/src/domain/entities/entity.js',
code: "import { calculate } from '../../metrics/calculate.js'",
errors: [
{
messageId: 'forbiddenImport',
},
],
},
],
})
最低限、次のケースをテストしておくと安全です。
- 許可されたレイヤーへの相対import
- 禁止されたレイヤーへの相対import
- 階層が深い相対import
- パッケージエイリアス経由のimport
- レイヤー名が接頭辞として一致するだけのパス
- Windows形式のファイルパス
-
core/src以外のファイル - 判定対象外の外部パッケージ
既存プロジェクトではwarnから導入する
長期間運用しているコードベースでは、既存違反が大量に見つかることがあります。
最初からerrorにすると、ルール導入そのものが開発を止めかねません。
そこで、設定済みのオプションを保ったまま、重大度だけを差し替えられるようにしました。
type RuleSeverity = 0 | 1 | 2 | 'off' | 'warn' | 'error'
type RuleSetting = RuleSeverity | [RuleSeverity, ...unknown[]]
const withSeverity = (
rules: Record<string, RuleSetting>,
severity: RuleSeverity,
): Record<string, RuleSetting> =>
Object.fromEntries(
Object.entries(rules).map(([name, value]) => [
name,
Array.isArray(value) ? [severity, ...value.slice(1)] : severity,
]),
)
導入順序は次のようにしています。
アーキテクチャ改善では、理想的なルールだけでなく、継続できる移行経路も重要です。
この実装で検出しないもの
このルールは意図的に小さく作っています。
そのため、現在の実装では次のパターンを検出しません。
-
import()による動的import -
require()によるCommonJS形式の読み込み -
export { ... } from '...'による再export -
export * from '...'による再export -
tsconfig.jsonを使った完全なモジュール解決
必要になった場合は、対応するASTノードを追加するか、
TypeScriptのresolverを利用する設計へ拡張できます。
利用側でimport形式を限定しているなら、単純な正規表現でも十分に機能します。
完全な汎用性より、理解しやすさと保守コストを優先した判断です。
同じパターンを別の設計ルールへ展開する
「パスを分類し、AST上の参照先と照合する」という構造は、別の制約にも応用できます。
- Atomic Designのコンポーネント間importを制限する
-
types/内へランタイムコードを置けないようにする -
useFoo.tsとexportされるcomposable名を一致させる - 共通定数のファイルへ関数やクラスを混在させない
個別のルールを増やす前に、ファイルを分類する仕組みを共通化すると展開しやすくなります。
まとめ
アーキテクチャ上の依存方向を、レビュー時の注意事項ではなく、
静的解析できる仕様として扱えます。
今回のルールは、次の3ステップで構成されています。
- ファイルパスからimport元のレイヤーを判定する
- import文字列からimport先のレイヤーを判定する
- 禁止関係のテーブルと照合する
RuleTesterでルール自体を検証し、warnから段階的に導入すれば、
既存コードベースにも適用できます。
アーキテクチャ図は設計を説明します。
lintルールは、その設計を実行可能な契約にします。