zukucode
主にWEB関連の情報を技術メモとして発信しています。

C# 状態と日時の更新をメソッドにまとめて更新漏れを防ぐ

申請を承認するときは、状態を「承認済み」にするだけでなく、承認日時も保存したい場合があります。

これらを呼び出し側で別々に代入していると、ある画面では日時も更新され、別のAPIでは状態だけが変わる、といった違いが生まれやすくなります。

今回は、一緒に変更すべき値を、操作を表すメソッドへまとめる方法を紹介します。

この記事では、状態と判断日時を持つ申請を例にします。承認・拒否のどちらでも、状態と日時を一緒に更新するルールを作ります。

プロパティを個別に変更する場合の問題

次のように書けるクラスを考えます。

呼び出し側で別々に更新する例
request.Status = RequestStatus.Approved;
request.DecidedAt = now;

この2行を複数の場所へ書くと、日時の代入を忘れたり、すでに拒否された申請を承認できてしまったりする可能性があります。

「承認時にはこの2つを更新する」というルールを、すべての呼び出し側が覚えている必要があります。

そこで、操作として意味のある単位にまとめます。

操作をまとめた呼び出し
request.Approve(now);

呼び出し側は承認を依頼し、その操作で変わる値や条件はクラス側で管理します。

どの状態から変更できるかを先に決める

状態の変更を、状態遷移と呼びます。今回の例では、次のルールにします。

現在の状態操作結果
申請中承認承認済みにして判断日時を保存する
申請中拒否拒否済みにして判断日時を保存する
承認済み承認または拒否エラーにする
拒否済み承認または拒否エラーにする

再実行されたとき、エラーにするか、何もせず終了するかは業務によって異なります。すでに同じ操作が完了していれば、何もせず戻る設計も考えられます。この記事では、違反を見つけやすくするため、判断済みならエラーにするルールを選んでいます。

外からの代入を制限する

次のコードは.NET 8以降のコンソールアプリでも使える簡略例です。

ApprovalRequest.cs
using 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の基本は、以下の記事を参照してください。

enumで決められた状態を表す方法

時刻を引数にすると確認しやすい

この例では、メソッド内部で現在時刻を取得せず、引数のnowを使います。

Program.cs
using 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などによる競合検知を組み合わせます。

ETagを使って更新の競合を検知する方法

確認するのは代入結果と変更できない条件

この例なら、次の点を確認します。

操作期待する結果
新しい申請を作る申請中で、判断日時は未設定
申請中のデータを承認する状態と判断日時が同時に変わる
申請中のデータを拒否する拒否済みになり、判断日時が入る
承認後に拒否するエラーになり、元の状態と日時が変わらない
承認を繰り返すこの例ではエラーになる

単純な名前の編集まで、すべて操作メソッドにする必要はありません。複数の項目を一緒に変更する、変更できる状態が限られる、といったルールが出てきたときにまとめると、呼び出し側の修正漏れを減らせます。


関連記事

  • C# 型付きIDで異なるIDの渡し間違いを防ぐ

    注文IDと顧客IDがどちらもGuidの場合、引数の順序を間違えてもコンパイルできます。メソッドの引数も両方Guidなら、値を逆に渡しても型エラーになりません。今回はIDごとに専用の型を作り、渡し間違い...


  • C# 関連データの未取得とnullをLazyFieldで区別する

    C#で関連データを必要なときだけ取得する場合、値がnullかどうかだけでは取得済みか判断できません。まだ取得していない場合と、取得した結果データが存在しなかった場合の両方がnullになるためです。値と...


  • vscode C#のusingを自動で設定するショートカット

    vscodeでC#のusing文を自動で設定するショートカットを紹介します。「The type or namespace name XXX could not be found (are you mi...


  • C# yyyyMMdd形式の日付文字列をyyyy/MM/dd形式に変換する

    yyyyMMdd形式の日付文字列をスラッシュ区切りのyyyy/MM/dd形式に変換する方法を紹介します。以下のような共通ファンクションを作成します。文字列の長さが8桁でない場合は変換は行わない仕様とし...


  • C# YYYYMMDD書式の文字列が日付かどうか判定する

    C#で、YYYYMMDD書式の文字列が日付として妥当かどうかを判定します。以下のように、Date.TryParseExactを使用して、日付型の文字列に変換可能かどうかで判定しています。時刻も同じ方法...


  • C# UTC時刻の日付をJST時刻に変換する

    C#のUTC時刻の日付DateTimeを、JST時刻の日付に変換する方法を紹介します。単純に時刻を+9時間するのと同じですが、ここではタイムゾーンの仕様に基づいて変換します。以下のように変換します。T...


  • C# TryParseで文字列を安全に数値へ変換する

    テキストボックスやCSVから受け取った値は文字列です。数値として使いたい場合、int.Parseは変換できない入力で例外を投げます。入力が誤っていることを通常の分岐として扱うなら、TryParseが向...


  • C# TimeProviderで有効期限のテストを安定させる

    有効期限を判定する処理でDateTimeOffset.UtcNowを直接使用すると、テスト中の時刻を固定できません。実際に時間が過ぎるのを待つテストは遅く、境界付近で結果が不安定になります。今回は.N...


  • C# 文字列を数値に安全に変換する

    C#で、string型の文字列を、intやdecimalなどの数値型に、例外が発生しないように安全に変換する方法を紹介します。以下のように、TryParseを使用します。int(またはdecimal)...


  • C# Json形式の文字列をクラスオブジェクトに変換する

    System.Text.Jsonで、Json型の文字列をクラスオブジェクトに変換する方法を紹介します。Jsonを扱うライブラリといえばJson.NET(Newtonsoft.Json)を使用することが...