【C#/Blazor】実務Webアプリ開発編 (25)EF Coreでエンティティをテーブルにマッピングする ~DbContext・規約・Fluent APIの基本~
実務Webアプリ開発編です。前回まで、Part IV(ドメイン層の実装)として、値オブジェクト・集約・ドメインサービスの実装とテストを見てきました。
今回からPart V(永続化とCQRS)に入ります。完成したドメインモデルを、データベースにどうつなぐかがテーマです。
最初の題材は、エンティティとテーブルの対応付け(O/Rマッピング)です。今回はエンティティ単体の対応付けを扱い、次回はエンティティ間の関連へ進みます。
第20回・第21回で集約を「単体→親子関係」と二段階で見たのと、同じ進み方です。
以下のような方に役立つ内容となっています。
- EF Coreを使ったことはあるが、規約とFluent APIの役割分担がよく分かっていない
- 値オブジェクトやenumをテーブルにどう保存するのか知りたい
- エンティティのプロパティとDBスキーマの対応をきれいに保つ方法を知りたい
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
今回の内容は、値オブジェクト・エンティティ・リポジトリの抽象が土台になります。以下の記事とつなげて読むと理解しやすいです。
Part IVで完成させたドメインモデルを、いよいよデータベースにつなぐ段階に入ります。
まずは「純粋なC#のクラスが、どうやってテーブルと対応するのか」から見ていきましょう。
なぜマッピング設定が必要か
前回までで完成させたDomain層のクラスは、外部技術に依存しない純粋なC#のコードでした。UserもEmailも、データベースのことは何も知りません。
一方で、アプリのデータはデータベースに保存しなければなりません。ここで問題になるのが、オブジェクトとテーブルでは「形」が違うことです。
オブジェクトの世界とテーブルの世界は形が違う
まず、マッピングの仕組みを使わない場合を見てみます。.NETにはADO.NETという低レベルのDBアクセスAPIがあり、SQLと結果の変換をすべて手書きします。
// ADO.NETの場合:SQLと変換をすべて手書きする
using var command = new SqlCommand(
"SELECT Id, Title, Price FROM Books WHERE Id = @id", connection);
command.Parameters.AddWithValue("@id", id);
using var reader = await command.ExecuteReaderAsync();
Book? book = null;
if (await reader.ReadAsync())
{
// 列の位置と型を1つずつ手動で対応付ける
book = new Book
{
Id = reader.GetInt32(0),
Title = reader.GetString(1),
Price = reader.GetInt32(2),
};
}SQL文・列の位置・型変換をすべて自分で管理することになります。列を1つ追加すると、SQLと変換コードの手書き箇所すべてに修正が波及します。
実務では、素のADO.NETではなくDapperなどの軽量マッパーで手間を減らすことも多いですが、SQLと対応付けを自分で管理するという本質は変わりません。
EF Coreを使うと、同じ処理は次のように書けます。
// EF Coreの場合:SQLも手動の変換コードも書かない
Book? book = await db.Books
.SingleOrDefaultAsync(b => b.Id == id);SQLも手動マッピングも登場しません。オブジェクトとテーブルの対応付けは、後ほど見るDbContextという1か所に集約されます。
とはいえ、EF Coreが何もかも自動で解決してくれるわけではありません。オブジェクトの世界とテーブルの世界には、次のような違いがあるためです。
| C#(オブジェクトの世界) | DB(テーブルの世界) |
|---|---|
Emailのような独自型がある | あるのはnvarcharなどの基本型だけ |
enum(Roleなど)がある | intや文字列で持つしかない |
| 文字列に長さの決まりがない | 列には最大長の指定がある |
| IDをいつ・どう生成するかは自由に設計できる | 主キーはDBが採番することが多い |
| オブジェクト同士は参照でつながる | テーブル同士は外部キーでつながる |
最後の「参照と外部キー」はエンティティ間の関連の話なので、次回扱います。今回はエンティティ単体に関わる上の4つが対象です。
EF Coreが「翻訳」を引き受ける
このような世界の違いを埋めて、オブジェクトとテーブルを相互に変換する仕組みをO/Rマッピング(Object-Relational Mapping)と呼びます。

EF Core(Entity Framework Core)は、.NETの代表的なO/Rマッパーです。MentorAppでは、第12回の技術スタック選定で採用を決めていました。
重要なのは、この翻訳の設定をドメインモデル側ではなく、EF Core側に持たせることです。
ドメインモデルは純粋なC#のまま変えず、世界の違いはEF Coreの設定が1か所で引き受けます。
EF Coreのマッピングの基本
それでは、EF Coreがどうやってクラスとテーブルを対応付けるのかを見ていきます。登場人物はDbContext・規約・Fluent APIの3つです。
DbContextとDbSet
DbContextは、「どのクラスを、どのテーブルに、どう保存するか」を知っている翻訳係です。アプリはDbContextを通してデータベースを読み書きします。
DbSet<T>は、テーブルに対応する入口です。LINQで問い合わせたりAddで追加したりすると、EF CoreがSQLに翻訳して実行してくれます。
public class AppDbContext : DbContext
{
// Booksテーブルへの入口
public DbSet<Book> Books => Set<Book>();
}規約(Convention)でここまで決まる
EF Coreには、何も設定しなくても、名前や型からマッピングを推論する仕組みがあります。これを規約(Convention)と呼びます。
代表的な規約は次のとおりです。
| 決まること | 規約の内容 |
|---|---|
| テーブル名 | DbSetのプロパティ名(Booksなど) |
| 主キー | Idという名前のプロパティ(int等なら自動採番も付く) |
| 列名 | プロパティ名がそのまま列名になる |
| 列の型 | C#の型から推論(string→nvarchar等) |
| NULL許容 | null許容型(string?等)ならNULL可の列になる |
つまり、素直な名前と型でクラスを書いていれば、設定ゼロでもかなりの部分が自動的に決まるわけです。
規約でそこまで決まるなら、マッピングの設定って何も書かなくていいの?
規約が推論できるのは「名前と型から分かる範囲」だけです。独自型の変換や最大長のように、規約で表せないものだけを明示的に設定します。
規約で足りない部分をFluent APIで設定する
規約で決まらない部分を設定する手段がFluent APIです。DbContextのOnModelCreatingメソッドの中に、メソッドチェーンの形で設定を書いていきます。
代表的な設定には、例えば次のようなものがあります(いずれも本記事に登場します)。
HasConversion:独自型(値オブジェクト等)とDBの基本型との変換HasMaxLength:文字列列の最大長HasIndex/IsUnique:インデックスと一意制約ValueGeneratedNever:ID生成のタイミング(DBの自動採番を使わない)
補足:属性(Data Annotations)による設定
なお、同じような設定を属性(Data Annotations)でエンティティに直接書く方法もあります。
public class Book
{
public int Id { get; set; }
[MaxLength(100)] // 属性でエンティティに直接書く
public string Title { get; set; } = "";
}ただし属性方式では、Domain層のクラスにDBスキーマの都合が入り込みます。
設定をDbContext側(Infrastructure層)に寄せれば、ドメインモデルを汚さずに済み、クリーンアーキテクチャの依存方向とも整合します。
本記事(とMentorApp)では、マッピング設定はすべてInfrastructure層のDbContextに置く方針で進めます。
ミニプロジェクトで動かす
規約とFluent APIの役割分担を、まず小さなコンソールアプリで体感してみます。
データベースは、MentorApp本体と同じSQL Serverを使います。Visual Studioに付属するLocalDB(開発用の軽量なSQL Server)につなぐので、DBの準備作業は不要です。
コンソールアプリのプロジェクトを作り、NuGetでMicrosoft.EntityFrameworkCore.SqlServerパッケージを追加すれば準備完了です。
コードはGitHubの25_EFCoreマッピング基礎に置いてあります。このプロジェクトは完成コードを解説していくスタイルで進めます。
(1)テーブル定義の確認、(2)保存、(3)読み出しの3ステップに分けて要点を見ていきます。
Program.csの出力の見出し番号(1・2・3)と対応しているので、手元で実行しながら読み進められます。
コードの全体像
登場するクラスは3つです。まず、エンティティのBookです。
namespace EfCoreMappingBasics;
public class Book
{
// 規約: 「Id」という名前のプロパティが主キーになる(intなら自動採番)
public int Id { get; set; }
// 規約: プロパティ名がそのまま列名になり、C#の型からDBの型が推論される
public string Title { get; set; } = "";
public int Price { get; set; }
// 独自型: 規約では変換できない → AppDbContextでHasConversionを設定
public Isbn Isbn { get; set; } = null!;
}次に、独自型のIsbnです。第19回で作ったEmail型のミニ版で、「不正な値のインスタンスは作れない」というルールを型に閉じ込めた、小さな値オブジェクトです。
namespace EfCoreMappingBasics;
/// <summary>
/// ISBN値オブジェクト(ハイフン付き13桁)
/// </summary>
public sealed record Isbn
{
public string Value { get; }
public Isbn(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("ISBNは必須です。");
var digits = value.Replace("-", "");
if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
throw new ArgumentException($"ISBNの形式が不正です: {value}");
Value = value.Trim();
}
public override string ToString() => Value;
}最後にDbContextです。マッピングの設定は、OnModelCreatingにある独自型Isbnの変換1つだけです。
「保存するときはstringへ、読み出すときはIsbnへ」という変換をHasConversionで指定しています。
using Microsoft.EntityFrameworkCore;
namespace EfCoreMappingBasics;
public class AppDbContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlServer(
"Server=(localdb)\\MSSQLLocalDB;Database=EfCoreMappingBasics;Trusted_Connection=True;TrustServerCertificate=True");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(entity =>
{
// 保存時: Isbn → string / 読み出し時: string → Isbn の変換を指定
entity.Property(e => e.Isbn)
.HasConversion(
isbn => isbn.Value,
value => new Isbn(value));
});
}
}Id・Title・Priceは規約だけでマッピングでき、規約で表せないのはIsbnの変換だけ。この役割分担を、実行結果で確かめていきます。
(1) 生成されたテーブル定義を確認する
Program.csでは、まずデータベースを作り直して、EF Coreが生成するテーブル定義(CREATE TABLE文)を表示しています。
// Program.cs(抜粋)
// 何度でも実行できるよう、毎回データベースを作り直す
await db1.Database.EnsureDeletedAsync();
await db1.Database.EnsureCreatedAsync();
Console.WriteLine("=== 1. 生成されたテーブル定義を確認する ===");
Console.WriteLine(db1.Database.GenerateCreateScript());実行結果は次のとおりです。
=== 1. 生成されたテーブル定義を確認する ===
CREATE TABLE [Books] (
[Id] int NOT NULL IDENTITY,
[Title] nvarchar(max) NOT NULL,
[Price] int NOT NULL,
[Isbn] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Books] PRIMARY KEY ([Id])
);
GOこの1つのテーブル定義に、規約とFluent APIの役割分担がそのまま現れています。まず、規約によって次のことが決まっています。
- テーブル名は
Books(DbSetのプロパティ名) Idが主キーになり、IDENTITY(DBの自動採番)が付く- プロパティ名が列名になり、
stringはnvarchar(max)、intはintに推論される
一方、[Isbn] nvarchar(max)の列だけは規約では作れません。Fluent APIで「stringに変換して保存する」と指定したことで、初めて文字列の列として作られています。
IdにDBの自動採番が付いている点は、あとでMentorAppと比べるので覚えておいてください。
なおEnsureCreatedAsyncはモデル定義から直接テーブルを作る手軽な方法です。
もしHasConversionの設定を書き忘れたら、どうなるの?
実行時に例外になります。EF Coreも「Isbnをどの型でどう保存するか」までは推論できないんです。
試しに設定を消して実行すると、次の例外が発生します。
System.InvalidOperationException: 'No suitable constructor was found for the type 'Isbn'. The following constructors had parameters that could not be bound to properties of the type:
Cannot bind 'value' in 'Isbn(string value)'
Cannot bind 'original' in 'Isbn(Isbn original)'
Note that only mapped properties can be bound to constructor parameters. Navigations to related entities, including references to owned types, cannot be bound.'(2) Bookを保存する
続いて、Bookを1件保存します。あわせて、DBに入っている生の値をあえてSQLで直接取り出して確認します。
// Program.cs(抜粋)
Console.WriteLine("=== 2. Bookを保存する ===");
Book book = new Book
{
Title = "C#プログラミング入門",
Price = 2800,
Isbn = new Isbn("978-4-1234-5678-9"),
};
db1.Books.Add(book);
await db1.SaveChangesAsync();
Console.WriteLine($"保存しました: Id={book.Id}(DBが自動採番した値)");
// DBに入っている生の値を確認するため、ここだけあえてSQLで直接取り出す
string rawIsbn = (await db1.Database
.SqlQuery<string>($"SELECT Isbn FROM Books")
.ToListAsync()).Single();
Console.WriteLine($"DB上の生の値: \"{rawIsbn}\"(ただの文字列として保存されている)");実行結果は次のとおりです。
=== 2. Bookを保存する ===
保存しました: Id=1(DBが自動採番した値)
DB上の生の値: "978-4-1234-5678-9"(ただの文字列として保存されている)C#側ではIsbn型のオブジェクトを渡しただけですが、DB側にはただの文字列として保存されています。
SQLも手動の変換コードも書いていません(生の値の確認だけ、あえてSQLを使っています)。
DBの中身を実際にみてみるとより理解が深まります。例えば、Visual Studioメニューの「表示>SQL Server オブジェクトエクスプローラー」で確認できます。

(3) 読み出すとIsbnは独自型に戻っている
最後に、別のDbContextで読み直して、DBからの往復を確認します。
// Program.cs(抜粋)
Console.WriteLine("=== 3. 読み出すとIsbnは独自型に戻っている ===");
// 別のDbContextで読み直し、DBからの往復を確認する
await using (var db2 = new AppDbContext())
{
Book loaded = await db2.Books.SingleAsync();
Console.WriteLine($"{loaded.Title} / {loaded.Price}円");
Console.WriteLine($"Isbn: {loaded.Isbn}(C#上の型: {loaded.Isbn.GetType().Name})");
}実行結果は次のとおりです。
=== 3. 読み出すとIsbnは独自型に戻っている ===
C#プログラミング入門 / 2800円
Isbn: 978-4-1234-5678-9(C#上の型: Isbn)読み出したIsbnは、ただの文字列ではなく独自型Isbnに戻っています。C#側では終始Isbn型のまま、DB側にはただの文字列として保存されているわけです。
この「C#の型はそのまま、変換はDbContextが1か所で引き受ける」という感覚が、O/Rマッピングの核心です。
ここからは、同じパターンをMentorAppの実コードで確認していきます。
MentorAppの実装をみる
MentorApp本体のマッピング実装を見ていきます。
今回関係するファイルは次のとおりです。エンティティ間の関連は次回扱うため、今回はUserを中心に見ていき、enumのマッピングだけMentorshipを例に取り上げます。
src/
MentorApp.Domain/
Models/
Users/
User.cs // エンティティ(最大長の定数もここ)
Email.cs // 値オブジェクト
Role.cs // enum
Mentorships/
Mentorship.cs // エンティティ
MentorshipStatus.cs // enum
…
MentorApp.Infrastructure/
Persistence/
AppDbContext.cs // 本記事の主役:DbSetとFluent API設定
PersistenceOptions.cs // DB接続設定の定義
PersistenceExtensions.cs // DI登録とプロバイダ切り替え
主役はAppDbContext.csです。あわせてUser.csとEmail.csも参照しながら進めます。
なお、AppDbContextはinternalで宣言されており、Infrastructure層の外からは直接触れません。第17回で見た「プロジェクト境界をコンパイラに強制させる」構成の実例です。
AppDbContextの全体構成
まず全体の骨格です。DbSetの定義と、エンティティごとの設定メソッドの呼び出しでできています。
internal class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<Mentorship> Mentorships => Set<Mentorship>();
public DbSet<Topic> Topics => Set<Topic>();
public DbSet<Message> Messages => Set<Message>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureUser(modelBuilder);
ConfigureMentorship(modelBuilder);
ConfigureTopic(modelBuilder);
ConfigureMessage(modelBuilder);
}
// ConfigureUser などの実装は後述
}DbSetは4つあります。第13回のデータモデル設計(ER図)で定義した4つのエンティティ(User・Mentorship・Topic・Message)と、そのまま対応しています。
クラスコメントには、設定の方針が一言で書かれています。
EF Coreの規約を重視し、規約で設定できないものはFluent APIで設定する。(AppDbContext.cs クラスコメントより)
ミニプロジェクトで体感した方針そのままです。以降はConfigureUserを中心に読んでいきます。
なお、コード上にはHasOneやOnDeleteといったエンティティ間の関連の設定も見えますが、これは次回の題材なので今回は飛ばします。
値オブジェクトのマッピング(HasConversion)
ConfigureUserの中で、値オブジェクトのEmailは次のように設定されています。
entity.Property(e => e.Email)
.HasMaxLength(Email.MaxLength)
.HasConversion(
email => email.Value,
value => new Email(value));保存時はemail.Valueでstringに変換し、読み出し時はnew Email(value)でEmail型に戻します。ミニプロジェクトのIsbnとまったく同じパターンです。
ここで注目したいのは、読み出し時にEmailのコンストラクタを通ることです。
コンストラクタにはバリデーションがあるので、DBから読んだ値も検証を通過した値オブジェクトになります。
第19回で見た「型が存在している時点で検証済み」という保証が、データベースを経由しても崩れないわけです。
最大長とユニークインデックス ~定義元はDomain層の1か所~
続いて、文字列プロパティの最大長です。ConfigureUserでは次のように設定されています(ユニーク制約の設定は後述します)。
entity.Property(e => e.ExternalId)
.HasMaxLength(User.ExternalIdMaxLength);
entity.Property(e => e.DisplayName)
.HasMaxLength(User.DisplayNameMaxLength);注目すべきは、255のような数値の直書きではなく、Domain層の定数を参照していることです。定数の定義元はこちらです。
// User.cs(Domain層)
public class User
{
public const int ExternalIdMaxLength = 255;
public const int DisplayNameMaxLength = 100;
// ...
}
// Email.cs(Domain層)
public sealed record Email
{
/// <summary>
/// メールアドレスの最大長(RFC 5321準拠)
/// </summary>
public const int MaxLength = 254;
// ...
}この定数は、第19回・第20回で見たバリデーションでも使われているものです。つまり、「バリデーションのルール」と「DBの列定義」の定義元が1か所になっています。
定義が二重化していると、「バリデーションは通ったのにDBに入らない」「片方だけ長さを変えて食い違う」といった事故が起きがちです。
定数を1か所にすることで、この種のズレを構造的に防いでいます。

もう1つ、ExternalIdにはHasIndex().IsUnique()でユニークインデックス(一意制約)が付いています。
// ExternalIdはIdPが発行するユーザー識別子。
// 同一ユーザーの二重登録を防ぐため、DB側でも一意性を保証する
entity.HasIndex(e => e.ExternalId)
.IsUnique();ExternalIdはIdP(外部の認証基盤)が発行するユーザー識別子です。
同じ利用者が誤って二重登録されることのないよう、アプリ側のロジックだけでなくDB側でも一意性を保証しています。
enumのマッピング
enumはDBに直接対応する型がないため、intか文字列に変換して保存します。
MentorAppでは、メンタリング関係の状態を表すMentorshipStatusを含め、すべてのenumを規約に任せてint保存にしています。
// MentorshipStatus.cs(Domain層)
public enum MentorshipStatus
{
Active = 0, // 進行中
Completed = 1, // 完了
Cancelled = 2 // キャンセル
}実は、enumは規約でもintに変換されます。UserのRoleもMentorshipのStatusも、明示設定を持たず規約に任せています。
AppDbContextには、enumのプロパティに対するHasConversionの設定は一切登場しません。
int保存はコンパクトですが、DBを直接覗いたときに0や1の意味が分かりません。
可読性を重視するなら、文字列保存(HasConversion<string>())という選択肢もあります。
MentorAppは、規約どおりのint保存を選んでいます。
IDはアプリ側で生成する(ValueGeneratedNever)
4つのエンティティすべてに、次の設定があります。
// Id はアプリ側で生成されるため、ValueGeneratedNeverを指定
entity.Property(x => x.Id).ValueGeneratedNever();主キーって、ミニプロジェクトのBookみたいにDBが番号を振ってくれるんじゃないの?
それも1つの方法です。ただMentorAppでは、IDはドメインモデルが自分で生成する設計でしたね。
第20回で見たコンストラクタを思い出してみましょう。
// User.cs(Domain層)のコンストラクタ
public User(string? externalId, string? displayName,
DateTimeOffset createdAt, string? email, Role role = Role.Mentee)
{
Validate(externalId, displayName, email).ThrowIfInvalid();
Id = Guid.NewGuid(); // IDはここで生成される
// ...
}Userは生成された瞬間に自分のIDを持ちます。DBに保存する前からIDが確定しているので、集約の組み立てやテストがやりやすくなるのでした(第20回)。
一方、EF Coreの規約では主キーは「追加時に値が生成されるもの」と扱われます。
そこでValueGeneratedNeverで、「生成はアプリ側がやるので、EF CoreやDBは関与しなくてよい」と伝えているわけです。
ドメイン層にEF Core対応のための仕掛け
エンティティ側にはEF Coreのための小さな仕掛けがあります。
// EF Core 用
private User() { }これは、EF CoreがDBから読み出した行をUserオブジェクトに組み立てるときに使う裏口です。
privateなので、アプリのコードが引数なしでUserを作ることは引き続きできません。
プロパティ定義の= null!;も同じ理由です。「EF Coreが読み出し時に必ず値を入れる」という前提で、コンパイラのnull警告を抑えるための書き方です。
これは以前にもお話した、ドメインモデルへEF Core都合が少し滲み出している部分ですね。実務的な割り切りとして行っています。
DBプロバイダの切り替え
最後に、どのデータベース製品につなぐかの設定をざっと見ておきます。MentorAppではappsettings.jsonのPersistenceセクションで指定します。
"Persistence": {
"Provider": "SqlServer",
"Providers": {
"SqlServer": {
"ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=MentorApp;..."
}
}
}この設定はPersistenceOptions.csのクラスにバインドされ、プロバイダはSqlServer・Sqlite・InMemoryの3種類から選べます。
実際の切り替えは、DI登録箇所(PersistenceExtensions.cs)のswitch文1か所で完結しています。
switch (databaseOptions.Provider)
{
case DatabaseProviders.Sqlite:
ConfigureSqlite(options, databaseOptions);
break;
case DatabaseProviders.SqlServer:
ConfigureSqlServer(options, databaseOptions);
break;
case DatabaseProviders.InMemory:
ConfigureInMemory(options, databaseOptions);
break;
// ...
}開発・本番はSQL Server、テストはInMemoryまたはSQLiteという使い分けです。どのプロバイダを選んでも、ドメインモデルとマッピング設定は一切変わりません。
ミニプロジェクトではOnConfiguringに接続先を直書きしましたが、MentorAppでは設定ファイルとDIに追い出し、環境ごとの切り替えを設定変更だけで行えるようにしています。
まとめ
今回は、Part Vの入口として、エンティティ単体をEF Coreでテーブルにマッピングする方法を見てきました。
ポイントを整理すると次のとおりです。
- オブジェクトとテーブルの世界の違いは、EF Coreの設定側(DbContext + Fluent API)が1か所で引き受ける
- 規約で決まるものは規約に任せ、規約で表せないものだけFluent APIで設定する
- 値オブジェクトはHasConversionで変換し、DBから読んだ値もバリデーションを通過させる
- 最大長はDomain層の定数を参照し、バリデーションとDBスキーマの定義元を1つに保つ
- IDをアプリ側で生成する設計は、ValueGeneratedNeverでEF Coreに伝える
次回は、エンティティ間の関連のマッピングへ進みます。参照と外部キー、1対多、そして削除ルール(Cascade / Restrict)を扱います。
第21回で考えた集約の境界が、テーブル設計の判断にそのまま現れるところが見どころです。
ドメインモデルは純粋なC#のまま変えず、世界の違いはDbContextが1か所で引き受ける。これが今回の軸でした。
引き続き、EF Coreによる関連のマッピングについて一緒に学んでいきましょう!





