LESSON 30分

ストーリー

佐藤CTOがモニターに依存関係グラフを表示しました。線が無数に交差し、まるで蜘蛛の巣のようです。

佐藤CTO
これが今の我々のコードベースの依存関係だ。見た目の通り、絡まり合っている
あなた
どこから手をつければいいか、わかりません…
佐藤CTO
大丈夫。依存関係の分析には体系的な方法がある。闇雲にコードを読むのではなく、ツールとメトリクスを使って科学的に分析するんだ。今日はその方法を教える

静的分析ツール

静的分析は、コードを実行せずにソースコードの構造を解析する手法です。

ESLint による依存ルールの検証

// .eslintrc.js -- import制約の設定例
module.exports = {
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        // ドメイン層からインフラ層へのimportを禁止
        {
          group: ['../infrastructure/*', '../adapters/*'],
          message: 'ドメイン層からインフラ層への依存は許可されていません',
        },
        // モジュール間の直接import禁止(公開APIのみ許可)
        {
          group: ['../order/internal/*', '../inventory/internal/*'],
          message: 'モジュール内部への直接アクセスは禁止です。公開APIを使用してください',
        },
      ],
    }],
  },
};

dependency-cruiser による依存ルール検証

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      comment: '循環依存を禁止',
      severity: 'error',
      from: {},
      to: { circular: true },
    },
    {
      name: 'no-domain-to-infra',
      comment: 'ドメイン層からインフラ層への依存を禁止',
      severity: 'error',
      from: { path: '^src/domain' },
      to: { path: '^src/(infrastructure|adapters)' },
    },
    {
      name: 'no-cross-module-internals',
      comment: 'モジュール間の内部アクセスを禁止',
      severity: 'warn',
      from: { path: '^src/modules/([^/]+)/' },
      to: {
        path: '^src/modules/([^/]+)/',
        pathNot: '^src/modules/$1/',  // 同一モジュール内は許可
      },
    },
  ],
  options: {
    doNotFollow: { path: 'node_modules' },
    tsConfig: { fileName: 'tsconfig.json' },
    reporterOptions: {
      dot: { theme: { graph: { rankdir: 'TB' } } },
    },
  },
};

import グラフ分析

import 関係の抽出と可視化

// TypeScriptのAST(抽象構文木)を使ったimport分析
import * as ts from 'typescript';
import * as path from 'path';

interface ImportRelation {
  source: string;      // importしているファイル
  target: string;      // importされているファイル
  importedNames: string[]; // importしている名前
  isTypeOnly: boolean; // 型のみのimportか
}

function analyzeImports(sourceFile: ts.SourceFile): ImportRelation[] {
  const relations: ImportRelation[] = [];

  ts.forEachChild(sourceFile, (node) => {
    if (ts.isImportDeclaration(node)) {
      const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
      const importClause = node.importClause;

      const importedNames: string[] = [];
      if (importClause?.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
        for (const element of importClause.namedBindings.elements) {
          importedNames.push(element.name.text);
        }
      }

      relations.push({
        source: sourceFile.fileName,
        target: moduleSpecifier,
        importedNames,
        isTypeOnly: importClause?.isTypeOnly ?? false,
      });
    }
  });

  return relations;
}

モジュール間の依存強度を測定

// モジュール間の依存強度を数値化する
interface ModuleDependency {
  fromModule: string;
  toModule: string;
  importCount: number;       // import文の数
  symbolCount: number;       // importされているシンボルの数
  isCircular: boolean;       // 循環依存か
  couplingStrength: 'weak' | 'moderate' | 'strong';
}

function calculateCouplingStrength(symbolCount: number): string {
  if (symbolCount <= 2) return 'weak';
  if (symbolCount <= 5) return 'moderate';
  return 'strong';
}

// 分析結果の例
const dependencies: ModuleDependency[] = [
  {
    fromModule: 'order',
    toModule: 'inventory',
    importCount: 8,
    symbolCount: 12,
    isCircular: true,     // 問題!
    couplingStrength: 'strong',
  },
  {
    fromModule: 'order',
    toModule: 'user',
    importCount: 3,
    symbolCount: 3,
    isCircular: false,
    couplingStrength: 'moderate',
  },
];

データベース依存関係のマッピング

モノリスでは通常、すべてのモジュールが1つのデータベースを共有しています。この共有状態を分析することが、サービス分割の鍵です。

テーブル間の参照関係

-- 外部キーによる参照関係を分析
-- PostgreSQLの例
SELECT
  tc.table_name AS source_table,
  ccu.table_name AS referenced_table,
  tc.constraint_name,
  kcu.column_name AS fk_column,
  ccu.column_name AS referenced_column
FROM
  information_schema.table_constraints AS tc
  JOIN information_schema.key_column_usage AS kcu
    ON tc.constraint_name = kcu.constraint_name
  JOIN information_schema.constraint_column_usage AS ccu
    ON ccu.constraint_name = tc.constraint_name
WHERE
  tc.constraint_type = 'FOREIGN KEY'
ORDER BY
  tc.table_name;
// データベース依存関係の分析結果
interface DatabaseDependency {
  sourceTable: string;
  targetTable: string;
  foreignKeys: string[];
  joinQueriesCount: number;   // このJOINが使われているクエリの数
  belongsToModule: string;    // どのモジュールが主に使用しているか
}

// テーブルの所属分析
const tableDependencies: DatabaseDependency[] = [
  {
    sourceTable: 'orders',
    targetTable: 'users',
    foreignKeys: ['user_id'],
    joinQueriesCount: 45,
    belongsToModule: 'order',
  },
  {
    sourceTable: 'order_items',
    targetTable: 'products',
    foreignKeys: ['product_id'],
    joinQueriesCount: 32,
    belongsToModule: 'order',  // products テーブルは inventory モジュール
  },
  {
    sourceTable: 'payments',
    targetTable: 'orders',
    foreignKeys: ['order_id'],
    joinQueriesCount: 28,
    belongsToModule: 'payment',
  },
];

テーブルアクセスパターンの分析

// どのモジュールがどのテーブルを読み書きしているかを分析
interface TableAccessPattern {
  table: string;
  readModules: string[];     // 読み取りしているモジュール
  writeModules: string[];    // 書き込みしているモジュール
  isSharedWrite: boolean;    // 複数モジュールが書き込みしている(問題)
}

const accessPatterns: TableAccessPattern[] = [
  {
    table: 'users',
    readModules: ['user', 'order', 'payment', 'notification', 'report'],
    writeModules: ['user'],
    isSharedWrite: false,     // OK: user モジュールだけが書き込み
  },
  {
    table: 'orders',
    readModules: ['order', 'inventory', 'payment', 'report'],
    writeModules: ['order', 'payment'],  // 問題: 2つのモジュールが書き込み
    isSharedWrite: true,
  },
  {
    table: 'products',
    readModules: ['inventory', 'order', 'report', 'search'],
    writeModules: ['inventory', 'order'],  // 問題: orderが直接productを更新
    isSharedWrite: true,
  },
];

共有ライブラリの識別

共有コードの分類

// 共有コードの種類と対処方針
interface SharedCode {
  path: string;
  type: 'utility' | 'domain-model' | 'infrastructure' | 'cross-cutting';
  usedByModules: string[];
  recommendation: string;
}

const sharedCode: SharedCode[] = [
  {
    path: 'src/shared/utils/dateUtils.ts',
    type: 'utility',
    usedByModules: ['order', 'inventory', 'report'],
    recommendation: '共有ライブラリとしてパッケージ化',
  },
  {
    path: 'src/shared/models/Money.ts',
    type: 'domain-model',
    usedByModules: ['order', 'payment', 'inventory'],
    recommendation: '各サービスにコピーして独立管理',
  },
  {
    path: 'src/shared/database/connection.ts',
    type: 'infrastructure',
    usedByModules: ['*'],
    recommendation: 'サービスごとに独自の接続管理に変更',
  },
  {
    path: 'src/shared/middleware/auth.ts',
    type: 'cross-cutting',
    usedByModules: ['*'],
    recommendation: 'API Gatewayまたは認証サービスに移行',
  },
];

循環依存の検出

循環依存はモノリスの分割を困難にする最大の障壁の一つです。

// 循環依存の検出アルゴリズム(深さ優先探索)
class CircularDependencyDetector {
  private graph: Map<string, Set<string>> = new Map();
  private cycles: string[][] = [];

  addDependency(from: string, to: string): void {
    if (!this.graph.has(from)) this.graph.set(from, new Set());
    this.graph.get(from)!.add(to);
  }

  detectCycles(): string[][] {
    const visited = new Set<string>();
    const recursionStack = new Set<string>();
    const path: string[] = [];

    for (const node of this.graph.keys()) {
      if (!visited.has(node)) {
        this.dfs(node, visited, recursionStack, path);
      }
    }

    return this.cycles;
  }

  private dfs(
    node: string,
    visited: Set<string>,
    recursionStack: Set<string>,
    path: string[],
  ): void {
    visited.add(node);
    recursionStack.add(node);
    path.push(node);

    const neighbors = this.graph.get(node) || new Set();
    for (const neighbor of neighbors) {
      if (!visited.has(neighbor)) {
        this.dfs(neighbor, visited, recursionStack, path);
      } else if (recursionStack.has(neighbor)) {
        // 循環を検出
        const cycleStart = path.indexOf(neighbor);
        this.cycles.push([...path.slice(cycleStart), neighbor]);
      }
    }

    path.pop();
    recursionStack.delete(node);
  }
}

// 使用例
const detector = new CircularDependencyDetector();
detector.addDependency('OrderService', 'InventoryService');
detector.addDependency('InventoryService', 'OrderService'); // 循環!
detector.addDependency('OrderService', 'PaymentService');

const cycles = detector.detectCycles();
// [['OrderService', 'InventoryService', 'OrderService']]

循環依存の解消パターン

パターン方法適用場面
インターフェース抽出共通インターフェースを上位に定義双方向の依存を一方向に
イベント駆動直接呼び出しをイベント発行に変更非同期で十分な場合
仲介者パターン中間サービスを導入複雑な依存の解消
依存の逆転依存方向を設計し直すアーキテクチャレベルの改善
// Before: 循環依存
// OrderService → InventoryService → OrderService

// After: イベント駆動で解消
class OrderService {
  constructor(
    private eventBus: EventBus,
    private inventoryPort: InventoryPort, // インターフェースへの依存
  ) {}

  async createOrder(dto: CreateOrderDto): Promise<Order> {
    const available = await this.inventoryPort.checkStock(dto.items);
    const order = await this.orderRepo.save(new Order(dto));
    // 直接呼び出しの代わりにイベントを発行
    await this.eventBus.publish(new OrderCreatedEvent(order));
    return order;
  }
}

// InventoryServiceはOrderServiceに直接依存しない
class InventoryEventHandler {
  constructor(private inventoryService: InventoryService) {}

  @OnEvent('order.created')
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.inventoryService.reserveStock(event.order.items);
  }
}

変更結合分析(Change Coupling)

一緒に変更されることが多いファイルは、論理的に結合しています。

# Gitログから一緒に変更されるファイルのペアを分析
# 過去6ヶ月のコミットを分析

git log --since="6 months ago" --pretty=format:'%H' | while read commit; do
  git diff-tree --no-commit-id --name-only -r "$commit"
  echo "---"
done
// 変更結合度の分析
interface ChangeCoupling {
  file1: string;
  file2: string;
  coChangeCount: number;        // 一緒に変更された回数
  file1TotalChanges: number;    // file1の総変更回数
  file2TotalChanges: number;    // file2の総変更回数
  couplingStrength: number;     // coChangeCount / min(total1, total2)
}

// 分析結果例
const changeCouplings: ChangeCoupling[] = [
  {
    file1: 'src/services/OrderService.ts',
    file2: 'src/services/InventoryService.ts',
    coChangeCount: 35,
    file1TotalChanges: 50,
    file2TotalChanges: 40,
    couplingStrength: 0.875,  // 非常に強い結合!
  },
  {
    file1: 'src/models/Order.ts',
    file2: 'src/controllers/OrderController.ts',
    coChangeCount: 28,
    file1TotalChanges: 40,
    file2TotalChanges: 30,
    couplingStrength: 0.933,  // 同一モジュール内なので問題なし
  },
];

// モジュール間の変更結合は問題のサイン
// 同一モジュール内の変更結合は自然

依存関係マトリクスの作成

分析結果を統合して、サービス分割の判断材料となる依存関係マトリクスを作成します。

interface DependencyMatrix {
  modules: string[];
  // matrix[i][j] = モジュールiからモジュールjへの依存強度
  codeDepMatrix: number[][];        // コード依存
  dataDepMatrix: number[][];        // データ依存
  changeDepMatrix: number[][];      // 変更結合
  combinedMatrix: number[][];       // 総合評価
}

// 依存関係マトリクスの作成
function createDependencyMatrix(
  codeDeps: ModuleDependency[],
  dataDeps: DatabaseDependency[],
  changeDeps: ChangeCoupling[],
): DependencyMatrix {
  // 各依存を0-1のスケールに正規化して結合
  // 重み: コード依存 40%, データ依存 35%, 変更結合 25%
  const weights = { code: 0.4, data: 0.35, change: 0.25 };

  // ... マトリクス計算 ...

  return matrix;
}

// 結果の解釈
// ┌──────────────────────────────────────────┐
// │        注文  在庫  決済  ユーザー  通知   │
// │ 注文    -    0.8   0.7   0.3     0.2   │
// │ 在庫   0.7    -    0.1   0.2     0.1   │
// │ 決済   0.6   0.1    -    0.4     0.3   │
// │ ユーザー 0.2  0.1   0.3    -      0.2   │
// │ 通知   0.1   0.1   0.1   0.2      -    │
// └──────────────────────────────────────────┘
//
// 注文 ↔ 在庫: 0.8/0.7 -- 非常に強い双方向依存(分割困難)
// 注文 ↔ 決済: 0.7/0.6 -- 強い依存(慎重に分割)
// 通知 ↔ 他: 0.1-0.2 -- 弱い依存(分割しやすい候補)

まとめ

ポイント内容
静的分析ESLint、dependency-cruiser でルールベースの検証
import分析AST解析でモジュール間の依存強度を測定
DB依存分析テーブル参照関係とアクセスパターンの把握
循環依存検出と解消パターン(インターフェース、イベント駆動)
変更結合Gitログから論理的な結合度を測定
依存マトリクスコード・データ・変更の3軸で総合評価

チェックリスト

  • 静的分析ツールの役割と設定方法を理解した
  • importグラフからモジュール間の依存強度を分析できる
  • データベースの依存関係をマッピングできる
  • 循環依存の検出と解消パターンを理解した
  • 変更結合分析の方法を理解した
  • 依存関係マトリクスの作成と解釈ができる

次のステップへ

次は「移行判断のフレームワーク」を学びます。これまでの分析結果を踏まえて、モノリスからマイクロサービスへの移行を判断するための体系的なフレームワークを身につけましょう。


推定読了時間: 30分