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

Stateパターン|ステータスごとの「できる操作」をクラスで表現する

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

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

TicketCraftでは、チケットのステータスごとに異なる操作を行えるようにする必要がありました。未着手状態なら詳細編集や担当者の割り当てが可能ですが、対応中になれば「開始日の更新」のみ許可され、解決済みではレビュー依頼を送るだけができます。クローズ済みのチケットは見るだけで、一切の変更ができません。これらのルールをTicketクラスにif文で羅列して実装したところ、ステータスが5つに増えた段階で条件分岐が200行を超える規模になり、新規メンバーのコードリーディングに数時間を要する事態が生じていました。

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

Stateパターンは、オブジェクトの内部状態によって振る舞いを変えたい場合に、条件分岐を各メソッドから分離して状態ごとのクラスとして表現するための設計パターンです。同じボタンでも「登録中」と「完了済み」では表示内容もクリック時の挙動も変わるように、状態が変われば処理の流れ自体が入れ替わる場面で威力を発揮します。

Before

public class Ticket {
    private String id;
    private String summary;
    private String status;
    private String assignee;
    private String startDate;

    public Ticket(String id, String summary) {
        this.id = id;
        this.summary = summary;
        this.status = "未着手";
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public void editContent(String content) {
        if (status.equals("未着手") || status.equals("対応中")) {
            summary = content;
            System.out.println(summary + " を更新しました");
        } else {
            System.out.println(status + " のチケットは編集できません");
        }
    }

    public void assignTo(String name) {
        if (status.equals("未着手")) {
            assignee = name;
            System.out.println(name + " に担当を割り当てました");
        } else {
            System.out.println(status + " のチケットには担当者を割り当てられません");
        }
    }

    public void startWork() {
        if (status.equals("未着手")) {
            setStatus("対応中");
            startDate = "2025-01-10";
            System.out.println("作業を開始しました(開始日: " + startDate + ")");
        } else {
            System.out.println(status + " 状態では作業を開始できません");
        }
    }

    public void updateStartDate(String date) {
        if (status.equals("対応中")) {
            startDate = date;
            System.out.println("開始日を更新しました: " + date);
        } else {
            System.out.println(status + " のチケットには開始日の更新はできません");
        }
    }

    public void sendReviewRequest(String reviewer) {
        if (status.equals("解決済み")) {
            System.out.println(reviewer + " にレビュー依頼を送信しました");
        } else {
            System.out.println(status + " のチケットにはレビュー依頼は送れません");
        }
    }
}

public class BeforeMain {
    public static void main(String[] args) {
        Ticket ticket = new Ticket("T-001", "ログイン画面の表示不具合");

        System.out.println("--- 未着手状態 ---");
        ticket.editContent("ログイン画面の表示不具合修正");
        ticket.assignTo("佐藤");
        ticket.startWork();

        System.out.println("--- 対応中状態 ---");
        ticket.updateStartDate("2025-01-15");
        ticket.editContent("内容変更");

        ticket.setStatus("解決済み");
        System.out.println("--- 解決済み状態 ---");
        ticket.sendReviewRequest("田中");

        ticket.setStatus("クローズ");
        System.out.println("--- クローズ状態 ---");
        ticket.editContent("内容変更");
    }
}

実行結果は以下の通りです。「対応中」でもeditContentが通ってしまう点に注目してください。

--- 未着手状態 ---
ログイン画面の表示不具合修正 を更新しました
佐藤 に担当を割り当てました
作業を開始しました(開始日: 2025-01-10)
--- 対応中状態 ---
開始日を更新しました: 2025-01-15
内容変更 を更新しました
--- 解決済み状態 ---
田中 にレビュー依頼を送信しました
--- クローズ状態 ---
クローズ のチケットは編集できません

このBefore実装の問題は3点あります。1つ目は、Ticketクラスの各メソッドの中にステータス判定のif文が散在しており、状態が増えるたびに全てのメソッドをチェックする必要があることです。2つ目は、振る舞いの定義がTicketという1つのクラスに集中しており、「クローズ状態のチケットは更新できない」というルールをコードだけで追うのが困難なことです。3つ目は、ステータスを表すstatusが単なる文字列のため、"未着手""未対応"のような表記ゆれをコンパイラが検知できないことです。

After

public class AfterMain {
    public static void main(String[] args) {
        TicketContext ticket = new TicketContext("T-001", "ログイン画面の表示不具合");

        System.out.println("--- 未着手状態 ---");
        ticket.editContent("ログイン画面の表示不具合修正");
        ticket.assignTo("佐藤");
        ticket.startWork();

        System.out.println("--- 対応中状態 ---");
        ticket.updateStartDate("2025-01-15");
        ticket.editContent("内容変更");

        ticket.setState(new ResolvedState());
        System.out.println("--- 解決済み状態 ---");
        ticket.sendReviewRequest("田中");

        ticket.setState(new ClosedState());
        System.out.println("--- クローズ状態 ---");
        ticket.editContent("内容変更");
    }
}

interface TicketState {
    void editContent(TicketContext ctx, String content);
    void assignTo(TicketContext ctx, String name);
    void startWork(TicketContext ctx);
    void updateStartDate(TicketContext ctx, String date);
    void sendReviewRequest(TicketContext ctx, String reviewer);
}

class UnstartedState implements TicketState {
    @Override
    public void editContent(TicketContext ctx, String content) {
        ctx.setSummary(content);
        System.out.println(ctx.getSummary() + " を更新しました");
    }

    @Override
    public void assignTo(TicketContext ctx, String name) {
        ctx.setAssignee(name);
        System.out.println(name + " に担当を割り当てました");
    }

    @Override
    public void startWork(TicketContext ctx) {
        ctx.setState(new WorkingInProgressState());
        ctx.setStartDate("2025-01-10");
        System.out.println("作業を開始しました(開始日: " + ctx.getStartDate() + ")");
    }

    @Override
    public void updateStartDate(TicketContext ctx, String date) {
        System.out.println("未着手状態では開始日の更新はできません");
    }

    @Override
    public void sendReviewRequest(TicketContext ctx, String reviewer) {
        System.out.println("未着手状態ではレビュー依頼を送れません");
    }
}

class WorkingInProgressState implements TicketState {
    @Override
    public void editContent(TicketContext ctx, String content) {
        System.out.println("対応中状態では内容編集はできません");
    }

    @Override
    public void assignTo(TicketContext ctx, String name) {
        System.out.println("対応中状態では担当者の割り当てはできません");
    }

    @Override
    public void startWork(TicketContext ctx) {
        System.out.println("対応中状態では作業開始は完了しています");
    }

    @Override
    public void updateStartDate(TicketContext ctx, String date) {
        ctx.setStartDate(date);
        System.out.println("開始日を更新しました: " + date);
    }

    @Override
    public void sendReviewRequest(TicketContext ctx, String reviewer) {
        System.out.println("対応中状態ではレビュー依頼は送れません");
    }
}

class ResolvedState implements TicketState {
    @Override
    public void editContent(TicketContext ctx, String content) {
        System.out.println("解決済み状態では内容編集はできません");
    }

    @Override
    public void assignTo(TicketContext ctx, String name) {
        System.out.println("解決済み状態では担当者の割り当てはできません");
    }

    @Override
    public void startWork(TicketContext ctx) {
        System.out.println("解決済み状態では作業開始は完了しています");
    }

    @Override
    public void updateStartDate(TicketContext ctx, String date) {
        System.out.println("解決済み状態では開始日の更新はできません");
    }

    @Override
    public void sendReviewRequest(TicketContext ctx, String reviewer) {
        ctx.setReviewer(reviewer);
        System.out.println(reviewer + " にレビュー依頼を送信しました");
    }
}

class ClosedState implements TicketState {
    @Override
    public void editContent(TicketContext ctx, String content) {
        System.out.println("クローズ済み状態では内容編集はできません");
    }

    @Override
    public void assignTo(TicketContext ctx, String name) {
        System.out.println("クローズ済み状態では担当者の割り当てはできません");
    }

    @Override
    public void startWork(TicketContext ctx) {
        System.out.println("クローズ済み状態では作業開始は完了しています");
    }

    @Override
    public void updateStartDate(TicketContext ctx, String date) {
        System.out.println("クローズ済み状態では開始日の更新はできません");
    }

    @Override
    public void sendReviewRequest(TicketContext ctx, String reviewer) {
        System.out.println("クローズ済み状態ではレビュー依頼は送れません");
    }
}

class TicketContext {
    private String id;
    private String summary;
    private String assignee;
    private String startDate;
    private String reviewer;
    private TicketState state;

    public TicketContext(String id, String summary) {
        this.id = id;
        this.summary = summary;
        this.state = new UnstartedState();
    }

    public void editContent(String content) { state.editContent(this, content); }
    public void assignTo(String name) { state.assignTo(this, name); }
    public void startWork() { state.startWork(this); }
    public void updateStartDate(String date) { state.updateStartDate(this, date); }
    public void sendReviewRequest(String reviewer) { state.sendReviewRequest(this, reviewer); }

    public void setState(TicketState newState) { this.state = newState; }

    public String getSummary() { return summary; }
    public String getStartDate() { return startDate; }

    public void setSummary(String s) { this.summary = s; }
    public void setAssignee(String a) { this.assignee = a; }
    public void setStartDate(String d) { this.startDate = d; }
    public void setReviewer(String r) { this.reviewer = r; }
}

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

--- 未着手状態 ---
ログイン画面の表示不具合修正 を更新しました
佐藤 に担当を割り当てました
作業を開始しました(開始日: 2025-01-10)
--- 対応中状態 ---
開始日を更新しました: 2025-01-15
対応中状態では内容編集はできません
--- 解決済み状態 ---
田中 にレビュー依頼を送信しました
--- クローズ状態 ---
クローズ済み状態では内容編集はできません

TicketStateインターフェースが状態ごとの振る舞いを定義し、UnstartedStateWorkingInProgressStateなどの具象クラスがそれぞれの実装を持ちます。TicketContextは現在保持しているstateオブジェクトに処理を委譲するだけで、if文による条件分岐が存在しません。ステータスが増えた場合も、新しいTicketState実装クラスを追加するだけでよく、既存コードへの影響が最小限になります。

🧓 ベテランの現場コラム 状態遷移のルールをif文で列挙するより、各Stateクラスを個別にユニットテストした方がテストの見通しが良くなります。「このステータスでできる操作・できない操作」を1クラス単位で確認できるので、レビューもしやすくなります。

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

  • 使う: ステータスやフェーズが増減しても既存メソッドの修正が不要なよう振る舞いを分離したい場合。状態ごとに複雑なロジックが定義される場合
  • 使わない: 状態遷移が単純でif文数行で書ける場合。状態の種類が今後もほとんど増減しないと分かっている場合

まとめ

  • Stateパターンは条件分岐による状態判定をクラスに集約し、拡張性を高める設計手法
  • 状態ごとの振る舞いを別クラスの責務として分離することで、新規ステータスの追加が既存コードの修正を伴わなくなる
  • 状態遷移ロジックをテストケースからStateクラスのユニットテストへ移行すれば、品質保証のコストも抑制できる

🎓 理解度テスト

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