EF Core 11 における複合型 vs 所有エンティティ: どちらを選ぶべきか
EF Core 11 では、値オブジェクトにはデフォルトで複合型を使い、別テーブルが必要な場合や独自の行にマッピングされたコレクションが必要な場合にのみ所有エンティティに切り替えてください。
EF Core 11 (.NET 11 と C# 14) では、Address、Money、DateRange のような値オブジェクトは複合型としてマッピングし、所有エンティティに手を伸ばすのはストレージの形がそれを強制するときだけにしてください。つまり、値が独自のテーブルを必要とする場合や、コレクションを別々の行として保存する必要がある場合です。この 1 つの軸がほぼすべてのケースを決めます。複合型は値のセマンティクスを持ち、アイデンティティを持ちません。これはまさに値オブジェクトそのものです。一方、所有エンティティは値オブジェクトの衣装をまとった完全なエンティティ型であり、その衣装は常にずり落ちます。EF Core 11 は、所有エンティティを選ぶ最後の理由がほぼ消え去ったリリースです。というのも、複合型は TPT/TPC 継承で動作するようになり、ExecuteUpdate をサポートし、JSON にマッピングすればコレクションを許容し、キーやインデックスを持てるようになったからです。
この記事は決定についての話であり、仕組みの話ではありません。ステップバイステップの構成が知りたい場合は、EF Core 11 で所有エンティティの代わりに複合型をマッピングする方法をお読みください。ここでは 2 つのマッピングを真正面から比較し、それぞれがどこで勝つのかを示し、あなたの代わりに選択してくれる落とし穴を挙げます。
1 画面に収まる機能マトリクス
両方のマッピングが存在する理由は、それらが異なる問いに答えるからです。所有エンティティは EF Core が「これはオーナーの内部に保存する従属エンティティです」と言うための方法です。複合型は EF Core が「これは値であり、それ自身のアイデンティティを持ちません」と言うための方法です。以下のすべてはそこから流れ出ます。
| 観点 | 複合型 | 所有エンティティ |
|---|---|---|
| 基盤となるモデルの種類 | 値、キーなし | エンティティ、シャドウ主キー |
| アイデンティティのセマンティクス | 値による (内容) | 参照による (アイデンティティ) |
LINQ での a == b の比較対象 | 内容 | アイデンティティ |
代入がフィールドをコピー (x.A = x.B) | はい、コピーします | 例外をスローする (共有参照) |
| オーナーと同じテーブル (テーブル分割) | はい (デフォルト) | はい (デフォルト) |
別テーブル (ToTable) | いいえ | はい |
単一の JSON カラム (ToJson) | はい | はい |
| 別々の子行としてのコレクション | いいえ | はい (OwnsMany) |
| JSON ドキュメント内のコレクション | はい (ComplexCollection + ToJson) | はい (OwnsMany + ToJson) |
ネストしたメンバーへの ExecuteUpdate | はい (EF Core 11) | いいえ |
CLR 型が struct や record になれる | はい | 参照型のみ |
| ネストしたスカラーへのキー / インデックス | はい (EF Core 11) | はい |
| オーナーでの TPT / TPC 継承 | はい (EF Core 11) | はい |
| 変更トラッカーのフットプリント | カラムレベル、独立したノードなし | 独立した追跡ノード + シャドウキー |
このテーブルを上から下まで読むと、パターンは明白です。複合型はセマンティクスに関するすべての行で勝ち、所有エンティティはストレージの形に関する 2 つの行 (別テーブル、別々の子行) で勝ちます。それがこの比較の縮図のすべてです。ここではバージョンが重要です。というのも、複合型の「はい」のセルのうち 3 つは EF Core 11 で初めて真になったものだからです。EF Core 9 では計算が違いました。
複合型を選ぶべきとき
以下のケースでは ComplexProperty (または [ComplexType] 属性) に手を伸ばしてください。これらは実際のコードベースにおける値オブジェクトの大多数をカバーします。
- その型が完全にそのデータによって定義される場合。
Address、Money、GeoPoint、DateRange、PersonNameなどです。同一のフィールドを持つ 2 つのインスタンスが交換可能なら、それは値であり、値は値のセマンティクスを望みます。EF Core 11 ではb.ComplexProperty(c => c.ShippingAddress)と書けば、フィールドはオーナーのテーブルにインラインで配置されます。 - 値を自然に代入したり比較したりしたい場合。
customer.BillingAddress = customer.ShippingAddressはフィールドをコピーしてきれいに保存し、Where(c => c.BillingAddress == c.ShippingAddress)は内容でフィルタリングします。以下で説明するように、これらは両方とも所有エンティティでは壊れます。 - 一括書き込みが値の内部に到達してほしい場合。 EF Core 11 は複合型のメンバーへの
ExecuteUpdateをサポートします:ExecuteUpdateAsync(s => s.SetProperty(c => c.ShippingAddress.PostalCode, "010001"))。所有エンティティはこれを一度も許可したことがありません。高速な書き込みパスを気にするなら、これだけで決定的です。トレードオフは ExecuteUpdate vs エンティティの読み込みと SaveChanges と同じものです。 - 値が
structやrecordである場合。 所有エンティティは EF Core がキーを付けて追跡できる参照型でなければなりません。複合型はreadonly struct Moneyやrecordになれます。これは「アイデンティティなし」という考えと一致します。record の相互作用は EF Core 11 で record を正しく使う方法 で全文を読む価値があります。
Microsoft のガイダンスはこのデフォルトについて微妙な言い方をしていません。EF Core 11 のリリースノートは、複合型の安定化作業が「所有エンティティのマッピング手法に代わるものとして複合型を使えるようにするため」に特に行われたと述べており、EF Core 10 のノートは既存の所有エンティティのユーザーに切り替えるよう伝えました。複合型をデフォルトとして扱い、所有エンティティを例外として扱ってください。
所有エンティティを選ぶべきとき
OwnsOne / OwnsMany に留まる理由は、構造上の理由がちょうど 2 つと、モデリング上の理由が 1 つあります。
- 値が独自のテーブルに存在しなければならない場合。 複合型は常にインラインです。オーナー上のテーブル分割されたカラムか、オーナー上の 1 つの JSON カラムのどちらかです。
ComplexProperty(...).ToTable("Addresses")は存在しません。スキーマがオーナーへの外部キーを持つ別テーブルにデータを要求する場合 (レポート用ビューがそれをキーにする、別のテーブルがそれを参照する、DBA がそれを義務付ける)、それはOwnsOne(...).ToTable(...)でマッピングされる所有エンティティです。 - コレクションを別々の行として必要とする場合。 それぞれが子テーブル内の独自の行でなければならない値オブジェクトの一対多は
OwnsManyです。テーブル分割された複合型は単一の値でなければならず、EF Core 11 はコレクションのためにComplexCollectionを追加しましたが、それらは子行としてではなく JSON ドキュメントの内部に 保存されます。要素を第一級の行としてインデックス化、結合、またはクエリしたいなら、OwnsManyが依然としてそのツールです。 - それが実際には値オブジェクトではない場合。 同じ内容を持つ 2 つのインスタンスが区別可能でなければならない場合や、そのものが現在のデータより長く生きるライフサイクルを持つ場合、それはアイデンティティを持ちます。それは本物の関連エンティティであり、所有型でも複合型でもありません。通常の一対多と、あなたが制御するキーでモデリングしてください。
これらの理由のどれもセマンティクスや利便性についてのものではないことに注目してください。それらは物理的なスキーマについてのものです。「これは別テーブルや別々の行を必要とするか」という問いへのあなたの答えが「いいえ」なら、EF Core 11 で所有エンティティを使う理由はありません。
人々を所有エンティティから離れさせる 3 つの尖ったエッジ
比較は、鋭いエッジにぶつかったときに具体的になります。3 つすべてが同じ根本原因から来ています。所有エンティティはエンティティなので、EF Core はそれにシャドウキーを与え、参照アイデンティティによって推論するのです。
第一に、インスタンスを共有できません。これは動くべきに見えて、動きません。
// .NET 11, EF Core 11 - owned entity mapping
var customer = await context.Customers.SingleAsync(c => c.Id == id);
customer.BillingAddress = customer.ShippingAddress;
await context.SaveChangesAsync(); // throws: the same owned instance is referenced twice
両方のプロパティが同じエンティティ型なので、EF Core は 1 つのエンティティが 2 か所から参照されているのを見て、それを拒否します。複合型なら、代入はフィールドをコピーしてきれいに保存します。
第二に、LINQ の等価性は内容ではなくアイデンティティを比較します。
// .NET 11, EF Core 11 - owned entity mapping
var same = await context.Customers
.Where(c => c.BillingAddress == c.ShippingAddress) // not what you meant
.ToListAsync();
所有エンティティでは、これはフィールドごとの比較には変換されません。複合型なら、EF Core 11 は内容を比較し (特定の EF Core 11 のバグ修正の後は、ネストした複合型も含めて)、そのためクエリは「2 つのアドレスが本当に等しい」ことを意味します。
第三に、ExecuteUpdate は所有エンティティのプロパティをまったくサポートしませんが、複合型版は動作します。
// .NET 11, EF Core 11 - complex type mapping
await context.Customers
.Where(c => c.ShippingAddress.City == "Bucuresti")
.ExecuteUpdateAsync(s =>
s.SetProperty(c => c.ShippingAddress.PostalCode, "010001"));
コードがこの 3 つのいずれかにぶつかるなら、所有エンティティのマッピングはあなたと戦っており、修正は症状を回避することではなくマッピングを切り替えることです。
パフォーマンス: 見出し数値ではなく、追跡ノードと結合についての話
ここでチャートに載せるような劇的なスループットの差はありませんし、そういうものを見せてくる人は疑うべきです。本物の、構造的なパフォーマンスの違いは 2 か所にあります。
1 つ目は変更追跡です。所有エンティティは、EF Core が管理するシャドウキーとともに、変更トラッカー内の独自のノードとして追跡されます。複合型は独立したノードではなく、そのカラムはオーナーの一部として、カラム差分のレベルで追跡されます。集約ごとに多くの値オブジェクトを持つオブジェクトグラフでは、SaveChanges の際にスナップショットを取り、修復し、差分を取るエントリが少なくなります。この違いは通常エンティティごとには小さいですが、読み込む値オブジェクトの数に応じてスケールし、そして単純に帳簿付けが少ないので、厳密に複合型に有利です。
2 つ目は結合であり、それはストレージ上の理由で実際に選ぶであろう所有エンティティのケースにのみ当てはまります。OwnsOne(...).ToTable("Addresses") のマッピングは別テーブルに存在するので、オーナーをその値オブジェクトとともに読み込むことは結合です。テーブル分割された複合型は別テーブルを持たず、したがって結合もありません。もし単なる習慣で値オブジェクトを所有エンティティに移し、それがどのみちオーナーのテーブルに配置された (デフォルト) なら、両者はストレージ的に等価であり、追跡の違いだけが残ります。所有エンティティの目玉機能 (別テーブル) を実際に使った瞬間に、複合型が構造上回避する結合コストを引き受けることになります。より広い追跡コストの全体像については、EF Core 11 における AsNoTracking vs AsNoTrackingWithIdentityResolution に同じ力学が現れます。
つまり、正直なパフォーマンスの言明はこうです: 複合型は同等の同一テーブルの所有エンティティより決して遅くならず、追跡において構造的により軽量です。所有エンティティは、複合型にできない唯一のことのためにそれらを使うときに、まさに結合を引き受けます。
あなたの代わりに選択する落とし穴: EF Core のバージョンと null 許容のルール
好みに関係なく、あなたの代わりに決定を下しうるものが 2 つあります。
1 つ目はあなたの EF Core のバージョンです。上記のすべては EF Core 11 を前提としています。EF Core 9 以前では、複合型は TPT/TPC 継承を持つエンティティで使えず、ネストしたメンバーへの ExecuteUpdate にはバグがあり、ネストした複合型の比較は誤っており、ComplexCollection は存在しませんでした。EF Core 9 に固定されているなら、継承された値オブジェクトやコレクションについては所有エンティティが依然として実用的な選択かもしれず、アップグレードの一環として切り替えを計画すべきです。EF Core 6 から EF Core 11 への移行ガイド はこれと並んで表面化しがちな破壊的変更をカバーしています。また、EF Core 11 の UseSqlServer は互換性レベル 160 (SQL Server 2022) をデフォルトにするようになり、これは一部の JSON 変換に影響することに注意してください。
2 つ目は、オプショナルな値のルールです。オプショナル (null 許容) な複合型は、少なくとも 1 つの必須の、非 null 許容プロパティ を持たなければなりません。というのも、EF Core はそのカラムを使って「値全体が null」であることと「値は存在するがそのオプショナルなフィールドが null」であることを区別するからです。本当にすべてのフィールドが null 許容である値オブジェクトを持っている場合、オプショナルな複合型はビルドされず、判別子を追加するか、null 許容性を再考するか、所有エンティティにフォールバックすることになります。実際には、本物の Address や Money は常に必須のフィールドを持つので、これがかみつくことはめったにありませんが、これはあなたの手を所有エンティティへと強制しうる唯一のモデリング上の制約です。
クエリフィルターは両方で同じように振る舞います: グローバルまたは名前付きのフィルターは値オブジェクトではなくオーナー側のエンティティに定義されるので、論理削除とマルチテナンシーはどちらのマッピングを選んでも同一に動作します。それが気がかりなら、EF Core 11 における名前付きクエリフィルター vs 単一のグローバルクエリフィルター を参照してください。これは複合型と所有エンティティの間の差別化要因ではありません。
推奨、はっきりと述べる
EF Core 11 では、値オブジェクトにはデフォルトで複合型を使ってください。Address、Money、GeoPoint、DateRange、およびその同類を ComplexProperty でマッピングし、値のセマンティクスを無料で手に入れ、ExecuteUpdate、struct/record のサポート、きれいな等価性を享受してください。所有エンティティに切り替えるのは、物理的なスキーマがそれを要求するときだけです: 値が独自のテーブルに存在しなければならない、または値のコレクションが別々の子行として保存されなければならない場合です。そして、そのものがデータより長く生きる本物のアイデンティティを持つなら、それはそもそも値オブジェクトではなかったので、あなたが所有するキーを持つ本物の関連エンティティとしてモデリングしてください。
経験則は、record を class から分けるものと同じです: そのものがデータによって定義されるなら、それは値であり、値は複合型です。追跡する必要のあるアイデンティティを持つなら、それはエンティティです。EF Core 11 はついにそのメンタルモデルをフレームワークに一対一で対応させられるようにし、所有エンティティは常にそれが最も得意としてきた狭いストレージのケースのために予約されるようになりました。
関連する読み物
- EF Core 11 で所有エンティティの代わりに複合型をマッピングする方法 は、
OwnsOneからComplexPropertyへの移行を含む、完全なステップバイステップです。 - EF Core 11 で record を正しく使う方法 は、複合型としての record 対エンティティとしての record をより深く掘り下げます。
- EF Core 11 で JSON カラムをマッピングしてクエリする方法 は、両方のマッピングが共有する JSON ストレージのオプションをカバーします。
- ExecuteUpdate vs エンティティの読み込みと SaveChanges は、複合型が値オブジェクトのために解き放つ一括更新のパスを枠組みします。
- EF Core 11 でテーブル・パー・ハイアラーキー (TPH) 継承マッピングを構成する方法 は、オーナーが継承階層に位置するときの相棒です。
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.