Javaを鍛える|デザインパターン全23回 #23

Template Methodパターン|クローズ処理の「共通の流れ」を固定する

  • Java
  • デザインパターン
  • Template Method
  • Javaを鍛える

📋 この記事の目次
  1. このパターンは何のためにあるか
  2. Before
  3. After
  4. こんな場面で使う/使わない
  5. まとめ
  6. 🎓 理解度テスト

TicketCraftではチケットをクローズする際、ステータスを「closed」に変更し、クローズ日時を記録し、関係者に通知を送るという3ステップの処理が必ず行われます。しかしバグ報告チケットと機能要望チケットで通知文言やログ出力先が違うため、開発チームはswitch-caseによる分岐処理を各メソッドに散りばめて対応していました。ある日、新規チケット種別として「セキュリティ脆弱性」が追加されようとしたとき、既存コードの修正箇所が12箇所にも及ぶことが発覚しました。

このパターンは何のためにあるか

Template Methodパターンは、アルゴリズム全体の流れを固定しつつ、一部だけ後から変更可能なステップを抽象化しておくことで、コードの重複を防ぐ設計パターンです。毎朝同じルーティンでコーヒーを淹れるが、ミルクを入れるタイミングだけが人によって違う場合、「お湯を沸かす→コーヒー粉をセットする→抽出する→ミルクを加える」という手順は固定し、「ミルクを加える」ステップだけ各自の実装に任せるのと同じ考え方です。

Before

チケット種別ごとの条件分岐がメソッド内に散在している実装です。

import java.time.LocalDateTime;
import java.util.List;

class TicketCloser {

    void closeTicket(String ticketType, String ticketId, List<String> recipients) {
        // ステータスを更新
        updateStatus(ticketId, "closed");

        if ("bug".equals(ticketType)) {
            logClosureDate(LocalDateTime.now());
            notifyRecipients(recipients, "バグ報告チケットがクローズされました: " + ticketId);
        } else if ("feature".equals(ticketType)) {
            logFeatureClosureDate(LocalDateTime.now());
            notifyFeatureRecipients(recipients, "機能要望チケットがクローズされました: " + ticketId);
        } else if ("security".equals(ticketType)) {
            logSecurityClosureDate(LocalDateTime.now());
            notifySecurityTeam(recipients, "セキュリティ脆弱性チケットがクローズされました: " + ticketId);
        }

        // 共通処理(種別に関わらず実行)
        archiveTicket(ticketId);
    }

    private void updateStatus(String ticketId, String status) {
        System.out.println("チケット[" + ticketId + "]のステータスを" + status + "に変更");
    }

    private void logClosureDate(LocalDateTime now) {
        System.out.println("[LOG] クローズ日時: " + now);
    }

    private void logFeatureClosureDate(LocalDateTime now) {
        System.out.println("[FEATURE-LOG] 機能要望クローズ日時: " + now);
    }

    private void logSecurityClosureDate(LocalDateTime now) {
        System.out.println("[SECURITY-LOG] セキュリティクローズ日時: " + now);
    }

    private void notifyRecipients(List<String> recipients, String message) {
        for (String r : recipients) {
            System.out.println("通知送信(" + r + "): " + message);
        }
    }

    private void notifyFeatureRecipients(List<String> recipients, String message) {
        for (String r : recipients) {
            System.out.println("[FEATURE-MSG] 機能要望通知(" + r + "): " + message);
        }
    }

    private void notifySecurityTeam(List<String> recipients, String message) {
        for (String r : recipients) {
            System.out.println("[SECURITY-MSG] セキュリティチーム通知(" + r + "): " + message);
        }
    }

    private void archiveTicket(String ticketId) {
        System.out.println("チケット[" + ticketId + "]をアーカイブ");
    }
}

public class BeforeMain {
    public static void main(String[] args) {
        TicketCloser closer = new TicketCloser();
        closer.closeTicket("bug", "BUG-1042", List.of("田中", "佐藤"));
        System.out.println("---");
        closer.closeTicket("feature", "FEAT-378", List.of("鈴木", "山本"));
    }
}

実行結果は以下の通りです。

チケット[BUG-1042]のステータスをclosedに変更
[LOG] クローズ日時: 2026-...
通知送信(田中): バグ報告チケットがクローズされました: BUG-1042
通知送信(佐藤): バグ報告チケットがクローズされました: BUG-1042
チケット[BUG-1042]をアーカイブ
---
チケット[FEAT-378]のステータスをclosedに変更
[FEATURE-LOG] 機能要望クローズ日時: 2026-...
[FEATURE-MSG] 機能要望通知(鈴木): 機能要望チケットがクローズされました: FEAT-378
[FEATURE-MSG] 機能要望通知(山本): 機能要望チケットがクローズされました: FEAT-378
チケット[FEAT-378]をアーカイブ

この実装には問題があります。チケット種別が増えるたびにcloseTicketメソッドのif文が延び、ネストも深くなります。共通処理であるステータス更新やアーカイブ処理は種別に関係なく1回しか実行されないのに、種別ごとのログ・通知処理は個別のメソッドとして並び、修正漏れのリスクが高まります。

After

Template Methodパターンを適用した実装では、チケットクローズの骨格となるcloseTicketメソッドを抽象クラスに定義し、種別固有の処理だけをサブクラスで実装します。

import java.time.LocalDateTime;
import java.util.List;

abstract class TicketCloser {

    void closeTicket(String ticketId, List<String> recipients) {
        // 共通ステップ1: ステータス更新
        updateStatus(ticketId, "closed");

        // 種別固有の処理(抽象メソッド)
        logClosureInfo(LocalDateTime.now());
        sendClosureNotification(ticketId, recipients);

        // 共通ステップ2: アーカイブ
        archiveTicket(ticketId);
    }

    private void updateStatus(String ticketId, String status) {
        System.out.println("チケット[" + ticketId + "]のステータスを" + status + "に変更");
    }

    protected abstract void logClosureInfo(LocalDateTime now);

    protected abstract void sendClosureNotification(String ticketId, List<String> recipients);

    private void archiveTicket(String ticketId) {
        System.out.println("チケット[" + ticketId + "]をアーカイブ");
    }
}

class BugReportCloser extends TicketCloser {
    @Override
    protected void logClosureInfo(LocalDateTime now) {
        System.out.println("[LOG] クローズ日時: " + now);
    }

    @Override
    protected void sendClosureNotification(String ticketId, List<String> recipients) {
        for (String r : recipients) {
            System.out.println("通知送信(" + r + "): バグ報告チケットがクローズされました: " + ticketId);
        }
    }
}

class FeatureRequestCloser extends TicketCloser {
    @Override
    protected void logClosureInfo(LocalDateTime now) {
        System.out.println("[FEATURE-LOG] 機能要望クローズ日時: " + now);
    }

    @Override
    protected void sendClosureNotification(String ticketId, List<String> recipients) {
        for (String r : recipients) {
            System.out.println("[FEATURE-MSG] 機能要望通知(" + r + "): 機能要望チケットがクローズされました: " + ticketId);
        }
    }
}

public class AfterMain {
    public static void main(String[] args) {
        TicketCloser closer = new BugReportCloser();
        closer.closeTicket("BUG-1042", List.of("田中", "佐藤"));
        System.out.println("---");

        closer = new FeatureRequestCloser();
        closer.closeTicket("FEAT-378", List.of("鈴木", "山本"));
    }
}

実行結果は以下の通りです(Beforeと同じ内容が、種別固有クラスに切り出された状態で得られます)。

チケット[BUG-1042]のステータスをclosedに変更
[LOG] クローズ日時: 2026-...
通知送信(田中): バグ報告チケットがクローズされました: BUG-1042
通知送信(佐藤): バグ報告チケットがクローズされました: BUG-1042
チケット[BUG-1042]をアーカイブ
---
チケット[FEAT-378]のステータスをclosedに変更
[FEATURE-LOG] 機能要望クローズ日時: 2026-...
[FEATURE-MSG] 機能要望通知(鈴木): 機能要望チケットがクローズされました: FEAT-378
[FEATURE-MSG] 機能要望通知(山本): 機能要望チケットがクローズされました: FEAT-378
チケット[FEAT-378]をアーカイブ

実装の重複が解消され、closeTicketメソッド内で種別ごとの分岐が完全に消えました。チケットクローズ処理の変更が必要なときはlogClosureInfosendClosureNotificationの実装だけを書けばよく、共通フローそのものを間違えるリスクが排除されます。新規種別を追加する際も既存コードへの修正は不要で、新しいサブクラスを1つ追加するだけで完結します。

🧓 ベテランの現場コラム Template Methodパターンで抽象メソッドの名前を一貫した命名規則にすると、後からコードを見た開発者がどのステップが何をしているか短時間で把握できます。「共通の流れを固定する」パターンだからこそ、その骨格自体が読みやすいことが重要です。

こんな場面で使う/使わない

  • 使う: 同じアルゴリズムの骨格があるが一部だけ異なる処理を実装したい場合(チケット種別ごとのクローズ処理など)
  • 使わない: フローそのものも大きく変わる場合。サブクラスが共通スーパークラスを知らなくても済む単純なポリモーフィズムで十分な場合

まとめ

  • Template Methodパターンはアルゴリズムの骨格を固定し、一部だけサブクラスに委譲する設計パターン
  • TicketCraftのチケットクローズ処理では、switch-caseによる重複分岐がTemplate Methodで解消された
  • 新規種別追加時の修正箇所が最小限になるため、保守性の向上とリリースリスクの低下に寄与する

🎓 理解度テスト

学んだ内容を確認しましょう。全3問。