チームの誰かがsrc/domain/invoice.tspgをimportした。PRはコンパイルを通る。テストも通る。コードレビューは300行に及び、誰も気づかない。

3ヶ月後、ドメインロジックを共有パッケージに切り出したくなる。できない。Postgresの型、コネクションプーリングのロジック、そしてモノリスにしか存在しないカスタムドライバラッパーに依存しているからだ。同心円が描かれたホワイトボードの図は、もはや笑い話だ。

これが自動化された強制なきクリーンアーキテクチャの、典型的な劣化パターンだ。アーキテクトは内向きの矢印を描く。開発者は外向きのimportを書く。コンパイラはお前の層など気にしない。

「ドメインがインフラをimportしない」ということの本当の意味

レイヤードアーキテクチャでは、依存関係は内向きに流れる。中心にあるドメイン層は、エンティティ、ビジネスルール、ユースケースを定義する。HTTPやSQL、キュー、ファイルシステムのことなど知らない。

インフラストラクチャは外周に位置する。ドメインが定義したインターフェースを実装する。リポジトリのインターフェースはドメインにあり、Postgresの実装はインフラストラクチャにある。ドメインはsave(invoice)を呼ぶ。pool.query()を呼ぶことは決してない。

ドメインコードがインフラストラクチャを直接importすると、二つのことが起きる。第一に、データベースが起動していなければドメインがテストできなくなる。第二に、PostgresをDynamoDBに差し替えるにはビジネスロジックを書き直す必要がある。レイヤリングの全目的——テスト可能性と入れ替え可能性——が霧散する。

コードレビューだけでは足りない理由

どのチームにも、レビューで悪いimportを捕まえるシニアエンジニアがいる。そのエンジニアは休暇中か、病気か、金曜の午後5時に50ファイルのPRをレビューしている。

人間は限られたRAMを持つパターンマッチャーだ。自動化された依存ルールは、無限の忍耐力を持つ決定論的バリデーターだ。アーキテクチャの境界を強制するべき場所は、構文エラーを強制する場所と同じ——ビルドパイプラインだ。コンパイルは通るが依存グラフを違反するなら、それは失敗すべきだ。

dependency-cruiserがレイヤー境界を強制する仕組み

TypeScriptとJavaScriptのコードベースでは、dependency-cruiserがアーキテクチャ図をテスト可能なルールセットに変換する。importグラフを解析し、禁じられたエッジが現れたらビルドを失敗させる。

インストール:

npm install --save-dev dependency-cruiser
npx depcruise --init

initスクリプトは.dependency-cruiser.jsの設定を生成する。そこに、ドメインがインフラストラクチャに触れることを禁じるルールを追加する:

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: "domain-cannot-import-infrastructure",
      comment:
        "Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        path: "^src/infrastructure",
      },
    },
    {
      name: "domain-cannot-import-node-modules",
      comment:
        "Domain code must not depend on external I/O libraries.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        dependencyTypes: ["npm"],
        // Allow only pure logic libraries like date-fns or lodash
        pathNot: "^(date-fns|lodash|ramda)",
      },
    },
  ],
  options: {
    doNotFollow: {
      path: "node_modules",
    },
    tsPreCompilationDeps: true,
    tsConfig: {
      fileName: "tsconfig.json",
    },
  },
};

このルールは言う:src/domain以下のファイルがsrc/infrastructure以下のものをimportしたら、ビルドを壊す。二つ目のルールは、pgaxiosのようなnode_modules経由のimportを捕まえる。

CIに組み込む:

# .github/workflows/ci.yml
jobs:
  architecture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx depcruise src

これで、src/domain/invoice.tsでのpgのimportは、人間がPRを開く前に失敗する。

失敗がどう見えるか

開発者がルールを破ると、以下のような出力を受け取る:

error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
  Domain code must not depend on infrastructure. Move the import to an adapter or repository.

error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
  Domain code must not depend on external I/O libraries.

✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.

エラーメッセージは、何をすべきかを伝える。データベース呼び出しをインフラストラクチャ層のリポジトリに移せ。リポジトリをインターフェースとしてユースケースに渡せ。ドメインコードは純粋なままだ。

他のエコシステムでの代替手段

全員がTypeScriptを使うわけではない。原則はどこでも同じだ。変わるのはツールだけだ。

Java: ArchUnitが黄金標準だ。設定ファイルではなく、テストを書く:

@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..infrastructure..");

これはJUnitテストとして実行されるので、新しいCIの配管なしにMavenやGradleと統合できる。

C#: NetArchTestは.NET向けに同じAPIスタイルを提供する。ユニットテストを書き、CIで実行する。

Go: 成熟した同等品はない。ほとんどのチームはシンプルなシェルスクリプトでこれを強制する:

#!/bin/bash
# scripts/check-domain-imports.sh

if grep -r "infrastructure/" internal/domain/ ; then
  echo "Domain code imports infrastructure. Fix the dependency direction."
  exit 1
fi

粗削りだが、動く。多くのGoチームは、カスタムのgo vetアナライザーやgo/astパッケージを使って、きちんとしたチェックを構築している。

ビルドシステム: Bazel、Gradle、Nxはすべてモジュール依存グラフをネイティブにサポートしている。ドメインとインフラストラクチャを、明示的な依存宣言を持つ別々のモジュールとして定義すれば、ビルドツールが無償で境界を強制する。ただし、まずそのビルドシステムを採用しなければならない。

トレードオフ:偽陽性、移行の痛み、チームの摩擦

自動化されたルールはタダではない。有効にする前に、コストを知っておくべきだ。

偽陽性は、層の間に位置する正当な共有ユーティリティがあるときに起きる。文字列のスラグ化ツールやカスタムエラーの基底クラスがsrc/sharedに置かれているかもしれない。ドメインもインフラストラクチャもそれをimportするなら、ルールは問題ない。src/infrastructure/utilsに置いてドメインがimportしたら、dependency-cruiserが文句を言う。修正方法は通常、共有コードを移動することだが、それは往々にして正しい判断だ。

移行の痛みは現実だ。コードベースがすでに30箇所でルールに違反しているなら、英雄的なリファクタリングなしにスイッチを押すことはできない。実用的なアプローチは、警告から始め、1スプリントで既存の違反を修正し、それからエラーに格上げすることだ。あるいは、既知のレガシーファイルをpathNotの例外にし、初日から新しい違反を禁じる方法もある。

チームの摩擦は隠れたコストだ。厳格なレイヤー強制に慣れていない開発者は反発する。「小さなimport一つくらい害はない」「ルールは官僚的だ」「スピードを落とす」と主張するだろう。これは文化的なシグナルであって、技術的なものではない。同じ開発者が、23時に機能を出荷するためにドメインモデルにexpressをimportするのだ。ルールが存在するのは、プレッシャーの下で人間がショートカットを選ぶからだ。

既存のコードベースへの実装方法

大規模な書き直しは必要ない。境界と、それを測る方法が必要だ。

  1. 層を描け。どのディレクトリがドメイン、アプリケーション、インフラストラクチャか決める。ARCHITECTURE.mdに書き留める。

  2. dependency-cruiser(またはエコシステムの同等品)を追加し、一つのルールを設ける:ドメインはインフラストラクチャをimportできない。

  3. 実行する。違反を数える。数が少なければ、設定をマージする前に修正する。多ければ、レガシーパスをpathNotの例外に追加する。

  4. CIで失敗させる。任意ではない。警告でもない。マージをブロックするエラーだ。

  5. 開発者がエラーにぶつかるたびに、importを移すのを手伝え。ビルドが赤いだけを伝えるんじゃない。コードがどこに置かれるべきか、なぜかを説明する。

2週間もすれば、チームはドメインファイルにpgをimportしようとするのをやめる。ホワイトボード上のアーキテクチャ図が、ついにリポジトリのコードと一致する。

FAQ

アプリケーション層はインフラストラクチャをimportしてもいいのか?

通常は、いい。アプリケーション層はユースケースをオーケストレートする。リポジトリや外部サービスについて知っていてもいいが、具体的な実装ではなくインターフェースに依存すべきだ。厳格な強制を望むなら、二つ目のルールを追加する:applicationinfrastructureの実装をimportできず、インターフェースのみ。

ドメインイベントはどうか?

ドメインイベントはよくある抜け穴だ。ドメインイベントは純粋なデータなので、ドメイン層に属する。それを発行するインフラストラクチャのイベントバスはインフラストラクチャに属する。アプリケーション層がドメインイベントハンドラをバスにサブスクライブする。ドメイン自体は、バスの存在を決して知らない。

NxやTurborepoでも使えるか?

はい。Nxにはプロジェクトレベルで機能する組み込みのモジュール境界ルールがある。ドメインとインフラストラクチャが別々のNxライブラリなら、追加のツールなしにルールを強制できる。BazelとGradleも同様だ。

インフラストラクチャのユーティリティが必要なら?

必要ない。そのユーティリティを、インフラストラクチャの依存を持たない共有のcommonkernelパッケージに移せ。本当にインフラストラクチャを必要とするなら、それはユーティリティではない。インフラストラクチャだ。

結論

自動化された強制なきクリーンアーキテクチャは、紳士協定だ。紳士協定は、本番の締切を生き延びない。

dependency-cruiser、ArchUnit、あるいはgrepを使ったシェルスクリプトでさえ、アーキテクチャを「提案」から「保証」に変える。ドメインは純粋なままだ。インフラストラクチャは差し替え可能なままだ。そして次に誰かがビジネスルールにpgをimportしようとしたとき、ビルドはPRが人間のレビュアーに届く前に失敗する。

一つのルールから始めろ。CIで赤にしろ。最初の違反を修正しろ。残りは続く。