C# 状態と日時の更新をメソッドにまとめて更新漏れを防ぐ
申請を承認するときは、状態を「承認済み」にするだけでなく、承認日時も保存したい場合があります。
これらを呼び出し側で別々に代入していると、ある画面では日時も更新され、別のAPIでは状態だけが変わる、といった違いが生まれやすくなります。
今回は、一緒に変更すべき値を、操作を表すメソッドへまとめる方法を紹介します。
この記事では、状態と判断日時を持つ申請を例にします。承認・拒否のどちらでも、状態と日時を一緒に更新するルールを作ります。
プロパティを個別に変更する場合の問題
次のように書けるクラスを考えます。
呼び出し側で別々に更新する例request.Status = RequestStatus.Approved;
request.DecidedAt = now;この2行を複数の場所へ書くと、日時の代入を忘れたり、すでに拒否された申請を承認できてしまったりする可能性があります。
「承認時にはこの2つを更新する」というルールを、すべての呼び出し側が覚えている必要があります。
そこで、操作として意味のある単位にまとめます。
操作をまとめた呼び出しrequest.Approve(now);呼び出し側は承認を依頼し、その操作で変わる値や条件はクラス側で管理します。
どの状態から変更できるかを先に決める
状態の変更を、状態遷移と呼びます。今回の例では、次のルールにします。
| 現在の状態 | 操作 | 結果 |
|---|---|---|
| 申請中 | 承認 | 承認済みにして判断日時を保存する |
| 申請中 | 拒否 | 拒否済みにして判断日時を保存する |
| 承認済み | 承認または拒否 | エラーにする |
| 拒否済み | 承認または拒否 | エラーにする |
再実行されたとき、エラーにするか、何もせず終了するかは業務によって異なります。すでに同じ操作が完了していれば、何もせず戻る設計も考えられます。この記事では、違反を見つけやすくするため、判断済みならエラーにするルールを選んでいます。
外からの代入を制限する
次のコードは.NET 8以降のコンソールアプリでも使える簡略例です。
ApprovalRequest.csusing System;
public enum RequestStatus
{
Pending,
Approved,
Rejected
}
public sealed class ApprovalRequest
{
public RequestStatus Status { get; private set; } = RequestStatus.Pending;
public DateTimeOffset? DecidedAt { get; private set; }
public void Approve(DateTimeOffset now)
{
EnsurePending();
Status = RequestStatus.Approved;
DecidedAt = now;
}
public void Reject(DateTimeOffset now)
{
EnsurePending();
Status = RequestStatus.Rejected;
DecidedAt = now;
}
private void EnsurePending()
{
if (Status != RequestStatus.Pending)
{
throw new InvalidOperationException("判断済みの申請は変更できません。");
}
}
}private setにすると、値を読むことはできますが、クラスの外から代入できません。変更する場合は、公開したApproveやRejectを通します。
DateTimeOffset?の?は、日時がまだない状態をnullで表せることを意味します。
状態名を表すenumの基本は、以下の記事を参照してください。
時刻を引数にすると確認しやすい
この例では、メソッド内部で現在時刻を取得せず、引数のnowを使います。
Program.csusing System;
var request = new ApprovalRequest();
var now = new DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero);
request.Approve(now);
Console.WriteLine(request.Status); // Approved
Console.WriteLine(request.DecidedAt == now); // True時刻を固定して渡せるため、テストでは期待した日時が保存されるかを確認できます。通常の処理では、呼び出し側が取得した時刻を渡します。
承認日時や完了日時など、同じ操作で更新する日時が増えても、引数で受け取った値を使えば、一連の操作で使う時刻をそろえられます。
TimeProviderを使って時刻に依存する処理をテストする方法
何でも状態変更メソッドへ入れない
Approveの中へ、メール送信、画面遷移、外部サービスへの通信まで入れる必要はありません。
今回まとめたいのは、「承認すると、このオブジェクトの何が変わるか」というルールです。通知や保存の呼び出しは、その操作を実行する側で組み合わせられます。
たとえば、認証・権限の確認、状態の変更、保存、通知の準備という順序を呼び出し側で組み立てると、状態変更のメソッドは依頼そのもののルールに集中できます。
分割するときは、メソッド名だけでなく、その中で保証する範囲も決めます。Approveを呼べば権限確認まで終わるのか、事前に別の処理で確認するのかが曖昧だと、呼び出し側で抜けが起きます。
データベースの同時更新は別に扱う
EnsurePendingがあっても、2つの処理が別々に「申請中」のデータを読み込んだ場合は、両方のオブジェクトで承認できてしまいます。
このコードが守るのは、メモリー上の1つのオブジェクトの更新ルールです。データベースに保存するときは、読み取った後に他の処理が更新していないかも確認します。
メソッドを作っただけで、データベースへの保存が一度に成功する保証は付きません。
複数のデータを一緒に保存する必要があればトランザクションを使い、同時更新にはETagなどによる競合検知を組み合わせます。
確認するのは代入結果と変更できない条件
この例なら、次の点を確認します。
| 操作 | 期待する結果 |
|---|---|
| 新しい申請を作る | 申請中で、判断日時は未設定 |
| 申請中のデータを承認する | 状態と判断日時が同時に変わる |
| 申請中のデータを拒否する | 拒否済みになり、判断日時が入る |
| 承認後に拒否する | エラーになり、元の状態と日時が変わらない |
| 承認を繰り返す | この例ではエラーになる |
単純な名前の編集まで、すべて操作メソッドにする必要はありません。複数の項目を一緒に変更する、変更できる状態が限られる、といったルールが出てきたときにまとめると、呼び出し側の修正漏れを減らせます。