ソフトウェア設計

【モジュール化 #2】モジュール依存関係の設計と循環依存の断ち方

前回は、モジュール化を「一緒に変わるものをまとめて境界を作ること」と整理しました。

読了 5 分
【モジュール化 #2】モジュール依存関係の設計と循環依存の断ち方のカバー画像

前回は、モジュール化を「一緒に変わるものをまとめて境界を作ること」と整理しました。

しかし、モジュールを分割してから本当の問題が始まります。分けた断片同士が参照し始めるからです。

AがBを使い、BがCを使い、ある日Cが再びAを使う。その瞬間、モジュール化の利点はすべて消えます。3つが実質的に1つの塊になるからです。

結論から言えば、モジュール依存関係の原則は2つです。依存方向を一方向にし、変更頻度の高い側が安定した側に依存するようにします。

この記事では、依存方向の決め方、循環依存が生じる典型的な経路、断ち切る3つの方法を整理します。


依存方向はどちらに流すべきか

依存関係には方向があります。モジュールAがモジュールBをimportすると、AはBに依存します。

矢印をどちらに向けるべきかには、古典的な答えがあります。

不安定なものは安定したものに依存させる。逆にすると、小さな変更がシステム全体に波及します。

安定したモジュールとは、頻繁には変わらないモジュールです。ドメインモデルや共通プロトコルなどが該当します。

不安定なモジュールは頻繁に変わるモジュールです。画面(UI)や、要件に応じて手を入れ続けるイベント対応ロジックなどです。

そのため、健全な依存グラフはおおむね次の方向になります。

  • 画面・機能モジュール → ドメインモジュール → 共通インターフェースモジュール
  • 矢印は安定した側へ下向きにだけ流れ、上流へは戻りません。

Robert C. Martinはこれを安定依存の原則(SDP、Stable Dependencies Principle)と呼びました。


循環依存はなぜ生じるのか

循環依存は、悪意なくごく自然に生じることがほとんどです。典型的な経路は次のとおりです。

OrderモジュールがUserモジュールを参照します。注文に注文者情報が必要だからです。

やがて会員画面に「最近の注文一覧」を表示したいという要件が出ます。UserがOrderを参照した瞬間、循環が完成します。

OrderとUserが互いを呼び出すと、このようにループが完成します。
OrderとUserが互いを呼び出すと、このようにループが完成します。

循環が生じると、3つのものが崩れます。

  1. ビルド単位の分離に失敗:コンパイラーが別々に処理できず、実質的に1モジュールになります(Swiftはモジュール間の循環importをコンパイルエラーで禁止します)。
  2. 独立したテストができない:AのテストにはBが必要で、BのテストにはAが必要になるデッドロックです。
  3. 影響範囲を予測できない:どちらを直しても反対側まで再確認が必要になります。

循環依存はどう断ち切るか

実務で使う方法は大きく3つあります。

1つ目は、共通部分を下位へ移すことです。

双方が必要としているのが実は「相手の一部」なら、その部分をより安定した下位モジュールへ抽出します。OrderとUserが必要とする型だけをドメインモデルモジュールへ移すイメージです。

2つ目は、インターフェースで方向を逆転すること(DIP)です。

一方の依存をプロトコルに置き換えます。Userモジュールは「注文一覧を提供する何か」というプロトコルだけを定義し、実装はOrderモジュールが担います。

// User モジュール:必要な能力だけをプロトコルとして宣言
public protocol OrderHistoryProviding {
    func recentOrders(of userID: String) -> [OrderSummary]
}

// Order モジュール: User モジュールのプロトコルを採用して実装
public struct OrderHistoryProvider: OrderHistoryProviding {
    public func recentOrders(of userID: String) -> [OrderSummary] {
        // 注文リポジトリを検索
    }
}

これで矢印はOrder → Userの一方向だけになります。前回のDIP記事で扱った依存性逆転が、モジュール単位でそのまま使われています。

3つ目は、組み立てを上位へ移すことです。

2つのモジュールを直接つながず、両方を知る上位モジュール(アプリターゲットや組み立て層)が接続します。画面遷移など、機能モジュール同士が互いを呼ぶ必要がある場面で特に有効です。

状況 断ち切る方法
互いの型の一部だけが必要 共通型を下位モジュールへ抽出
一方が他方の機能を呼び出す プロトコルを定義して依存性を逆転
機能モジュール間の画面遷移 上位の組み立て層で接続

いつ適用し、いつ過剰になるのか

依存方向の管理にもコストがかかります。プロトコルが増え、組み立てコードも必要になるからです。

  • モジュールが3〜4個以上でチームも分かれているなら、方向のルールを文書化し、ツールで循環を監視する価値があります。
  • 2モジュールの小規模プロジェクトなら、循環さえ作らなければ十分です。すべての参照をプロトコルで包むのは過剰です。
  • すでに循環のあるレガシーなら、一度にすべてを断とうとせず、最も頻繁に変わるモジュールから矢印を整理しましょう。

面接ではこう聞かれます

Q. モジュール間の循環依存はなぜ問題で、どう解決しますか?

循環が生じると、ビルド・テスト・デプロイの観点で2つのモジュールが一体化し、モジュール化の利点が失われます。共通型を下位モジュールへ抽出する、プロトコルで依存方向を逆転する(DIP)、上位の組み立て層で2つを接続する、という方法で矢印を一方向に整理します。

Q. 安定依存の原則(SDP)を説明してください。

モジュールは、自分より安定した、つまり変更の少ないモジュールだけに依存すべきだという原則です。変更の多いモジュールが依存グラフの下流にあると、その変更が上流全体へ伝播するため、変更頻度の低いドメイン・インターフェースモジュールをグラフの下側に置く構造が健全です。

矢印を1本断つだけでも、会議が必要なことはありますよね。
矢印を1本断つだけでも、会議が必要なことはありますよね。

次回は、多くのチームがモジュール化の途中で陥る罠、名前からして危険なCommonモジュールについて扱います。

「とりあえず共通だからCommonに入れよう」がモジュール全体を再び1つの塊へ戻す仕組みと、レイヤー構造での予防策を解説します。