Commandパターン|ステータス変更を「元に戻せる」ようにする
TicketCraftでは、あるチケットのステータス変更操作でトラブルが起きていた。担当者が「進行中」から「完了」へ、さらに「完了」から「修正依頼」へと、複数のステータス変更を連続で行った結果、実際の作業内容と履歴が一致しなくなるケースが相次いでいた。誤って「完了」に変更してしまったチケットを元に戻す手段がなく、担当者が慌てて別の方法で対応せざるを得ない状況が続いていた。
このパターンは何のためにあるか
Commandパターンは、操作そのものをオブジェクトとして表現し、実行・取り消し・再実行などの動作を統一されたインターフェースで制御するためのデザインパターンです。日常のたとえ話でいえば、料理店の注文伝票に似ています。注文の内容が一枚の紙(=コマンド)として記録され、必要に応じてその内容を確認したり、キャンセルしたりできます。
Before
TicketCraftには当初、以下のようなステータス変更メソッドが存在していました。チケットを直接操作し、履歴を残さない実装であるため、誤った変更に対する復旧手段が用意されていませんでした。
import java.util.ArrayList;
import java.util.List;
public class Ticket {
private String id;
private String title;
private String status;
public Ticket(String id, String title) {
this.id = id;
this.title = title;
this.status = "未対応";
}
public void setStatus(String newStatus) {
this.status = newStatus;
}
public String getStatus() {
return status;
}
public String getId() {
return id;
}
}
public class StatusManager {
private List<Ticket> tickets = new ArrayList<>();
public void changeStatus(Ticket ticket, String targetStatus) {
ticket.setStatus(targetStatus);
System.out.println(ticket.getId() + " のステータスを「" + targetStatus + "」に変更しました。");
}
}
public class BeforeMain {
public static void main(String[] args) {
StatusManager manager = new StatusManager();
Ticket ticket1 = new Ticket("TC-001", "画面レイアウトの修正");
System.out.println("=== ステータス変更実行 ===");
manager.changeStatus(ticket1, "進行中");
manager.changeStatus(ticket1, "完了");
manager.changeStatus(ticket1, "修正依頼");
System.out.println("\n=== 現在のステータス確認 ===");
System.out.println("TC-001: " + ticket1.getStatus());
System.out.println("\n=== 問題点 ===");
System.out.println("「完了」に戻したいが、履歴が残っていないため復旧できない。");
}
}
この実装には次のような問題がありました。ステータス変更の履歴が一切記録されないため、誤った変更を行った場合でも元に戻す方法が用意されていません。ステータス変更ロジックと実行制御が密結合しており、将来的に「変更前の値の保存」のような機能を追加する場合にも修正範囲が大きくなってしまいます。
After
Commandパターンを適用すると、ステータス変更操作自体をオブジェクトとして表現できるため、履歴管理やUndo機能が実現可能になります。
import java.util.ArrayList;
import java.util.List;
public class Ticket {
private String id;
private String title;
private String status;
public Ticket(String id, String title) {
this.id = id;
this.title = title;
this.status = "未対応";
}
public void setStatus(String newStatus) {
this.status = newStatus;
}
public String getStatus() {
return status;
}
public String getId() {
return id;
}
}
public interface Command {
void execute();
void undo();
}
public class ChangeStatusCommand implements Command {
private Ticket ticket;
private String newStatus;
private String previousStatus;
public ChangeStatusCommand(Ticket ticket, String newStatus) {
this.ticket = ticket;
this.newStatus = newStatus;
this.previousStatus = ticket.getStatus();
}
@Override
public void execute() {
ticket.setStatus(newStatus);
System.out.println(ticket.getId() + " のステータスを「" + previousStatus + "」から「" + newStatus + "」に変更しました。");
}
@Override
public void undo() {
String beforeUndo = ticket.getStatus();
ticket.setStatus(previousStatus);
System.out.println(ticket.getId() + " のステータスを「" + beforeUndo + "」から「" + previousStatus + "」に戻しました。");
}
}
public class CommandHistory {
private List<Command> history = new ArrayList<>();
public void record(Command command) {
history.add(command);
}
public int size() {
return history.size();
}
public Command get(int index) {
if (index < 0 || index >= history.size()) {
throw new IndexOutOfBoundsException("履歴インデックス" + index + "は範囲外です。");
}
return history.get(index);
}
}
public class StatusManagerAfter {
private List<Ticket> tickets = new ArrayList<>();
private CommandHistory history = new CommandHistory();
public void changeStatus(Ticket ticket, String targetStatus) {
ChangeStatusCommand command = new ChangeStatusCommand(ticket, targetStatus);
command.execute();
history.record(command);
}
public void undo(int index) {
if (index < 0 || index >= history.size()) {
System.out.println("指定された履歴インデックスは存在しません。");
return;
}
Command command = history.get(index);
command.undo();
}
}
public class AfterMain {
public static void main(String[] args) {
StatusManagerAfter manager = new StatusManagerAfter();
Ticket ticket1 = new Ticket("TC-001", "画面レイアウトの修正");
System.out.println("=== ステータス変更実行 ===");
manager.changeStatus(ticket1, "進行中"); // 履歴[0]
manager.changeStatus(ticket1, "完了"); // 履歴[1]
manager.changeStatus(ticket1, "修正依頼"); // 履歴[2]
System.out.println("\n=== 現在のステータス確認 ===");
System.out.println("TC-001: " + ticket1.getStatus());
System.out.println("\n=== Undo機能(履歴[1]=「完了」への変更を取り消す) ===");
manager.undo(1);
System.out.println("\n=== Undo後のステータス確認 ===");
System.out.println("TC-001: " + ticket1.getStatus());
}
}
実行結果は以下の通りです。
=== ステータス変更実行 ===
TC-001 のステータスを「未対応」から「進行中」に変更しました。
TC-001 のステータスを「進行中」から「完了」に変更しました。
TC-001 のステータスを「完了」から「修正依頼」に変更しました。
=== 現在のステータス確認 ===
TC-001: 修正依頼
=== Undo機能(履歴[1]=「完了」への変更を取り消す) ===
TC-001 のステータスを「修正依頼」から「進行中」に戻しました。
=== Undo後のステータス確認 ===
TC-001: 進行中
各ステータス変更がCommandオブジェクトとして記録されるため、履歴を遡って元に戻す操作が可能になりました。重要なのは、単純な数値の加減算ではなく「変更前の状態」をコマンドオブジェクト内部に保持している点です。これにより、複雑な状態遷移を持つチケットであっても、正確なUndoが実現できます。
🧓 ベテランの現場コラム 設計段階で「元に戻す」ことを想定しておくと、コードのテストしやすさやチームメンバー間の操作認識の共有にも大きく寄与します。「取り消せない操作」は、実装時点では気づきにくいリスクとして後から重くのしかかってきます。
こんな場面で使う/使わない
- 使う: 実行内容の履歴管理やUndo機能が必要な場合。操作を後からログとして出力・監査したい場合
- 使わない: 単純な計算処理のように、実行結果だけを返せば十分で、取り消しや履歴が不要な場合
まとめ
- Commandパターンは操作そのものをオブジェクト化し、実行・取り消しの制御を統一的に行う
- 履歴管理とUndo機能を実現するには「変更前の状態」をコマンド内部に保持する設計が有効
- 密結合だった処理ロジックから分離されるため、拡張性とテスト容易性が向上する
🎓 理解度テスト
学んだ内容を確認しましょう。全3問。