【Java】依存性逆転の原則(DIP)で通知処理をメール・Slack・テスト用ダミーに差し替える

■概要

オブジェクト指向設計の原則集「SOLID」の "D" にあたる依存性逆転の原則(DIP: Dependency Inversion Principle)を学習用に実装する。

DIP は次の2点を主張する原則である。

  • 上位(高レベル)モジュールは、下位(低レベル)モジュールに依存してはならない。両者とも「抽象」に依存すべきである。
  • 「抽象」は「具体的な実装の詳細」に依存してはならない。「具体的な実装の詳細」が「抽象」に依存すべきである。

ざっくり言うと、「業務の流れを書くクラス」が「メール送信クラス」を直接名指しするのをやめて、間に interface(抽象)をはさみ、そこにお互いが依存するようにするということ。
本記事では「注文完了時に購入者へ通知する」業務フローを題材に、DIP に違反した NG 例と、DIP を守った OK 例を対比する。

■実装するサンプル処理の概要

以下の登場人物で、NG 例(ng パッケージ)と OK 例(ok パッケージ)を実装する。

  • ng.OrderCompleteService:高レベルモジュール。具象クラス EmailSendernew して直接使う(送信手段がメールに固定され、DIP 違反)
  • ng.EmailSender:低レベルモジュール。メール送信を担当する具象クラス
  • ok.MessageSender:「通知を送る」という役割だけを定義した抽象(インターフェース)
  • ok.EmailSender / ok.SlackSenderMessageSender を実装した具象クラス
  • ok.OrderCompleteService:高レベルモジュール。MessageSender だけに依存し、実物はコンストラクタで受け取る(依存性の注入 / DI)
  • DependencyInversionPrincipleMain:NG 例・OK 例を実行し、通知先をメール/Slack/テスト用ダミーへ差し替えて確認する

▼実行環境

  • Java:バージョン17

■クラス図

実装する処理のクラス図は以下の通り。
NG 例では ng.OrderCompleteService の矢印が具象クラス ng.EmailSender に向いている(高レベル→低レベルの依存)。一方 OK 例では、ok.OrderCompleteServiceok.EmailSender / ok.SlackSender も、すべて抽象 ok.MessageSender に矢印が向いており、依存の向きが"逆転"していることが分かる。

classDiagram direction LR class Main["DependencyInversionPrincipleMain"] { +main(String[] args) void } class NgOrderCompleteService["ng.OrderCompleteService"] { -EmailSender emailSender +completeOrder(String orderId) void } class NgEmailSender["ng.EmailSender"] { +sendEmail(String message) void } class OkOrderCompleteService["ok.OrderCompleteService"] { -MessageSender messageSender +OrderCompleteService(MessageSender messageSender) +completeOrder(String orderId) void } class MessageSender["ok.MessageSender"] { <<interface>> +send(String message) void } class OkEmailSender["ok.EmailSender"] { +send(String message) void } class SlackSender["ok.SlackSender"] { -String channel +SlackSender(String channel) +send(String message) void } NgOrderCompleteService --> NgEmailSender : 具象クラスに直接依存(NG・密結合) OkOrderCompleteService --> MessageSender : 抽象だけに依存(OK) OkEmailSender ..|> MessageSender : implements SlackSender ..|> MessageSender : implements Main ..> NgOrderCompleteService : 生成して実行 Main ..> OkOrderCompleteService : 実装を注入して実行

■フローチャート

DependencyInversionPrincipleMain#main の処理経路は以下の通り。
OK 例①〜③では ok.OrderCompleteService のソースを一切変更せずに、コンストラクタへ渡す実装(メール/Slack/ダミー)だけを差し替えている点がポイント。

flowchart TD A([開始]) --> B["【NG例】ng.OrderCompleteService を生成(内部で new EmailSender() が固定)"] B --> C["ngService.completeOrder(「A-001」):注文完了を出力し EmailSender でメール送信"] C --> D["【OK例①】EmailSender を生成し ok.OrderCompleteService にコンストラクタ注入(DI)"] D --> E["completeOrder(「B-100」) → MessageSender 経由でメール通知"] E --> F["【OK例②】SlackSender(「orders」) を生成して注入(Service のコードは無変更)"] F --> G["completeOrder(「B-101」) → MessageSender 経由で Slack 通知"] G --> H["【OK例③】ラムダ式でテスト用ダミー実装を生成して注入"] H --> I["completeOrder(「B-102」) → ダミーが送信内容を記録するだけ"] I --> J[まとめメッセージを出力] J --> K([終了])

■サンプルコード

以下に今回実装するサンプルコードを示す。

【NG例】具象クラスに直接依存しているコード

・src/main/java/ng/EmailSender.java

package ng;

/**
 * 【NG例】メール送信を担当する「低レベルモジュール(=具体的な処理の担当者)」。
 *
 * <p>「低レベル」とは "レベルが低い=ダメ" という意味ではなく、
 * 「実際の細かい処理(メールを送る、DBに保存する等)を行う部品」という意味です。
 * 逆に「高レベルモジュール」は、それらの部品を使って業務の流れを組み立てる側を指します。
 */
public class EmailSender {

  /**
   * メールを送信する(ここでは学習用に、送った内容をコンソールに表示するだけ)。
   *
   * @param message 送りたい本文
   */
  public void sendEmail(String message) {
    System.out.println("[メール送信] " + message);
  }
}

・src/main/java/ng/OrderCompleteService.java

package ng;

/**
 * 【NG例】注文完了の業務フローを担当する「高レベルモジュール」。
 *
 * <p>問題点:このクラスは {@link EmailSender} という「具体的なクラス」に直接依存しています。
 * <ul>
 *   <li>クラスの内部で {@code new EmailSender()} していて、送信手段が「メール」に固定されている。</li>
 *   <li>あとから「Slackで通知したい」となったら、このクラス自体を書き換える必要がある。</li>
 *   <li>テストのときも本物の EmailSender が動いてしまい、差し替えができない。</li>
 * </ul>
 * これが「高レベルモジュールが低レベルモジュールに依存している」状態で、
 * 依存性逆転の原則(DIP)に違反しているコードです。
 */
public class OrderCompleteService {

  // 具体的なクラスを名指しで持ってしまっている(=密結合)。
  private final EmailSender emailSender = new EmailSender();

  /**
   * 注文を完了し、購入者へ通知する。
   *
   * @param orderId 注文番号
   */
  public void completeOrder(String orderId) {
    // 本来ここに「在庫を減らす」「注文ステータスを更新する」等の業務処理が入る想定。
    System.out.println("注文 " + orderId + " を完了しました。");

    // 通知手段が EmailSender に固定されている。ここを Slack に変えたい場合、
    // このクラスのソースコードを直接修正するしかない。
    emailSender.sendEmail("ご注文 " + orderId + " を承りました。");
  }
}

【OK例】抽象(interface)に依存し、実装を注入するコード

・src/main/java/ok/MessageSender.java

package ok;

/**
 * 【OK例】「通知を送る」という役割だけを定義した抽象(インターフェース)。
 *
 * <p>DIP のポイントは「具体的なクラスではなく、この抽象に依存させること」です。
 * <ul>
 *   <li>高レベルモジュール({@link OrderCompleteService})は、この {@code MessageSender} だけを知っていればよい。</li>
 *   <li>低レベルモジュール({@link EmailSender} / {@link SlackSender})は、この抽象を「実装する」側にまわる。</li>
 * </ul>
 * こうすると、両者が矢印の向きとして「抽象」を指すようになり、
 * 高レベル→低レベル だった依存の向きが "逆転" します。これが「依存性逆転」の名前の由来です。
 */
public interface MessageSender {

  /**
   * 通知を1件送信する。
   *
   * @param message 送りたい本文
   */
  void send(String message);
}

・src/main/java/ok/EmailSender.java

package ok;

/**
 * 【OK例】メールで通知する具体的な実装(低レベルモジュール)。
 *
 * <p>{@link MessageSender} を implements しているので、
 * 「MessageSender が欲しい場所」ならどこにでも渡せる。
 */
public class EmailSender implements MessageSender {

  @Override
  public void send(String message) {
    System.out.println("[メール送信] " + message);
  }
}

・src/main/java/ok/SlackSender.java

package ok;

/**
 * 【OK例】Slackで通知する具体的な実装(低レベルモジュール)。
 *
 * <p>ポイント:この新しい送信手段を追加しても、
 * 高レベルモジュール({@link OrderCompleteService})は一切変更しなくてよい。
 * 「機能追加のときに既存コードを触らなくて済む」のが DIP の実利です。
 */
public class SlackSender implements MessageSender {

  /** 投稿先チャンネル名。コンストラクタで受け取り、送信先を柔軟に変えられるようにしている。 */
  private final String channel;

  public SlackSender(String channel) {
    this.channel = channel;
  }

  @Override
  public void send(String message) {
    System.out.println("[Slack投稿] #" + channel + " : " + message);
  }
}

・src/main/java/ok/OrderCompleteService.java

package ok;

/**
 * 【OK例】注文完了の業務フローを担当する「高レベルモジュール」。
 *
 * <p>NG例との違いは1点だけ:
 * <b>具体的なクラス(EmailSender など)ではなく、抽象 {@link MessageSender} に依存している</b>こと。
 *
 * <ul>
 *   <li>{@code new} で送信手段を作らず、コンストラクタで「外から受け取る」(=依存性の注入 / DI)。</li>
 *   <li>メール・Slack・テスト用のダミーなど、何を渡すかは呼び出し側の自由。</li>
 *   <li>このクラスは「通知する」という約束(インターフェース)だけを知っていればよい。</li>
 * </ul>
 */
public class OrderCompleteService {

  // 抽象への参照だけを持つ。中身が何なのか(メールかSlackか)はこのクラスは知らない。
  private final MessageSender messageSender;

  /**
   * @param messageSender 使いたい通知手段。呼び出し側が決めて渡す(コンストラクタ・インジェクション)。
   */
  public OrderCompleteService(MessageSender messageSender) {
    this.messageSender = messageSender;
  }

  /**
   * 注文を完了し、購入者へ通知する。
   *
   * @param orderId 注文番号
   */
  public void completeOrder(String orderId) {
    // 本来ここに「在庫を減らす」「注文ステータスを更新する」等の業務処理が入る想定。
    System.out.println("注文 " + orderId + " を完了しました。");

    // 実際の送信手段が何であっても、呼び出し方は同じ1行で済む。
    messageSender.send("ご注文 " + orderId + " を承りました。");
  }
}

【実行クラス】NG例・OK例を動かすmain

・src/main/java/DependencyInversionPrincipleMain.java

import ok.EmailSender;
import ok.MessageSender;
import ok.SlackSender;

/**
 * 依存性逆転の原則(DIP: Dependency Inversion Principle)の学習用サンプル。
 *
 * <p>DIP はオブジェクト指向設計の原則集「SOLID」の "D" にあたるもので、内容は次の2つです。
 * <ol>
 *   <li>上位(高レベル)のモジュールは、下位(低レベル)のモジュールに依存してはならない。
 *       両者とも「抽象」に依存すべきである。</li>
 *   <li>「抽象」は「具体的な実装の詳細」に依存してはならない。
 *       「具体的な実装の詳細」が「抽象」に依存すべきである。</li>
 * </ol>
 *
 * <p>ざっくり言うと:
 * <br>「業務の流れを書くクラス」が「メール送信クラス」を直接名指しするのをやめて、
 * 間に {@code interface}(抽象)をはさみ、そこにお互いが依存するようにする、ということです。
 *
 * <h2>このサンプルの登場人物</h2>
 * <pre>
 *   ng パッケージ … DIP に違反した「悪い例」
 *     OrderCompleteService  → EmailSender を new して直接使う(送信手段がメールに固定)
 *
 *   ok パッケージ … DIP を守った「良い例」
 *     MessageSender(interface) … 「通知を送る」という抽象
 *     EmailSender / SlackSender … MessageSender を実装した具体クラス
 *     OrderCompleteService … MessageSender だけに依存し、実物は外から受け取る
 * </pre>
 */
public class DependencyInversionPrincipleMain {

  public static void main(String[] args) {

    System.out.println("========== NG例:具体クラスに直接依存している ==========");
    // 送信手段を選ぶ余地がない。内部で new EmailSender() が固定されている。
    ng.OrderCompleteService ngService = new ng.OrderCompleteService();
    ngService.completeOrder("A-001");
    System.out.println("→ Slackで通知したくなったら OrderCompleteService の中身を書き換えるしかない。");

    System.out.println();
    System.out.println("========== OK例:抽象(MessageSender)に依存している ==========");

    // (1) まずはメールで通知する構成。
    //     「どの送信手段を使うか」を決めるのは "呼び出し側" である main の責務になる。
    MessageSender email = new EmailSender();
    ok.OrderCompleteService okServiceByEmail = new ok.OrderCompleteService(email);
    okServiceByEmail.completeOrder("B-100");

    System.out.println();

    // (2) 次は Slack で通知する構成。
    //     OrderCompleteService のソースは1文字も変えていないのに、通知先を差し替えられた。
    MessageSender slack = new SlackSender("orders");
    ok.OrderCompleteService okServiceBySlack = new ok.OrderCompleteService(slack);
    okServiceBySlack.completeOrder("B-101");

    System.out.println();

    // (3) おまけ:テスト用の「ダミー実装」もその場で渡せる(差し替え自由)。
    //     本物のメールを送らずに、呼ばれた内容だけを確認したい、というテストで役立つ。
    MessageSender fake = message -> System.out.println("[テスト用ダミー] 送信内容を記録しました: " + message);
    ok.OrderCompleteService okServiceForTest = new ok.OrderCompleteService(fake);
    okServiceForTest.completeOrder("B-102");

    System.out.println();
    System.out.println("【まとめ】");
    System.out.println("・高レベル(業務フロー)と低レベル(送信処理)の間に interface を置く。");
    System.out.println("・高レベルは interface だけを見る。実物は外から注入(DI)する。");
    System.out.println("・すると『機能追加・差し替え・テスト』のときに既存クラスを触らずに済む。");
  }
}

■実行結果

DependencyInversionPrincipleMain を実行すると、以下が出力される。

========== NG例:具体クラスに直接依存している ==========
注文 A-001 を完了しました。
[メール送信] ご注文 A-001 を承りました。
→ Slackで通知したくなったら OrderCompleteService の中身を書き換えるしかない。

========== OK例:抽象(MessageSender)に依存している ==========
注文 B-100 を完了しました。
[メール送信] ご注文 B-100 を承りました。

注文 B-101 を完了しました。
[Slack投稿] #orders : ご注文 B-101 を承りました。

注文 B-102 を完了しました。
[テスト用ダミー] 送信内容を記録しました: ご注文 B-102 を承りました。

【まとめ】
・高レベル(業務フロー)と低レベル(送信処理)の間に interface を置く。
・高レベルは interface だけを見る。実物は外から注入(DI)する。
・すると『機能追加・差し替え・テスト』のときに既存クラスを触らずに済む。

NG 例(A-001)は必ずメールで通知される。送信先を Slack に変えるには ng.OrderCompleteService 自体を書き換えるしかない。
一方 OK 例では、ok.OrderCompleteService のコードを1文字も変えずに、B-100 はメール、B-101 は Slack、B-102 はテスト用ダミーへと通知先を差し替えられている。
このように、高レベルモジュールと低レベルモジュールの間に抽象(interface)を置き、実物を外から注入することで、機能追加・差し替え・テストのたびに既存クラスへ手を入れずに済むのが依存性逆転の原則の実利である。

コメント

タイトルとURLをコピーしました