実務Webアプリ開発編です。前回はPart V(永続化とCQRS)の初回として、エンティティ単体をEF Coreでテーブルにマッピングする方法(規約とFluent API)を見ました。

今回はその続きとして、エンティティ間の関連をマッピングします。C#の「参照」とテーブルの「外部キー」という、つながり方の違う2つの世界をどう橋渡しするかがテーマです。

以下のような方に役立つ内容となっています。

  • ナビゲーションプロパティと外部キーの関係がよく分かっていない
  • EF Coreがどうやってエンティティ間の関連を推論しているのか知りたい
  • DDDの集約で保護した子エンティティのコレクションを、EF Coreでどう永続化するのか知りたい

以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。

今回の内容は、ER図での関連の設計と、集約の設計が土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (13)データモデルを設計する ~エンティティと関連を定義する~ この記事は、実務Webアプリ開発編の一記事です。前回の「システム構成を設計する」では、アプリ全体の構成要素と役割の分け方を整理しました...
【C#/Blazor】実務Webアプリ開発編 (20)DDDのエンティティ・集約・集約ルートを実コードで理解する ~不変条件の守り方~ 実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。 今回はPart IV(ドメイン層の実...
【C#/Blazor】実務Webアプリ開発編 (21)DDDの集約境界の決め方と守り方 ~TopicとMessageの親子関係を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
プロ太

前回でエンティティ単体、つまりER図でいう「箱」はテーブルに対応付けられました。今回は箱と箱を結ぶ「線」=関連のマッピングです。

C#とデータベースでは「つながり方」の表現がまったく違います。その橋渡しの仕組みを一緒に見ていきましょう!

オブジェクトの参照とテーブルの外部キー

前回、オブジェクトの世界とテーブルの世界の「形の違い」を表で整理しました。

そこで「オブジェクト同士は参照でつながる ↔ テーブル同士は外部キーでつながる」という話がありましたが、今回はここを掘り下げます。

参照でつながる世界と外部キーでつながる世界

関連の持ち方にはさまざまなパターンがありますが、ここでは代表として、本とレビューのような親子(1対多)の関係を例に、2つの世界のつながり方の違いを比べてみます。

1対多の関連では、1件しか存在しない親(本)の側を「1側」、複数存在しうる子(レビュー)の側を「多側」とも呼びます。この呼び方は後半でも使います。

以下のようなイメージです。

C#の世界では、オブジェクト同士は参照でつながります。親が子の一覧(コレクション)を持つことも、子が親への参照を持つことも、その両方を持つこともできます。

一方、テーブルの世界では、1対多の関連は子側(多側)のテーブルにある外部キー(Foreign Key。以下、FK)列によって表現されます。

親テーブルの側には、子の一覧を保持する列はありません。

「1対多では、FKは必ず多側にある」——このルールは、この後の説明でもずっと効いてきます。

プロ美

え、C#では親が子のリストを持ってるのに、テーブルだと子側にしか関連の情報がないの?ずいぶん形が違うんだね。

プロ太

そうなんです。この非対称な2つの世界の橋渡しがEF Coreの仕事です。

そして前回と同じく、橋渡しの大半は規約が自動で推論してくれます。

関連の起点はナビゲーションプロパティ

では、この橋渡しをC#とEF Coreのコードでどう表すのでしょうか。「本」と、その本に付く「レビュー」(1対多)を例に、まずは最小の形から見てみます。

// 親(1側)が、子のコレクションを持つ
public class Book
{
    public int Id { get; set; }

    public string Title { get; set; } = "";

    // ナビゲーションプロパティ:この本に付いたレビューの一覧
    public List<Review> Reviews { get; set; } = [];
}

public class Review
{
    public int Id { get; set; }

    public string Text { get; set; } = "";
}
プロ美

え、FKはどこにも書いてないよ?これだけで本当に関連になるの?

プロ太

なります。このBook.Reviewsのような「参照の道」こそ、EF Coreが関連を見つける手がかりなんです。

Book.Reviewsのように、関連先のエンティティをオブジェクトとして辿るためのプロパティをナビゲーションプロパティと呼びます。

C#側だけにある「道」であり、これ自体はDBの列になりません。ここが初学者の混乱しやすいポイントです。

そして、規約で関連が成立する条件は「どちらか片側にナビゲーションプロパティがあること」です。

EF Coreはこの「参照の道」を見つけて1対多を推論し、テーブルの世界で必要になるFK列(BookId)は内部的に補ってくれます(シャドウプロパティと呼ばれる仕組みです)。

方向は逆でもかまいません。上の例の代わりに、子側にpublic Book Book { get; set; }という親への参照だけを持たせても、同じように関連が推論されます。

両側に持たせて、双方向に辿れるようにすることもできます。

IDで関連を扱いたいときはFKプロパティを足す

関連はナビゲーションプロパティだけで成立しますが、「IDだけで関連を作りたい」「関連先のIDを読み取りたい」という場合は、子側にFKの値を持つプロパティを足します。

たとえばWebアプリでレビューを投稿する場面を考えると、画面から届くのは「どの本か」を示すID(bookId=5のような値)であって、Bookオブジェクトではありません。

// Bookは先ほどのまま。子側にFKプロパティを足す
public class Review
{
    public int Id { get; set; }

    // FKプロパティ:親テーブルの主キーの値を持つ
    public int BookId { get; set; }

    public string Text { get; set; } = "";
}

このReview.BookIdのように、関連先の主キーの値そのものを持つプロパティをFKプロパティと呼びます。

FKプロパティも命名規約で発見されます。

{関連先のエンティティ名}Id(今回のBookId)、またはナビゲーションプロパティとペアで持つ場合は{ナビゲーション名}Idという名前であれば、規約がFKとして認識します。

FKプロパティがあると、new Review { BookId = 5, ... }を保存するだけで関連を作れて、関連付けのためにBookをDBから読み込む必要がありません。

DBから読み込むときも、Review単体から「どの本のレビューか」が分かります。

ナビゲーションプロパティ・FKプロパティを整理

ここまでの2種類のプロパティの役割を整理すると次のとおりです。

種類表すもの規約による関連作成
ナビゲーションプロパティ
(親側)
List<Review> Reviews子の一覧をオブジェクトとして辿る道作成される
ナビゲーションプロパティ
(子側)
Book Book親をオブジェクトとして辿る道作成される
FKプロパティ
(子側のみ)
int BookId親の主キーの値作成されない
(Fluent APIで明示的に定義必要)

ナビゲーションプロパティを持たず、FKプロパティだけを書いても、規約による関連にはなりません列は作られますが、関連にはFluent APIでの明示が必要です。

プロ太

あくまで規約としての関連発見の起点はナビゲーションプロパティ、というわけですね。

なお、1対1や多対多などほかの関連パターンでも、規約においてナビゲーションプロパティが関連の起点になる考え方は同じです。

全体像は公式ドキュメントのリレーションシップの概要にまとまっています。

ミニプロジェクトで動かす

規約による関連の推論を、前回と同じく小さなコンソールアプリで体感してみます。

今回は関連だけを見るために、最小のプロジェクトを新しく作ります(前回の値オブジェクトなどは持ち込みません)

データベースは前回と同じLocalDBを使うので、準備作業は不要です。コンソールアプリにMicrosoft.EntityFrameworkCore.SqlServerパッケージを追加すれば動きます。

コードはGitHubの26_EFCore関連マッピング基礎に置いてあります。

BookとReviewの1対多を最小構成で作る

エンティティは、以下のBookReviewを使います。

public class Book
{
    public int Id { get; set; }

    public string Title { get; set; } = "";

    // ナビゲーションプロパティ
    public List<Review> Reviews { get; set; } = [];
}

public class Review
{
    public int Id { get; set; }

    // FKプロパティ: 規約により Books テーブルへのFK列になる
    // (ただし、別途ナビゲーションプロパティがないとただの列になってしまう)
    public int BookId { get; set; }

    public string Text { get; set; } = "";
}

もう1つの登場クラスがAppDbContextです。

using Microsoft.EntityFrameworkCore;

namespace EfCoreRelationshipBasics;

public class AppDbContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Review> Reviews => Set<Review>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer(
            "Server=(localdb)\\MSSQLLocalDB;Database=EfCoreRelationshipBasics;Trusted_Connection=True;TrustServerCertificate=True");
}

注目してほしいのは、関連に関する設定が1行もないことです。あるのはDbSetが2つと接続先だけです。

設定ゼロでFK付きテーブルができる

前回と同じく、実行のたびにEnsureDeletedEnsureCreatedでデータベースを作り直し、生成されるテーブル定義を表示してみます。

Books、Reviewsテーブルは次のようになりました。

=== 1. 生成されたテーブル定義を確認する ===
CREATE TABLE [Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id])
);
GO


CREATE TABLE [Reviews] (
    [Id] int NOT NULL IDENTITY,
    [BookId] int NOT NULL,
    [Text] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Reviews] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Reviews_Books_BookId] FOREIGN KEY ([BookId]) REFERENCES [Books] ([Id]) ON DELETE CASCADE
);
GO


CREATE INDEX [IX_Reviews_BookId] ON [Reviews] ([BookId]);
GO

BookId列にFK制約(FOREIGN KEY ... REFERENCES [Books])が自動で付いています。命名規約に合わせただけで、EF Coreが1対多の関連を推論した結果です。

FK列を検索で使うことが多いため、インデックス(IX_Reviews_BookId)も自動で作られています。細かな配慮まで規約に含まれているわけです。

プロ太

もう1つ、ON DELETE CASCADEという句が付いていることにも気づいたでしょうか。

「親が消えたら子はどうなるか」を決めるこの句が、次回の主役です。今回は「規約ではこうなる」とだけ覚えて先へ進みます。

Bookを保存してReviewごと読み戻す

次に、Reviewを2件持つBookを保存してみます。

// Program.cs(抜粋)
await using (var db1 = new AppDbContext())
{
    // ...(EnsureDeleted/EnsureCreatedでDBを作り直す処理など)

    Book book = new Book { Title = "C#プログラミング入門" };
    book.Reviews.Add(new Review { Text = "とても分かりやすかった" });
    book.Reviews.Add(new Review { Text = "サンプルコードが豊富" });
    db1.Books.Add(book); // 親をAddすると、コレクション内の子も一緒に保存される
    await db1.SaveChangesAsync();
}

コードのどこにもBookIdを設定していませんが、親をAddしてSaveChangesすると、コレクション内の子も一緒に保存され、FK列にも親の主キーが入ります

テーブルの中身を確認してみましょう。Visual StudioのSQL Serverオブジェクトエクスプローラーなどで確認できます。

次にDBからBookデータを読み込んでみましょう。「Include」の有り無しという2通りの方法で読み込んでいます。

Console.WriteLine("=== 3. Includeなしで読むと、関連は読み込まれない ===");
// 保存に使ったDbContextは保存したオブジェクトを覚えているため、
// 「DBから読み戻す」動きを確認するには別のDbContextを使う
await using (var db2 = new AppDbContext())
{
    Book withoutInclude = await db2.Books.SingleAsync();
    Console.WriteLine($"{withoutInclude.Title} / Reviews.Count = {withoutInclude.Reviews.Count}(空のまま)");
}

Console.WriteLine();
Console.WriteLine("=== 4. Includeで、Reviewごと一気に読み戻す ===");
await using (var db3 = new AppDbContext())
{
    Book loaded = await db3.Books
        .Include(b => b.Reviews) // 親子をまとめて(=集約を単位に)読み出す
        .SingleAsync();
    Console.WriteLine($"{loaded.Title} / Reviews.Count = {loaded.Reviews.Count}");
    foreach (Review review in loaded.Reviews)
    {
        Console.WriteLine($"  - {review.Text}");
    }
}

実行結果は次のとおりです。

=== 3. Includeなしで読むと、関連は読み込まれない ===
C#プログラミング入門 / Reviews.Count = 0(空のまま)

=== 4. Includeで、Reviewごと一気に読み戻す ===
C#プログラミング入門 / Reviews.Count = 2
  - とても分かりやすかった
  - サンプルコードが豊富
プロ美

あれ、「.Include(b => b.Reviews)…」ってつけないとBookの子要素であるReviewのコレクションは読み込まれないんだね?

プロ太

その通りです!そのまま読むとReviewsは空です。

Includeを付けたときだけ、FKでつながった行がナビゲーションのコレクションに詰め直されて返ってきます。

親子をまとめて、つまり集約を単位に一気に読み戻すイメージです。
(BookとReviewは、Bookを集約ルートとする集約とみなせますね。)

必要な関連だけを明示して読む」のがEF Coreの既定の考え方です。

命名やプロパティの型、アクセシビリティなどによって、EF Coreの規約がどこまで適用されるかは意外と細かく決まっています。

公式ドキュメントだけで理解しようとするよりも、このような小さなプロジェクトを作って実際に試してみると、理解が深まるでしょう。

DbContext.Database.GenerateCreateScriptメソッドで生成されるテーブル定義をみると、「EF Coreがどう解釈したのか」をSQLレベルで確認できます。

MentorAppの実装をみる

ここからは、MentorAppの実コードで関連のマッピングを見ていきます。登場するファイルの位置関係は次のとおりです。

src/
├─ MentorApp.Domain/
│  └─ Models/
│     ├─ Users/User.cs
│     ├─ Mentorships/Mentorship.cs
│     └─ Topics/Topic.cs・Message.cs
└─ MentorApp.Infrastructure/
   └─ Persistence/
      └─ AppDbContext.cs   ← 関連のマッピング設定(今回の主役)

マッピング設定は前回と同じくAppDbContext.csに集約されています。

第13回で設計したER図を思い出してください。あの図の線1本1本が、コードでどう表現されているかを確認していきます。

4つの関連の全体像

MentorAppには4種類(本数では5本)の関連があります。ER図の線はさまざまに見えますが、実は多重度はすべて1対多です。したがって、FK列は必ず多側のテーブルにあります。

では関連ごとに何が違うのかというと、「集約の内側の関連か、集約をまたぐ参照か」です。FKプロパティとナビゲーションプロパティの持ち方を整理すると、次のとおりです。

関連
(多側→1側)
集約FKプロパティナビゲーションプロパティ
Message → Topic同一集約
(Topicが親)
TopicIdTopic.Messages
(1側から多側へ)
Mentorship → User別集約
(線は2本)
MentorUserId
MenteeUserId
Mentorship.MentorUser
Mentorship.MenteeUser
(多側から1側へ)
Topic → Mentorship別集約MentorshipIdTopic.Mentorship
(多側から1側へ)
Message → User別集約SenderUserIdMessage.SenderUser
(多側から1側へ)

5本すべての関連は、Fluent APIなしでも成立します。

まず、どの関連にもナビゲーションプロパティがあるため、EF Coreはそこから1対多の関連を推論できます。FKプロパティがなくても、関連自体は成立します。

そのうえで、MentorUserIdなどは{ナビゲーション名}Idまたは{1側のエンティティ名}Idという命名規約に合っているため、その関連のFKプロパティとして認識されます。

MentorAppでは、関連付けそのものをIDで表し、そのIDを検索や認可判定にも使うため、FKプロパティを明示的に定義しています。

まず関連付けです。Mentorshipの作成では、new Mentorship(mentorUserId, menteeUserId, ...)のように、Userオブジェクトを読み込まずIDだけで関連付けます。

「そのIDのUserが本当に存在するのか」という確認は、コンストラクタではなく、作成処理の側でDBに問い合わせて事前に行います。

検索では、リポジトリがMentorUserIdなどのFKプロパティを条件に、対象のユーザーが参加しているMentorshipを探します。

認可判定では、MentorshipのIsParticipantメソッドが、ログインユーザーのIDをMentorUserIdMenteeUserIdと比べて、操作を許可できる参加者かを判定します。

ナビゲーションプロパティは、必要な関連データをクエリで辿るために使います。

集約内のTopic.MessagesIncludeで読み込み、別集約へのMentorUserなどは表示用クエリのSelectで名前を取得するために使われています。

プロ太

整理すると、MentorAppは「集約の内側はナビゲーションで辿り、集約をまたぐ参照はIDで持つ」が原則です。

別集約へのナビゲーションは、表示用クエリの補助として限定的に使っています。

AppDbContextに書いているのは削除動作

では、規約だけで関連が成立するのに、AppDbContextHasManyHasOneがあるのはなぜでしょうか。(HasMany・HasOneはFluent APIによる明示的な関連の指定です)

// 同一集約:Topicと一緒にMessageも削除する
entity.HasMany(e => e.Messages)
    .WithOne()
    .OnDelete(DeleteBehavior.Cascade);

// 別集約:Userを削除してもMentorshipは削除しない
entity.HasOne(e => e.MentorUser)
    .WithMany()
    .OnDelete(DeleteBehavior.Restrict);

HasManyHasOneは、OnDeleteを設定する関連を指定しているだけです。

関連を成立させるための設定ではありません。同一集約では連鎖削除を明示し、別集約では連鎖削除を防いでいます。詳しくは次回扱います。

保護したコレクションも規約で扱える

第21回で、Topic.Messagesは外から追加・削除できないように保護しました。Topic.csの実装は次のとおりです。

// Topic.cs(Domain層)
public class Topic
{
    private readonly List<Message> _messages = [];

    public IReadOnlyList<Message> Messages => _messages.AsReadOnly();
}

_messagesEF Coreのためではなく、メッセージの追加をPostMessage経由に限るためのフィールドです。

EF Coreはこれをバッキングフィールドとして規約で認識できるため、集約の保護を崩すような公開setterや追加のマッピング設定は必要ありません。

Domain層へのEF Core都合の染み出し

Domain層はEF Coreを参照せず、マッピング設定はInfrastructure層に置いているため、Domain層のモデルは技術詳細に依存せず純粋です。

ただ、EF Core都合が少し染み出している部分もあります。EF Core用のprivateコンストラクタや、別集約へのナビゲーションプロパティMentorship.MentorUserなど)です。

別集約への参照はID参照が原則でした。それでもナビゲーションプロパティを持たせているのは、表示用クエリで関連先を辿れる利便性のためです。

さらに極論すると、規約に関連を発見してもらうために{ナビゲーション名}Idのような命名を選ぶこと自体、すでにEF Core都合ともいえるでしょう。

プロ太

ここらへんはこのシリーズで何度もお伝えしているように、クリーンアーキテクチャ・DDDの原則とEF Coreの利便性のバランスをとった結果です。

まとめ

今回は、エンティティ間の関連をEF Coreでマッピングする方法を見てきました。重要なポイントは以下です。

  • C#は参照でつながり、テーブルは(1対多では)多側のFK列でつながる。この非対称な橋渡しの大半は規約が推論してくれる
  • ナビゲーションプロパティは関連を辿る道、FKプロパティはIDで関連を扱う手段。{ナビゲーション名}Idの命名規約で両者が結びつく
  • 関連データは既定では読み込まれない。Includeで集約を単位に一気に読み戻せる
  • MentorAppは集約の内側をナビゲーションで辿り、集約をまたぐ参照はID参照を原則とする

そして、MentorAppの5本の関連は規約だけで成立し、AppDbContextのFluent APIは削除動作の指定にだけ使われていました。この指定は省略できるものではありません。

次回は、今回引いた関連の線1本1本に「関連先の行が消えたら、それを参照する行はどうなるか」(削除ルール:Cascade/Restrict)を決めていきます。

プロ太

関連の「線」は引けました。集約の内外に応じてナビゲーションとFKの使い方を決め、単純なマッピングは規約に任せる——これが今回の軸でした。

引き続き、EF Coreの削除ルールについて一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。