状態遷移をルールにする
このレッスンでは、注文ステータスのような状態の変化を、あちこちの if ではなく1か所のルールとして表す書き方を身につけます。
散らばった if は必ず食い違います
注文には状態があります。下書き、申請済み、承認済み、出荷済み、キャンセル済み。そして「承認済みでなければ出荷できない」のような決まりがあります。この決まりを、操作ごとのメソッドの先頭に書いていくと、次のようになります。
Java
public void approve(Order order) {
if (!"submitted".equals(order.getStatus())) {
throw new IllegalStateException();
}
order.setStatus("approved");
}
public void ship(Order order) {
if (!"approved".equals(order.getStatus())) {
throw new IllegalStateException();
}
order.setStatus("shipped");
}
public void cancel(Order order) {
if ("shipped".equals(order.getStatus())) {
throw new IllegalStateException();
}
order.setStatus("canceled");
}一見すると素直です。問題は、状態が1つ増えたときに起きます。返品受付という状態を足すとしたら、どのメソッドの if を直せばよいでしょうか。答えは「全部を読み直して判断する」であり、読み飛ばした1つが不具合になります。しかも管理画面用の別クラスや、バッチ処理にも同じ if が写されていると、直し漏れは確実に発生します。
さらに厄介なのが、この形では遷移表の全体像がどこにも存在しないことです。仕様を聞かれたとき、メソッドを順に読んで頭の中で組み立てるしかありません。
表として先に決めます
代わりに、まず表を書きます。ここでは次のルールとします。
| 現在の状態 | 操作 | 遷移後の状態 |
|---|---|---|
| draft | submit | submitted |
| submitted | approve | approved |
| submitted | reject | draft |
| approved | ship | shipped |
| draft | cancel | canceled |
| submitted | cancel | canceled |
| approved | cancel | canceled |
この表に無い組み合わせはすべて不許可です。shipped と canceled は終端の状態で、そこからはどの操作も受け付けません。cancel は shipped と canceled 以外のすべての状態から可能、と言い換えることもできます。
表の形にしておくと、確認したいことが目で確かめられます。canceled から何かできてしまわないか、submitted から直接 ship できてしまわないか。コードを読まずに済むのが表の価値です。
ルールを1か所に集めます
表をそのままコードにします。Java なら列挙型と Map の組み合わせが素直です。
Java
public enum OrderStatus {
DRAFT, SUBMITTED, APPROVED, SHIPPED, CANCELED;
}
public final class OrderTransition {
private static final Map<String, OrderStatus> RULES = Map.of(
"DRAFT/submit", OrderStatus.SUBMITTED,
"SUBMITTED/approve", OrderStatus.APPROVED,
"SUBMITTED/reject", OrderStatus.DRAFT,
"APPROVED/ship", OrderStatus.SHIPPED,
"DRAFT/cancel", OrderStatus.CANCELED,
"SUBMITTED/cancel", OrderStatus.CANCELED,
"APPROVED/cancel", OrderStatus.CANCELED);
public static Optional<OrderStatus> next(OrderStatus current, String action) {
return Optional.ofNullable(RULES.get(current.name() + "/" + action));
}
}こうすると、状態を1つ増やす作業は「表に行を足す」という1つの操作になります。呼ぶ側は次のように書きます。
Java
@Service
public class OrderService {
@Transactional
public Order apply(Long id, String action) {
Order order = orderRepository.findById(id).orElseThrow();
OrderStatus next = OrderTransition.next(order.getStatus(), action)
.orElseThrow(() -> new IllegalStateException("invalid transition"));
order.setStatus(next);
return orderRepository.save(order);
}
}呼ぶ側から状態の知識が消えました。Service は「遷移できるか聞いて、できたら適用する」だけになっています。
置き場所の話
この遷移ルールは Controller には置きません。管理画面から呼んでもバッチから呼んでも同じ答えでなければならないからです。Service に置いてもよいですし、状態を持つドメインオブジェクト自身のメソッドとして持たせる形もよく使われます。避けるべきなのは、画面ごとに書き写すことと、データベースの制約だけに頼ることです。
演習で書くもの
演習では、上の表をそのまま実装します。現在の状態と操作を受け取り、遷移できるなら遷移後の状態、できないなら invalid を返します。Spring の Service が引き受ける判断のうち、最も典型的なものの1つです。
要件
- 遷移できる場合は遷移後の状態名を、できない場合は invalid という文字列を返すこと
- shipped と canceled からはどの操作も受け付けず invalid を返すこと
- 未知の状態名や未知の操作名、null が渡された場合も invalid を返すこと
入出力例
transit("draft", "submit") → "submitted"
transit("submitted", "approve") → "approved"
transit("submitted", "reject") → "draft"
transit("approved", "ship") → "shipped"
transit("approved", "cancel") → "canceled"
transit("shipped", "cancel") → "invalid"
transit("draft", "ship") → "invalid"
transit("canceled", "submit") → "invalid"
transit("unknown", "submit") → "invalid"