第 1 章:ミュータブルなオブジェクトからイミュータブルなデータへ

本章の内容:


プログラムを書くとき、「オブジェクトの状態を変更する」ことを、ごく自然に行っているのではないでしょうか。ここでいう状態とは、ある時点でオブジェクトが保持しているデータの値、たとえばプロパティ(property)やフィールド(field)の値です。

たとえば、注文を「支払い済み」にしたいとしましょう。直感的には、次のように書けます。

order.Status = OrderStatus.Paid;
order.PaidAt = DateTimeOffset.UtcNow;

直接的でわかりやすい書き方です。注文を一つの処理の流れの中だけで使い、状態の変化も追いやすいなら、オブジェクトを直接変更する方法は便利です。

ところがプログラムが大きくなるにつれて、こうした「オブジェクトの状態を直接変更する」方法が問題を引き起こすことがあります。ある時点でオブジェクトがどの状態にあるのか、誰がいつその状態に変更したのかを、確かめにくくなるのです。

本章では、この問題から出発し、イミュータブルデータ設計(immutable data design)の基本的な考え方を紹介します。オブジェクトを直接変更する代わりに、毎回の「変更」を「新しいバージョンを作ること」として表します。小さな発想の転換に見えますが、データを中心に扱うアプリケーション(data-centric applications)では、データモデリング、状態遷移、テストのしやすさ、保守のしやすさに大きく影響します。これが本書全体を貫く中心的な考え方です。

イミュータブル設計によって保守やデバッグの負担を減らせても、実行時の性能は別に評価する必要があります。新しいバージョンのオブジェクトを作ると、メモリ割り当て(allocation)のコストが増える可能性があります。実行時間やメモリ使用量にどのような影響があるかは、性能テストで確認しましょう。

1.1 ミュータブルなオブジェクトの悩み

ミュータブルなオブジェクトを使う設計は、最初はたいてい妥当なものです。問題は、システムが大きく複雑になるにつれて表面化します。同じオブジェクトが複数のメソッド、サービス、バックグラウンド処理に渡され、どの場所でもその状態の一部を変更できるようになるのです。

具体例を見てみましょう。注文管理システムを開発しているとします。注文は作成後、確定、支払い、発送といった状態を経て、一定の条件を満たせばキャンセルもできます。直感的な設計は、Order クラスを作り、いくつかのプロパティ(properties)で注文に関する情報をすべて記録することです。

public class Order
{
    public string Id { get; set; } = string.Empty; // 注文番号
    public string CustomerId { get; set; } = string.Empty; // 顧客番号
    public OrderStatus Status { get; set; }     // 注文の状態
    public List<OrderLine> Lines { get; set; } = []; // 注文明細
    public decimal Total { get; set; }          // 注文の合計金額
    public DateTimeOffset CreatedAt { get; set; }    // 作成時刻
    public DateTimeOffset? SubmittedAt { get; set; } // 確定時刻
    public DateTimeOffset? PaidAt { get; set; }      // 支払い時刻
    public DateTimeOffset? ShippedAt { get; set; }   // 発送時刻
    public string? CancelReason { get; set; }   // キャンセル理由
}

さらに、注文の状態遷移を処理するサービスを用意します。ここでは OrderService としましょう。

public class OrderService
{
    public void Submit(Order order, DateTimeOffset now)  // (1)
    {
        order.Status = OrderStatus.Submitted;
        order.SubmittedAt = now;
    }

    public void Pay(Order order, DateTimeOffset now)  // (2)
    {
        order.Status = OrderStatus.Paid;
        order.PaidAt = now;
    }

    public void Ship(Order order, DateTimeOffset now)  // (3)
    {
        order.Status = OrderStatus.Shipped;
        order.ShippedAt = now;
    }

    public void Cancel(Order order, string reason)  // (4)
    {
        order.Status = OrderStatus.Cancelled;
        order.CancelReason = reason;
        order.SubmittedAt = null;
        order.PaidAt = null;
    }
}

この注文サービスには四つのメソッドがあります。

  1. Submit:注文を確定済みにし、確定時刻を記録します。
  2. Pay:注文を支払い済みにし、支払い時刻を記録します。
  3. Ship:注文を発送済みにし、発送時刻を記録します。
  4. Cancel:注文をキャンセル済みにし、キャンセル理由を保存して、確定時刻と支払い時刻を消去します。

ソースコード: DemoMutableOrder

小さなプログラムならこの設計で動きますが、よく考えると、いくつかの問題が潜んでいます。

問題 1:状態とデータの対応関係が明確でない

Cancel メソッドを見ると、SubmittedAt(確定時刻)と PaidAt(支払い時刻)を消去しています。

public void Cancel(Order order, string reason)
{
    order.Status = OrderStatus.Cancelled;
    order.CancelReason = reason;
    order.SubmittedAt = null;
    order.PaidAt = null;
}

しかし、注文をキャンセルするとき、これらのプロパティの値を消す必要はあるのでしょうか。支払い後にキャンセルされた注文なら、PaidAt は履歴として残すべきでしょうか。それとも消すべきでしょうか。

現在の設計では、こうした「プロパティの値と業務上の意味の対応関係」は、コードを読まなければわかりません。型からは読み取れないのです。

さらに厄介なのは、Order オブジェクトに PaidAt や ShippedAt など、null 許容のプロパティがいくつもあることです。たとえば PaidAt == null は、「まだ支払われていない」場合と「支払い済みだったが、キャンセル後に記録を消した」場合の両方を表し得ます。同じ null に二つの業務上の意味が対応するため、混乱を招きます。では、Order をイミュータブル(immutable)にするだけで、この曖昧さは自動的になくなるのでしょうか。答えは、いいえです。

Order をイミュータブルにしても、Status プロパティと複数の null 許容プロパティを組み合わせる設計がそのままなら、PaidAt == null が複数の意味を持ち得る点は変わりません。十分に改善するには、「状態」と「その状態が持つべきデータ」を明確に表す、より正確な型設計が必要です。イミュータブル設計は、状態遷移を明確な流れとして書くよう促します。同じオブジェクトのプロパティを書き換え、その変更が業務上何を意味するかを複数のメソッドに散在させる書き方を避けやすくなるのです。

これは、データの表現(representation)の問題です。コード内のデータは、保存できるだけでなく、それが何を表しているかも明確にする必要があります。Status、PaidAt、CancelReason の関係が開発者の頭の中にしかなく、型やメソッドで明示されていなければ、Order を使うコードはプロパティの意味を推測しなければならないかもしれません。その解釈や推測を誤ると、バグにつながります。

問題 2:どのメソッドからでも、どのプロパティでも変更できる

Order 型のすべてのプロパティには、公開の setter({ get; set; })があります。つまり、ある order オブジェクトの参照を取得したコードは、いつでもどのプロパティでも変更できます。

order.Status = OrderStatus.Submitted;
order.SubmittedAt = now;

小さなチームやプログラムでは問題にならなくても、規模が大きくなると、一つの Order オブジェクトが複数のサービス、ミドルウェア(middleware)、イベントハンドラー(event handler)に渡されることがあります。すると、「この時点でも order の Status は Submitted なのか。どこかですでに変更されていないか」と、不安になってきます。

答えを出すには、実行の流れを頭の中で再現するか、デバッガーでオブジェクトの状態変化を一つずつ確認する必要があります。AI エージェントに分析を手伝ってもらっても、どのコードがオブジェクトを変更し得るかを追う必要は変わりません。現在の状態を確かめるために、これほど多くの場所を調べなければならないことが、「プログラムの振る舞いを把握しにくい(hard to reason about)」ということです。

問題 3:複数の場所で同じオブジェクトを変更する

OrderService.Pay の実行中に、別の非同期(asynchronous)処理も同じ order オブジェクトを読み取ったり変更したりする場面を考えてみましょう。たとえば、自動通知を送るバックグラウンドジョブです。

複数の処理が同じミュータブルなオブジェクトに同時にアクセスし、適切な同期(synchronization)がなければ、競合状態(race condition)が起こり得ます。この例では、Pay は先に Status を設定し、その後で PaidAt を設定します。これは独立した二回の代入であり、他の処理が両方の代入後にしか読み取れないという保証はありません。ちょうど代入の間に別のコードが同じオブジェクトを読み取ると、Status は Paid(支払い済み)なのに、PaidAt はまだ null(支払い時刻未設定)という「途中の状態」を見る可能性があります。

こうした問題は安定して再現しにくく、デバッグも困難です。

1.2 見方を変える:変更を変換として表す

「注文の状態を変更する」とは、本質的に何をしているのでしょうか。注文の支払いを処理するとき、手元には「確定済み」の注文があり、支払い後には、その注文の「支払い済み」バージョンが欲しいはずです。この二つのバージョンは、状態と支払い時刻以外、ほとんど同じ内容を持ちます。

ミュータブルなオブジェクトの考え方では、同じオブジェクトを、ある状態から別の状態に変更します。確かに、これは最も直感的な方法です。

しかし、別の方法もあります。古いバージョンの注文から、新しいバージョンの注文を作るのです。古いオブジェクトはその場で変更せず、新しいオブジェクトに新しい状態を持たせます。この「変更=新しいバージョンの作成」という考え方が、イミュータブルデータ設計の中心です。

この二つの考え方を C# で表すと、次のようになります。

// ミュータブル設計:既存のオブジェクトを直接変更する
order.Status = OrderStatus.Paid;
order.PaidAt = now;

// イミュータブル設計:既存のオブジェクトから新しいバージョンを作る
var paidOrder = order with
{
    Status = OrderStatus.Paid,
    PaidAt = now
};

イミュータブル設計では、order のプロパティは元の値を保ち、paidOrder は更新後の状態を持つ新しいオブジェクトになります。

ここで押さえたい基本は、イミュータブル設計では、状態を変える必要があるたびに、新しいバージョンを作るということです。プログラムで支払い後の注文を使い続けたいなら、生成した新しいバージョンを別の変数に代入するか、次のように元の変数に再代入します。

order = order with
{
    Status = OrderStatus.Paid,
    PaidAt = now
};

このコードは「order を変更している」ように見えますが、実際には変数 order が新しいオブジェクトを参照するようにしています。古いオブジェクトのプロパティは再代入されていません。イミュータブル設計で制限するのは、オブジェクト自体の変更です。変数が新しい値を参照することは禁止しません。

表し方が変わっただけに見えるかもしれませんが、その影響は見た目以上に大きなものです。ここから、その理由を見ていきましょう。

1.3 C# の record と with 式

イミュータブル設計の利点を説明する前に、C# がこの設計を支えるために提供している構文を確認しましょう。

record でイミュータブルなデータを作る

C# 9 で導入された record 型には、イミュータブルなデータを表現しやすくするための機能が用意されています。データの値を表すのに適した振る舞いが標準で提供されており、位置指定パラメーター(positional parameters)や init プロパティを使うと、作成後に変更しないデータオブジェクトを書きやすくなります。最も簡単な書き方の一つを示します。

public record Order(
    string Id,          // 注文を識別する注文番号。
    string CustomerId,  // 注文した顧客と結び付ける顧客番号。
    OrderStatus Status, // 注文の状態
    decimal Total,      // 注文の合計金額
    DateTimeOffset CreatedAt, // 注文の作成時刻
    DateTimeOffset? PaidAt = null // 支払い時刻
);

このように、主なデータメンバーを型名の直後の丸括弧内に書く形式を、positional record(位置指定 record)と呼びます。この形式では、コンパイラーが次の処理を自動的に行います。

  • オブジェクトの初期化中だけ代入できるプロパティを作成します。これは init-only プロパティと呼ばれ、第 2 章で詳しく紹介します。
  • 値に基づく等価性比較(Equals、GetHashCode)を実装します。
  • デバッグに便利な ToString メソッドを生成します。

Order オブジェクトを作るには、次のように書きます。

var order = new Order(
    Id: "ORD-001",
    CustomerId: "CUST-42",
    Status: OrderStatus.Draft,  // Draft は注文がまだ確定されていないことを表す。
    Total: 1200m,
    CreatedAt: DateTimeOffset.UtcNow
);

注文オブジェクトを作った後は、init-only プロパティに直接再代入できません。

order.Status = OrderStatus.Paid; // コンパイルエラー!初期化はすでに完了している

これが、init-only プロパティを使うと、作成後に変更しないデータオブジェクトを書きやすくなる理由です。

with 式で新しいバージョンを作る

record のもう一つの重要な構文が with 式です。既存の record から、変更したいプロパティだけを変え、ほかのプロパティを引き継いだ新しいバージョンを作れます。

var paidOrder = order with
{
    Status = OrderStatus.Paid,
    PaidAt = DateTimeOffset.UtcNow
};

このコードの実行後は、次のようになります。

  • order のプロパティは元の値を保ち、以前の状態のままです。
  • paidOrder は、新しい Status と PaidAt を持つ別の Order オブジェクトです。

つまり、with は「既存のオブジェクトをもとにコピーを作り、指定した変更を適用する」操作です。このため、結果を paidOrder に代入するか、次の処理に渡す必要があります。with で生成した新しいオブジェクトを使わなければ、元の order が自動的に支払い済みになることはありません。

これが、「変更を変換として表す」考え方の C# による実装です。

ソースコード: DemoRecordBasics

ここでの「コピー」は浅いコピーである点に注意してください。値型のメンバーでは値がコピーされ、参照型のメンバーでは参照だけがコピーされます。参照先のオブジェクトまでコピーされるわけではありません。このため、プロパティがミュータブルなコレクションを参照していると、新旧の record が同じコレクションを共有することがあります。イミュータブルデータ設計には、適切なメンバーの型も必要です。第 2 章で詳しく説明します。

Note

record に対する with 式は C# 9 で導入され、C# 10 で struct と匿名型にも拡張されました。C# 8 以前を使うプロジェクトでは、手動でコピーするなどの方法が必要です。

1.4 イミュータブル設計の三つの利点

基本的な構文がわかったところで、この設計スタイルが実際にどのような問題を解決するかを見ていきましょう。

利点 1:予測しやすい(predictable)

予測しやすいとは、見えないところでオブジェクトがその場で変更されないということです。本章の Order は、初期化が完了すると、別のメソッドに渡しても、そのメソッドから init-only プロパティに再代入することはできません。

init-only が制限するのは、初期化後にプロパティへ再代入できないという点です。プロパティの参照先のオブジェクトまで、自動的にイミュータブルになるわけではありません。たとえば、後で Order にコレクションのプロパティを加えるなら、他のコードに注文内容を変更されないように、コレクションとその要素がミュータブルかどうかも確認する必要があります。第 2 章で詳しく説明し、第 3 章では、より完全な Order の例を扱います。

次の例で、この性質を確認できます。

// イミュータブル設計では、このコードの動作を予測できる
var now = DateTimeOffset.UtcNow;
var submittedOrder = OrderWorkflow.Submit(draft, now);
var paidOrder = OrderWorkflow.Pay(submittedOrder, now);

// ここでも submittedOrder は Submitted のまま
// paidOrder は Paid の状態
// 二つは別々の Order オブジェクト
Console.WriteLine(submittedOrder.Status); // Submitted
Console.WriteLine(paidOrder.Status);      // Paid

この例の OrderWorkflow は、注文の作成と状態遷移のメソッドをまとめた静的クラスです。ひとまず「古いバージョンを受け取り、新しいバージョンを返す」操作の集まりと考えてください。各メソッドの実装は、後ほど 1.5 節で紹介します。

ミュータブル設計なら、「OrderWorkflow.Pay は submittedOrder を変更していないだろうか」と心配になり、実装を確認する必要があるかもしれません。このイミュータブル設計では、Pay は別の Order を返し、submittedOrder のプロパティを変更しません。

利点 2:テストしやすい(testable)

イミュータブルなデータを使うと、次の二つの理由でテストがしやすくなります。

一つ目は、変換メソッドがテストの入力を変更しないことです。draft の注文を用意して複数のメソッドに渡しても、処理が終わった後の draft は、最初の draft のままです。

二つ目は、状態遷移を一段階ずつ検証できることです。各処理が、その変換結果を表す新しいバージョンを返すため、テストでは各バージョンの状態を個別に検証できます。次は xUnit による単体テストの例です([Fact] は、その下のメソッドがテストメソッドであることを示す属性です)。

[Fact]
public void Order_Should_Track_Each_State_Transition()
{
    // Arrange: テストの初期状態を作る。
    //   draft の状態は、後続のどの操作でも変わらない。
    //   fixedTime によって、時刻に関する検証を安定させ、再現可能にする。
    var fixedTime = new DateTimeOffset(
        2026, 1, 1, 9, 0, 0, TimeSpan.Zero);
    var draft = OrderWorkflow.Create("CUST-42", [], fixedTime);

    // Act + Assert: Submit は新しい注文バージョンを返す。
    var submitted = OrderWorkflow.Submit(draft, fixedTime);
    Assert.Equal(OrderStatus.Submitted, submitted.Status);
    Assert.Equal(fixedTime, submitted.SubmittedAt);

    // Pay は submitted を受け取り、さらに新しい注文バージョンを返す。
    var paid = OrderWorkflow.Pay(submitted, fixedTime.AddHours(1));
    Assert.Equal(OrderStatus.Paid, paid.Status);
    Assert.Equal(fixedTime.AddHours(1), paid.PaidAt);

    // Pay は submitted の状態を変更していない。
    Assert.Equal(OrderStatus.Submitted, submitted.Status);
    Assert.Null(submitted.PaidAt);
}

ソースコード: DemoOrderStateTests

このテストでは、状態遷移の全体の流れが明確で、それぞれの状態遷移も個別に検証できます。

利点 3:追跡しやすい(traceable)

イミュータブルなデータは、「スナップショット(snapshot)」を表すのに適しています。変換するたびに、その時点の状態を表す新しいオブジェクトを生成できます。必要なバージョンを残せば、明確な履歴を作れます。

これは、監査ログ(audit log)、イベントソーシング(event sourcing)、問題の診断で役立ちます。

// 共通の基準時刻を使って注文の状態遷移を示す
var now = DateTimeOffset.UtcNow;
var order = OrderWorkflow.Create("CUST-42", lines, now);
var submittedOrder = OrderWorkflow.Submit(order, now);
var paidOrder = OrderWorkflow.Pay(
    submittedOrder, now.AddHours(2));
var shippedOrder = OrderWorkflow.Ship(
    paidOrder, now.AddDays(1));

// 各段階の状態のスナップショットをメモリに残す
var auditTrail = new[]
{
    order, submittedOrder, paidOrder, shippedOrder
};

ソースコード: DemoImmutableOrder

ミュータブル設計では、別途スナップショットを作る仕組みを用意しない限り、オブジェクトを変更すると古い状態が消えてしまいます。イミュータブル設計では、オブジェクト自体の状態は変わらず、状態の変更が必要なときに新しいバージョンを作ります。そのため、変更前にオブジェクトのスナップショットを手動で作ることなく、どのバージョンを履歴として永続化するかを選べます。

Note: イミュータブル設計そのものが、各バージョンを自動保存するわけではありません(状態の履歴を保存するコードは別途必要です)。また、監査ログやイベントソーシング(event sourcing)と同じものでもありません。

1.5 Order を見直す:イミュータブル版の姿

ここまでの考え方を組み合わせ、Order を設計し直してみましょう。

まず、record で Order を定義します。

public sealed record Order( // (1)
    string Id,
    string CustomerId,
    OrderStatus Status,
    IReadOnlyList<OrderLine> Lines, // (2)
    DateTimeOffset CreatedAt,
    DateTimeOffset? SubmittedAt = null, // (3)
    DateTimeOffset? PaidAt = null,
    DateTimeOffset? ShippedAt = null,
    string? CancelReason = null
)
{
    public decimal Total => Lines.Sum(
        line => line.UnitPrice * line.Quantity); // (4)
}

注目したい設計上の判断がいくつかあります。

  1. sealed を使う:この型を継承させる意図がないことを示し、派生クラスの作成を禁止します。
  2. List<OrderLine> の代わりに IReadOnlyList<OrderLine> を使う:Order の公開 API は、明細を変更する手段を提供しません。ただし、これは最初の保護にすぎません。コレクションが本当にイミュータブルであることをどう保証するかは、第 2 章で詳しく扱います。
  3. 既定値を持つパラメーターを後ろに置く:Order の作成時には、SubmittedAt などの省略可能なパラメーターに対応する引数を省略できます。それより前の必須パラメーターについては、呼び出し元が引数を指定する必要があります。
  4. Total は Lines から計算する派生プロパティです。呼び出し元が注文の明細と合計金額を不整合にすることはできません。

Note

IReadOnlyList<T> はイミュータブルコレクションではありません。このインターフェイスを通じて、呼び出し元が直接要素を追加・削除できないことを表すだけです。基になるコレクションをほかのコードが保持していれば、内容は変わる可能性があります。第 2 章で詳しく説明します。

次に、OrderWorkflow クラスに、次の状態遷移メソッドを定義します。

  • Create:Draft 状態の注文、つまり未確定の下書き注文を作成します。
  • Submit:注文を確定し、下書き状態から「確定済み」へ変換します。
  • Pay:確定済みの注文を「支払い済み」へ変換します。
  • Ship:支払い済みの注文を「発送済み」へ変換します。
  • Cancel:「キャンセル済み」の注文バージョンを作り、過去の時刻情報を保持します。

まず、簡略化した状態遷移図で、各メソッドの関係を確認しましょう。

この図は、本章の例の流れを簡略化したものです。不正な状態遷移をどう制限するかは、後の章で扱います。

読みやすくするため、同じクラスをいくつかの部分に分けて見ていきます。以下はすべて OrderWorkflow クラスのメソッドで、外側の public static class OrderWorkflow 宣言は省略しています。

最初は、注文を作る部分です。Create は、Draft 状態の注文を作ります。

public static Order Create(
    string customerId,
    IReadOnlyList<OrderLine> lines,
    DateTimeOffset now)
{
    return new Order(
        Id: Guid.NewGuid().ToString(),
        CustomerId: customerId,
        Status: OrderStatus.Draft,
        Lines: lines,
        CreatedAt: now
    );
}

呼び出し元が now を渡し、Create がその値を作成時刻 CreatedAt に設定します。メソッド内でシステム時刻を直接取得するわけではありません。こうすると、テストで固定時刻を渡せるため、実行時刻の違いによって単体テストや統合テストの結果が不安定になるのを避けられます。合計金額は Order.Total が明細からその都度計算し、整合性を保ちます。

本章では例を簡潔にするため、Create の内部で Guid.NewGuid() を呼び出しています。このため、出力はまだ引数だけでは完全には決まりません。第 4 章では、application layer で ID と現在時刻を取得し、それらを引数として domain factory に渡すように変更します。

Application layer と domain factory

Application layer(アプリケーション層)は、ユースケースの実行を調整します。たとえば、注文 ID と現在時刻を取得し、ドメインモデル(domain model)を呼び出して処理を行います。ビジネスルールはドメインモデルに任せます。Microsoft のドキュメント The application layer を参照してください。

Domain factory(ドメインファクトリー)は、ドメインオブジェクトの作成処理をカプセル化し、作成時にビジネスルールを満たすことを保証します。第 4 章では、アプリケーション層からデータを受け取り、注文を作成する条件を確認し、正しい注文オブジェクトを返します。Eric Evans の DDD Reference の「Factories」(本文 18 ページ)も参考になります。

続いて、典型的な状態遷移である Submit と Pay です。どちらも既存の Order を受け取り、with で新しいバージョンを作ります。

public static Order Submit(Order order, DateTimeOffset now)
{
    return order with
    {
        Status = OrderStatus.Submitted,
        SubmittedAt = now
    };
}

public static Order Pay(Order order, DateTimeOffset now)
{
    return order with
    {
        Status = OrderStatus.Paid,
        PaidAt = now
    };
}

重要なのは、Submit も Pay も、引数 order のプロパティを変更していないことです。確定時刻 SubmittedAt と支払い時刻 PaidAt は、返される新しいバージョンにだけ反映され、元の注文のプロパティはそのままです。

最後は、発送とキャンセルです。キャンセルの例では、それまでの確定時刻や支払い時刻を残す方法も示します。

public static Order Ship(
    Order order,
    DateTimeOffset now)
{
    return order with
    {
        Status = OrderStatus.Shipped,
        ShippedAt = now
    };
}

public static Order Cancel(Order order, string reason)
{
    return order with
    {
        Status = OrderStatus.Cancelled,
        CancelReason = reason
        // 注意:SubmittedAt と PaidAt はどちらもそのまま残す
        // 履歴なので消去しない
    };
}

ソースコード: DemoImmutableOrder

特に注目したいのが Cancel です。キャンセル理由を加えて状態を変えるだけで、SubmittedAt や PaidAt は消去しません。これは、本章で想定したビジネスルールに基づくものです。キャンセルは新しい状態であり、過去の出来事を消すことではありません。

元のミュータブル設計と比べると、次の改善があります。

  • 状態遷移の書き方が統一されています。 Submit や Pay は、古いバージョンを受け取り、新しいバージョンを返します。メソッドシグネチャだけでは入力オブジェクトが変更されないことを保証できませんが、注文処理全体がこの慣例を守れば、呼び出し元も一貫した方法でメソッドを使えます。

  • キャンセル時に過去の時刻情報を残します。 本章で想定するビジネスルールでは、キャンセルによって過去の出来事を消さないため、キャンセル済みの注文にも SubmittedAt と PaidAt が残ります。他のシステムでは、それぞれのキャンセル、無効化、返金のルールに応じてデータの表し方を決める必要があります。

  • 同じ order オブジェクトを複数の場所で使っても、その場で状態を変更される心配がありません。 ある場所で Pay(order, now) を呼び出しても、別の場所が持つ order が突然支払い済みになることはありません。

Note

in と ref readonly は、オブジェクトのイミュータビリティを保証しません。C# では、メソッドのパラメーターに in や ref readonly を付けて、「読み取り専用参照で渡す」ことをシグネチャに示せます。これは、メソッド内でそのパラメーターに別の値を再代入できないことを意味します。C# 言語リファレンスの Method parameters and modifiers を参照してください。

ただし、読み取り専用参照だからといって、参照先のオブジェクトがイミュータブルとは限りません。パラメーターの型が class や record class なら、in Order order が保護するのは order というパラメーター変数自体だけです。メソッドがオブジェクトのミュータブルなメンバーを呼び出して内部状態を変えることは防げず、order.Lines の背後にあるコレクションも、本当にイミュータブルになるわけではありません。

したがって、この例の設計は、主に Order の init-only プロパティと、OrderWorkflow が入力オブジェクトを変更せずに新しいバージョンを返すことに支えられています。パラメーター修飾子が、これらを保証するわけではありません。

1.6 状態はいつ変更するのか

イミュータブル設計に初めて触れたとき、「それなら、オブジェクトの状態はいつ変更するのだろう」と疑問に思うことがあります。

答えは、データの更新が必要なときは、元のデータオブジェクトを変更する代わりに、新しいバージョンを作って使う、ということです。

次は、読み取り、変換、保存の流れを示すコードです。リポジトリと時刻を取得する仕組みの実装は省略しています。

// データベースから注文を読み取る(order はある時点のスナップショット)
var order = await orderRepository.GetAsync(orderId);

// 支払い後の新しいバージョンを作る
var paidOrder = OrderWorkflow.Pay(order, clock.UtcNow);

// 新しいバージョンをデータベースに保存し、注文レコードを更新する
await orderRepository.SaveAsync(paidOrder);

clock は、現在時刻を取得するために独自に用意したオブジェクトです。clock.UtcNow は .NET の組み込み API ではありません。ここでは現在の UTC 時刻を返すものと想定し、型と実装は示していません。

この orderRepository は、注文の読み取りと保存をカプセル化し、呼び出し元が具体的なデータの保存方法を意識せずに済むようにします。内部では Entity Framework Core、他の ORM(object-relational mapping)、Dapper、手書きの SQL、その他の保存方法を使えます。SaveAsync の実装は技術の選択によって異なります。ここでの要点は、呼び出し元が新しい注文バージョンを repository に渡して保存することです。ORM の追跡機能を使う場合のトレードオフは、第 5 章で扱います。

データベースの注文レコードは確かに更新されますが、元の order オブジェクトは変更されていません。全体の流れは、「古い状態を読み取る → 新しい状態を計算する → 新しい状態を保存する」です。

このパターンでは、各処理を独立してテストでき、全体の流れも理解しやすくなります。

1.7 ミュータブル設計が適している場面

すべてにイミュータブル設計が適しているわけではありません。次のような場面では、ミュータブル設計のほうが自然なことがあります。

  • ORM のエンティティ(entity):Entity Framework Core の従来の追跡更新方式では、通常、同じエンティティオブジェクトのプロパティの変化を追跡し、変更をデータベースに書き戻します。毎回新しいオブジェクトを作る方法に全面的に切り替えると、エンティティの識別や追跡状態を別途扱う必要があり、使いにくくなるかもしれません。イミュータブルなエンティティが不可能というわけではありませんが、異なるマッピングや更新戦略が必要です。
  • 従来の MVVM を使う UI 状態:WPF や MAUI などでは、ミュータブルな ViewModel と双方向バインディング(two-way binding)を組み合わせ、画面入力を編集中の状態にすぐ反映することがよくあります。入力のたびに画面全体の新しいバージョンを作ると、データバインディング、検証メッセージ、フォーカスの管理が難しくなる可能性があります。一方向データフローの UI アーキテクチャなら、イミュータブルな状態が適する場合もあります。
  • 構築中の一時オブジェクト:メソッド内部で List<T> にデータを集め、最後にイミュータブルコレクションへ変換するのは、ごく普通の方法です。
  • 性能に敏感なホットパス:実行頻度が高く、全体の性能に大きく影響するコード部分です。「更新」のたびに新しいオブジェクトを作ると、メモリ割り当て回数やガベージコレクション(GC)の負荷が増えます。高頻度で低遅延が求められる場面では、この追加コストが遅延やスループットに直接現れることがあります。

これらは第 5 章で詳しく扱います。ここでは、イミュータブル設計を状況に応じて使う道具だと考えてください。構文を覚えることより、いつ使うかを学ぶことが大切です。

イミュータブル設計と DOP、DOD の関係

本書でいうイミュータブルデータ設計は、データ指向プログラミング(data-oriented programming、DOP)という方法論全体と同じではありません。DOP の原則の表し方は一つに決まっていません。Yehonathan Sharvit が示すものを例にすると、コードとデータを分ける、汎用データ構造でデータを表す、データをイミュータブルとして扱う、データのスキーマと表現を分ける、という四つの原則が相互に働きます。イミュータビリティはその一つです。Principles of Data-Oriented Programming を参照してください。

本書はイミュータブルデータ設計に焦点を当てますが、明示的に宣言する Order や OrderLine など、C# の名前付きの型も重視します。また、振る舞いをデータ型の内部に置くか、外部サービスに置くかを、状況に応じて判断します。つまり、DOP と共通する考え方はありますが、DOP 全体に従うよう勧めているわけではありません。

また、イミュータブルデータ設計は、高性能プログラミングでよく使われるデータ指向設計(data-oriented design、DOD)とも異なります。DOD は、データの実際の使われ方やメモリ上の配置に注目し、アクセスパターンに合わせてデータを再構成することで、CPU キャッシュの利用効率や実行性能を改善します。ゲームやその他の性能に敏感なシステムでよく使われますが、イミュータビリティは必須条件ではありません。

1.8 本章の振り返り

本章では、イミュータブルデータ設計の基本的な考え方を紹介しました。ミュータブルな Order クラスから出発し、大きなプログラムでミュータブル設計がもたらす問題を確認してから、イミュータブルなバージョンへ書き換えました。

中心となる発想の転換は、オブジェクトを直接変更する代わりに、新しい状態を持つ新しいバージョンを作ることです。C# では、record と with 式が、そのための主な道具になります。

イミュータブル設計には、三つの主な利点があります。

  • 予測しやすい:変換メソッドが入力オブジェクトをその場で変更しないため、状態の変化が明確になります。
  • テストしやすい:変換メソッドが入力オブジェクトのプロパティをその場で変更せず、各状態遷移を個別に検証できます。
  • 追跡しやすい:保存した各バージョンを状態のスナップショットにでき、監査証跡(audit trail)を作りやすくなります。

イミュータブル設計は、アプリケーションの状態を永遠に固定するものではありません。個々のデータオブジェクトをその場で変更することを避けるものです。状態の更新が必要になれば、古いバージョンから新しいバージョンを計算し、データベースに保存したり、メモリ内の現在のバージョンを差し替えたり、次の処理に渡したりします。

また、イミュータブル設計は道具であって、絶対的な規則ではありません。ORM のエンティティ、UI 状態、構築中の一時オブジェクト、性能に敏感なホットパスでは、ミュータブル設計のほうが適していることもあります。

続く第 2 章では、record、init、with など、C# でイミュータブルなデータを構築する言語機能と、イミュータブルコレクションの選び方を詳しく見ていきます。