1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アーキテクチャ図を描くだけでは、実装に制約はかかりません。

プロジェクト開始時に「依存は内側へ向ける」と合意しても、半年後には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ステップで構成されています。

  1. ファイルパスからimport元のレイヤーを判定する
  2. import文字列からimport先のレイヤーを判定する
  3. 禁止関係のテーブルと照合する

RuleTesterでルール自体を検証し、warnから段階的に導入すれば、
既存コードベースにも適用できます。

アーキテクチャ図は設計を説明します。

lintルールは、その設計を実行可能な契約にします。

参考資料

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?