第8章:データベースアクセスと ORM (Entity Framework Core)
この章では、ASP.NET Core アプリケーションから Entity Framework Core (EF Core) を使ってデータベースにアクセスする方法を扱います。EF Core の基本的な考え方から、DI コンテナーへの登録、クエリと保存、マイグレーションの運用、テストまでを一通り説明します。
EF Core は機能が非常に多いため、この章では ASP.NET Core から使ううえで必要になる部分に絞っています。個々の機能の詳細は 6 本の付録に分けています。
| 付録 | 扱う内容 |
|---|---|
| 付録 EF Core 1:モデル定義(エンティティとリレーションシップ) | エンティティの構成、リレーションシップ、値の変換・所有型・複合型、継承 |
| 付録 EF Core 2:モデル定義(キー・採番・SQL Server 固有) | 代替キー、シャドウプロパティ、シーケンス、テンポラルテーブル、空間データ、hierarchyid、Azure SQL の価格レベル、互換性レベル |
| 付録 EF Core 3:マイグレーションの詳細とクエリ | マイグレーションの読み方、dotnet ef のサブコマンド、履歴テーブルのカスタマイズ、分割クエリ、照合順序、生の SQL、ユーザー定義関数 |
| 付録 EF Core 4:更新・トランザクション・レプリカ | 切断されたエンティティ、同時実行制御、デッドロック、インターセプター、レプリカ |
| 付録 EF Core 5:パフォーマンス | 計測と診断、インデックス、コンパイル済みクエリとモデル、NativeAOT、変更検出のコスト |
| 付録 EF Core 6:テスト | 実データベースに対するテスト、製品コードがトランザクションを使うテスト、SQLite インメモリとその制限、WebApplicationFactory、リポジトリパターン |
- 概要と設計方針
- DbContext と ASP.NET Core への組み込み
- クエリの基本
- 保存とトランザクションの基本
- マイグレーションとスキーマ管理
- 本番運用とスケールアウト
- テストとアーキテクチャ
- 参考ドキュメント
1. 概要と設計方針
Section titled “1. 概要と設計方針”EF Core とは
Section titled “EF Core とは”Entity Framework Core (EF Core) は、.NET 向けの軽量かつ拡張可能なオープンソースの O/R マッパー (Object-Relational Mapper) です。データベースのテーブルを C# のクラス(エンティティ)にマッピングし、SQL を直接書く代わりに LINQ でクエリを表現できます。
LINQ (Language Integrated Query: 言語統合クエリ) は、データへの問い合わせ機能を C# に統合した仕組みです。
EF Core は大きく次の 3 つの役割を担います。
| 役割 | 内容 |
|---|---|
| クエリの変換 | LINQ 式ツリーを解析し、プロバイダーごとの SQL に変換して実行する |
| 変更追跡 (Change Tracking) | 読み込んだエンティティの変更を記録し、SaveChanges で INSERT / UPDATE / DELETE に変換する |
| スキーマ管理 | モデルの差分からマイグレーションを生成し、データベーススキーマを更新する |
式ツリー (Expression Tree) は、コードを木構造のデータとして表したものです。
アプリケーション、DbContext、プロバイダー、データベースの関係を図にすると次のようになります。
flowchart LR
accTitle: EF Core の階層構造
accDescr: アプリケーションコードが DbContext を呼び、DbContext がデータベースプロバイダーを経由してデータベースにアクセスする。応答は逆順に返る。
APP["アプリケーションコード<br>LINQ / SaveChanges"]
CTX["DbContext<br>変更追跡・クエリパイプライン"]
PROV["データベースプロバイダー<br>SqlServer / Npgsql / SQLite ..."]
DB[("データベース")]
APP --> CTX
CTX --> PROV
PROV --> DB
DB --> PROV
PROV --> CTX
CTX --> APP
本章では .NET 10 / EF Core 10 を前提とします。EF Core のバージョンは .NET のバージョンに追随しており、EF Core 10 は .NET 10 と同じく LTS (Long Term Support) リリースです。
まずは動くコードを見る
Section titled “まずは動くコードを見る”細かい説明はこのあとの節で行いますが、先に「EF Core を使うとどう書けるのか」を見ておきます。ここでは SQL Server に次の 2 つのテーブルがあるものとして、これを EF Core で操作します。
erDiagram
accTitle: Blogs と Posts のテーブル構成
accDescr: Blogs テーブルと Posts テーブルが 1 対多で関連する。Blogs は Id・Name・Rating を、Posts は Id・Title・PublishedAt・外部キー BlogId を持つ。
Blogs ||--o{ Posts : "1 対多"
Blogs {
型 列名 "制約と説明"
int Id PK "ブログ ID(自動採番)"
nvarchar Name "ブログ名(必須)"
int Rating "評価(必須)"
}
Posts {
型 列名 "制約と説明"
int Id PK "投稿 ID(自動採番)"
nvarchar Title "投稿のタイトル(必須)"
datetimeoffset PublishedAt "公開日時(必須)"
int BlogId FK "所属するブログの Id"
}
1 つのブログ (Blogs) が複数の投稿 (Posts) を持ち、投稿は必ずどれか 1 つのブログに属します。図の PK は主キー制約 (PRIMARY KEY)、FK は外部キー制約 (FOREIGN KEY) を表します。Posts.BlogId が Blogs.Id を参照する外部キーです。
1. テーブルに対応するクラスと DbContext を用意する
public class Blog{ public int Id { get; set; } public required string Name { get; set; } public int Rating { get; set; } public List<Post> Posts { get; set; } = [];}
public class Post{ public int Id { get; set; } public required string Title { get; set; } public DateTimeOffset PublishedAt { get; set; } public int BlogId { get; set; } public Blog? Blog { get; set; }}
public class BloggingContext(DbContextOptions<BloggingContext> options) : DbContext(options){ public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();}2. 追加する (INSERT)
オブジェクトを組み立てて Add し、SaveChangesAsync を呼ぶだけです。
var blog = new Blog { Name = ".NET ブログ", Rating = 4 };blog.Posts.Add(new Post { Title = "EF Core 入門", PublishedAt = DateTimeOffset.UtcNow });
context.Blogs.Add(blog);await context.SaveChangesAsync();
// 採番された主キーがオブジェクトに書き戻されるConsole.WriteLine(blog.Id); // 1Console.WriteLine(blog.Posts[0].Id); // 1公式の関連データの保存が説明するとおり、追加したエンティティから参照される新しい関連エンティティも一緒に保存されます。次は、上のコードを SQL Server 2022 で実行したときの確認例です。Blog と Post の INSERT が発行され、外部キーも設定されています。
INSERT INTO [Blogs] ([Name], [Rating])OUTPUT INSERTED.[Id]VALUES (@p0, @p1);
INSERT INTO [Posts] ([BlogId], [PublishedAt], [Title])OUTPUT INSERTED.[Id]VALUES (@p2, @p3, @p4);3. 問い合わせる (SELECT)
SQL の代わりに LINQ を書きます。
var blogs = await context.Blogs .Where(b => b.Rating >= 3) .OrderBy(b => b.Name) .Select(b => new { b.Name, PostCount = b.Posts.Count }) .ToListAsync();EF Core は LINQ のクエリをデータベースプロバイダーに渡し、データベース用のクエリへ翻訳します(公式のデータのクエリの説明)。次は SQL Server 2022 で確認できている SQL の例です。Posts.Count が副問い合わせになっており、この例では件数を数えるために投稿を全件読み込む必要がありません。
SELECT [b].[Name], ( SELECT COUNT(*) FROM [Posts] AS [p] WHERE [b].[Id] = [p].[BlogId]) AS [PostCount]FROM [Blogs] AS [b]WHERE [b].[Rating] >= 3ORDER BY [b].[Name]4. 更新する (UPDATE)
更新用のメソッドは呼びません。読み込んだオブジェクトのプロパティを書き換えて SaveChangesAsync を呼ぶと、EF Core が変更を検出して UPDATE を組み立てます。
var blog = await context.Blogs.FirstAsync(b => b.Name == ".NET ブログ");blog.Rating = 5;await context.SaveChangesAsync();公式の変更追跡の説明では、プロパティ単位で変更を追跡し、変更された値を更新すると説明しています。次の SQL Server 2022 の確認例でも、変更した Rating だけが UPDATE 文に含まれ、書き換えていない Name は対象外です。
UPDATE [Blogs] SET [Rating] = @p0OUTPUT 1WHERE [Id] = @p1;O/R マッパーの位置づけ
Section titled “O/R マッパーの位置づけ”.NET でデータベースにアクセスする手段は EF Core だけではありません。用途に応じて選択します。
| 手段 | 特徴 | 向いている場面 |
|---|---|---|
| EF Core | LINQ、変更追跡、マイグレーションを備えたフル機能の O/R マッパー | 一般的な業務アプリケーション。読み書き両方を扱う |
| Dapper などのマイクロ O/R マッパー | SQL を自分で書き、結果をオブジェクトにマッピングするだけ | SQL を完全に制御したい、複雑な集計クエリ |
ADO.NET (DbConnection / DbCommand) | 最も低レベルな API | 特殊な最適化や、プロバイダー固有の機能を直接使う場合 |
EF Core でも付録3の「生の SQL を使う」で扱う FromSql によって生の SQL を書けるため、「基本は EF Core、一部のクエリだけ SQL を直接書く」という組み合わせが現実的な選択になります。
データベースプロバイダーの選択
Section titled “データベースプロバイダーの選択”EF Core 自体はデータベースに依存せず、プロバイダー と呼ばれる NuGet パッケージを追加することで各データベースに対応します。
| データベース | パッケージ | 提供元 | 公式一覧の記載 |
|---|---|---|---|
| SQL Server / Azure SQL / Azure Synapse Analytics | Microsoft.EntityFrameworkCore.SqlServer | EF Core プロジェクト (Microsoft) | 8, 9, 10 |
| SQLite | Microsoft.EntityFrameworkCore.Sqlite | EF Core プロジェクト (Microsoft) | 8, 9, 10 |
| Azure Cosmos DB for NoSQL | Microsoft.EntityFrameworkCore.Cosmos | EF Core プロジェクト (Microsoft) | 8, 9, 10 |
| PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL | Npgsql Development Team | 8, 9 |
| MySQL / MariaDB | Pomelo.EntityFrameworkCore.MySql | Pomelo Foundation Project | 8, 9 |
Code First と Database First
Section titled “Code First と Database First”EF Core でモデルとデータベースを対応づけるアプローチは 2 つあります。
flowchart TB
accTitle: Code First と Database First の流れ
accDescr: Code First は C# のエンティティクラスからマイグレーションを生成してデータベーススキーマを作る。Database First は既存のデータベースから scaffold でエンティティクラスと DbContext を生成する。
subgraph CF["Code First(モデル駆動)"]
direction TB
C1["C# のエンティティクラスを書く"] --> C2["dotnet ef migrations add"]
C2 --> C3["マイグレーションを適用"]
C3 --> C4[("データベーススキーマ")]
end
subgraph DF["Database First(リバースエンジニアリング)"]
direction TB
D1[("既存のデータベース")] --> D2["dotnet ef dbcontext scaffold"]
D2 --> D3["エンティティクラスと DbContext が生成される"]
end
| アプローチ | 使いどころ |
|---|---|
| Code First | 新規開発。スキーマの変更履歴をソース管理に残せる |
| Database First | 既存データベースがある場合や、DBA がスキーマを管理している場合 |
本章では Code First を中心に説明します。Database First の場合は次のコマンドでエンティティと DbContext を生成します。
dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True" Microsoft.EntityFrameworkCore.SqlServer --output-dir Modelsパッケージの追加とツールの準備
Section titled “パッケージの追加とツールの準備”Web API プロジェクトに SQL Server プロバイダーを追加します。
dotnet add package Microsoft.EntityFrameworkCore.SqlServerdotnet add package Microsoft.EntityFrameworkCore.DesignMicrosoft.EntityFrameworkCore.Design は、マイグレーションの生成やスキャフォールディングといった 設計時 (design-time) の操作に必要です。実行時には不要ですが、dotnet ef コマンドを使うプロジェクトには追加しておきます。
続いて EF Core のコマンドラインツールをインストールします。
dotnet tool install --global dotnet-efすでにインストール済みの場合は更新します。
dotnet tool update --global dotnet-efインストールを確認します。
dotnet ef --version2. DbContext と ASP.NET Core への組み込み
Section titled “2. DbContext と ASP.NET Core への組み込み”エンティティクラスの定義
Section titled “エンティティクラスの定義”エンティティは特別な基底クラスを継承しない、単なる C# クラス(POCO: Plain Old CLR Object)として定義します。ブログと投稿という典型的な 1 対多の関係を例にします。
ここでの CLR は、.NET のコードを実行する環境である共通言語ランタイム (Common Language Runtime) を指します。
namespace BloggingApi.Models;
public class Blog{ public int Id { get; set; } public required string Name { get; set; } public required string Url { get; set; } public int Rating { get; set; } public DateTimeOffset CreatedAt { get; set; }
// ナビゲーションプロパティ(1 対多の「多」側) public List<Post> Posts { get; set; } = []; public List<Contributor> Contributors { get; set; } = [];}
public class Post{ public int Id { get; set; } public required string Title { get; set; } public string? Content { get; set; } public DateTimeOffset PublishedAt { get; set; }
// 外部キー public int BlogId { get; set; }
// ナビゲーションプロパティ(1 対多の「1」側) public Blog? Blog { get; set; }}
public class Contributor{ public int Id { get; set; } public required string DisplayName { get; set; }
public int BlogId { get; set; } public Blog? Blog { get; set; }}ここで使っている C# の機能を整理します。
| 機能 | 意味 |
|---|---|
required 修飾子 | オブジェクト初期化時に値の設定を必須にする。EF Core では非 null の必須列としてマッピングされる |
string? | null 許容参照型。EF Core はこれを NULL 許容列としてマッピングする |
= [] | コレクション式による空のリストの初期化 |
DbContext の定義
Section titled “DbContext の定義”DbContext は、エンティティのセット(DbSet<T>)を公開し、クエリと保存の起点となるクラスです。
using Microsoft.EntityFrameworkCore;using BloggingApi.Models;
namespace BloggingApi.Data;
public class BloggingContext : DbContext{ public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) { }
public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { // ここでモデルの詳細を構成する(後述) }}DbContextOptions<BloggingContext> を受け取るコンストラクターは、DI から構成を受け取るために必要です。
DI への登録と接続文字列
Section titled “DI への登録と接続文字列”Program.cs で AddDbContext を呼び出して DI コンテナーに登録します。
using Microsoft.EntityFrameworkCore;using BloggingApi.Data;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("BloggingDatabase") ?? throw new InvalidOperationException("接続文字列 'BloggingDatabase' が見つかりません。");
builder.Services.AddDbContext<BloggingContext>(options => options.UseSqlServer(connectionString));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();接続文字列は appsettings.json の ConnectionStrings セクションに置きます。
{ "ConnectionStrings": { "BloggingDatabase": "Server=(localdb)\\mssqllocaldb;Database=Blogging;Trusted_Connection=True;MultipleActiveResultSets=true" }}登録した DbContext は、コントローラーやサービスにコンストラクターインジェクションで注入します。
using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using BloggingApi.Data;using BloggingApi.Models;
namespace BloggingApi.Controllers;
[ApiController][Route("api/[controller]")]public class BlogsController(BloggingContext context) : ControllerBase{ [HttpGet] public async Task<ActionResult<IEnumerable<Blog>>> GetBlogs(CancellationToken cancellationToken) => await context.Blogs.AsNoTracking().ToListAsync(cancellationToken);}接続の暗号化は既定で有効
Section titled “接続の暗号化は既定で有効”SQL Server プロバイダーが使う Microsoft.Data.SqlClient は、バージョン 4.0 から Encrypt の既定値が True に変わりました。 EF Core の SQL Server プロバイダーでは、EF Core 7 への更新でこの変更が影響します。SqlClient と EF Core のバージョン番号は別です。既定の TrustServerCertificate=False と組み合わせると、サーバー証明書の検証も必要になります。開発用のコンテナーや自己署名証明書のサーバーに、それまで動いていた接続文字列で接続すると失敗します。
SqlException: サーバーとの接続を正常に確立しましたが、ログイン前のハンドシェイク中にエラーが発生しました。(provider: TCP プロバイダー, error: 35 - 内部の例外が発生しました) → AuthenticationException: 証明書のチェーン検証に失敗しました。 エラー: 'The certificate was not trusted., [Status: UntrustedRoot]公式の暗号化と証明書検証の説明に基づく、開発環境向けの設定の違いは次のとおりです。自己署名証明書を使う SQL Server 2022 のコンテナーでも、両方の設定で接続できることを確認できています。
| 追加する設定 | 意味 |
|---|---|
TrustServerCertificate=True | 暗号化はするが、証明書の検証を省略する |
Encrypt=False | クライアント側で接続の暗号化を必須にしない |
DbContext のライフタイムとスレッド安全性
Section titled “DbContext のライフタイムとスレッド安全性”AddDbContext は DbContext を Scoped サービスとして登録します。ASP.NET Core では 1 つの HTTP リクエストが 1 つのスコープに対応するため、リクエストごとに DbContext インスタンスが作られ、リクエスト終了時に破棄されます。
HTTP リクエスト内で BloggingContext を使う処理の流れを示します。
sequenceDiagram
accTitle: HTTP リクエストと Scoped な DbContext の寿命
accDescr: 正常な要求では、ASP.NET Core が要求スコープの DbContext を使ってクエリと保存を行い、HTTP レスポンスを送信する。要求完了時の後処理でスコープと DbContext を破棄する。
participant C as クライアント
participant M as ASP.NET Core
participant CT as BloggingContext (Scoped)
participant DB as データベース
C->>M: HTTP リクエスト
M->>CT: スコープ生成・インスタンス生成
CT->>DB: クエリ実行
DB-->>CT: 結果(エンティティを変更追跡)
CT->>DB: SaveChangesAsync
M-->>C: HTTP レスポンスを送信
M->>CT: 要求完了時の後処理で DisposeAsync
ASP.NET Core 10.0.11 の公式実装は、要求スコープを応答完了時の破棄対象として登録します。応答送信後のコールバックを扱う API が OnCompleted です。Kestrel の実 HTTP 通信でも、要求完了時の後処理を試験用に一時停止する条件で、DbContext がまだ破棄されていなくても応答本文を取得できることを確認できています。図は正常系のサーバー側の処理順を表しており、クライアントの受信完了を待ってから破棄するという保証ではありません。
DbContext は 1 つの 作業単位 (unit-of-work) を表すよう設計されており、公式ドキュメントは次の点を明記しています。
DbContextは スレッドセーフではない。複数のスレッドで同じインスタンスを共有してはいけない- 非同期メソッドは必ず
awaitしてから、次の操作でインスタンスを使う - EF Core が投げる
InvalidOperationExceptionはコンテキストを回復不能な状態にすることがあり、握りつぶして処理を続行してはいけない
Singleton サービスやバックグラウンドサービスから DbContext を使う場合は、IServiceScopeFactory でスコープを作り、その中から解決します。1 つの要求の中で複数のクエリを並列に実行したい場合は、AddDbContextFactory で登録した IDbContextFactory<T> からインスタンスを個別に作ります。どちらの手順も 付録 EF Core 5 で扱います。
DbContext プーリング
Section titled “DbContext プーリング”高スループットのアプリケーションでは、AddDbContext の代わりに AddDbContextPool を使うと、DbContext インスタンスを再利用するプールが有効になり、インスタンスの生成コストを削減できます。ただしプールされたインスタンスは再利用されるため、リクエストごとに変わる状態をフィールドに保持する設計とは相性が悪くなります。詳しくは付録5の「DbContext プーリングでインスタンスを使い回す」を参照してください。
3. クエリの基本
Section titled “3. クエリの基本”基本的なクエリ
Section titled “基本的なクエリ”EF Core では LINQ でクエリを記述します。式ツリーが解析され、プロバイダーが SQL に変換します。
// すべての行を取得var blogs = await context.Blogs.ToListAsync(cancellationToken);
// 条件で絞り込むvar dotnetBlogs = await context.Blogs .Where(b => b.Url.Contains("dotnet")) .OrderBy(b => b.Name) .ToListAsync(cancellationToken);
// 単一の行を取得(見つからなければ null)var blog = await context.Blogs .FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
// 主キーで取得(追跡中のインスタンスがあればデータベースに行かない)var cached = await context.Blogs.FindAsync([id], cancellationToken);
// 集計var count = await context.Posts.CountAsync(p => p.BlogId == id, cancellationToken);非同期メソッドの対応表です。ASP.NET Core では、スレッドをブロックしないために必ず非同期版を使います。
| 同期 | 非同期 |
|---|---|
ToList() | ToListAsync() |
First() / FirstOrDefault() | FirstAsync() / FirstOrDefaultAsync() |
Single() / SingleOrDefault() | SingleAsync() / SingleOrDefaultAsync() |
Count() / Any() | CountAsync() / AnyAsync() |
SaveChanges() | SaveChangesAsync() |
クエリはいつ実行されるか
Section titled “クエリはいつ実行されるか”IQueryable<T> に対する Where や OrderBy はクエリを組み立てるだけで、データベースへのアクセスは発生しません。実際に実行されるのは次のタイミングです。
ToListAsync/ToArrayAsyncなどで結果を実体化したときFirstAsync/SingleAsync/CountAsyncなどのスカラー結果を返すメソッドを呼んだときforeach/await foreachで列挙したとき
// ここではまだ SQL は発行されないIQueryable<Blog> query = context.Blogs.Where(b => b.Name.StartsWith("A"));
if (onlyRecent){ query = query.Where(b => b.CreatedAt > DateTimeOffset.UtcNow.AddDays(-30));}
// ここで初めて 1 回の SQL が実行されるvar result = await query.ToListAsync(cancellationToken);変更追跡と AsNoTracking
Section titled “変更追跡と AsNoTracking”既定では、クエリが返したエンティティは DbContext の チェンジトラッカー に登録されます。これによりプロパティを変更するだけで SaveChangesAsync が UPDATE を発行できますが、追跡そのものにコストがかかります。
読み取り専用のクエリでは AsNoTracking() を使います。
var blogs = await context.Blogs .AsNoTracking() .Where(b => b.Url.Contains("dotnet")) .ToListAsync(cancellationToken);公式の非追跡クエリの説明は、取得したエンティティを更新しない場合に非追跡クエリを使うよう勧めています。ASP.NET Core の GET エンドポイントのように、取得した結果をそのまま返すだけの処理では、AsNoTracking() を検討してください。ただし、同一エンティティの重複を解決する必要がある場合など、追跡ありのほうが効率的なケースも公式に説明されています。
補足として、SQLite で 2,000 行を 30 回読み取る条件では、追跡あり/なしの実行時間が 7.48 ミリ秒/2.32 ミリ秒、割り当てバイト数が約 1.99 MB/約 0.77 MB となることを確認できています。これらは同条件での確認値であり、別のクエリでの性能順位や改善倍率を保証するものではありません。
追跡を行わないと、同じ行が複数回結果に現れたときに別々のインスタンスが作られます。同一性を保ちたい場合は AsNoTrackingWithIdentityResolution() を使います。
var posts = await context.Posts .AsNoTrackingWithIdentityResolution() .Include(p => p.Blog) .ToListAsync(cancellationToken);DbContext 全体で既定の追跡動作を変えることもできます。
builder.Services.AddDbContext<BloggingContext>(options => options.UseSqlServer(connectionString) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));この場合、追跡したいクエリだけ AsTracking() を付けて個別に戻せます。
var blog = await context.Blogs .AsTracking() .FirstAsync(b => b.Id == id, cancellationToken);関連データの読み込み
Section titled “関連データの読み込み”ナビゲーションプロパティを一緒に読み込むには Include を使います(イーガーロード)。
var blogs = await context.Blogs .Include(b => b.Posts) .ToListAsync(cancellationToken);さらに深い階層は ThenInclude でたどります。ここでは本編で定義済みの Post → Blog → Contributors を読み込みます。
var posts = await context.Posts .Include(p => p.Blog) .ThenInclude(b => b!.Contributors) .ToListAsync(cancellationToken);読み込む関連データを絞り込む フィルター付き Include も使えます。
var blogs = await context.Blogs .Include(b => b.Posts .Where(p => p.PublishedAt > DateTimeOffset.UtcNow.AddYears(-1)) .OrderByDescending(p => p.PublishedAt) .Take(5)) .ToListAsync(cancellationToken);Include の中で使えるのは Where、OrderBy、OrderByDescending、ThenBy、ThenByDescending、Skip、Take です。
すでに読み込んだエンティティに対して、後から関連データを読み込む 明示的読み込み (Explicit Loading) もあります。
var blog = await context.Blogs.FirstAsync(b => b.Id == id, cancellationToken);
await context.Entry(blog) .Collection(b => b.Posts) .LoadAsync(cancellationToken);関連データを読み込まずに件数だけ数えたり、条件で絞って読み込んだりする方法は付録 EF Core 3 の「関連データを読み込まずに数える」を参照してください。
遅延読み込みと N+1 問題
Section titled “遅延読み込みと N+1 問題”Microsoft.EntityFrameworkCore.Proxies パッケージを追加し、UseLazyLoadingProxies() を呼ぶと、まだ読み込まれていない関連データをナビゲーションプロパティへのアクセス時に自動的に取得する 遅延読み込み (Lazy Loading) を有効にできます。パッケージの追加だけでは有効になりません。
公式の遅延読み込みに関する注意では、ナビゲーションへのアクセスで追加クエリが発生するため、N+1 問題 を引き起こしやすいと説明されています。次はその例です。
// 遅延読み込みが有効な場合の悪い例var blogs = await context.Blogs.ToListAsync(cancellationToken); // 1 回のクエリ
foreach (var blog in blogs){ // ループのたびにクエリが発行される(N 回) Console.WriteLine($"{blog.Name}: {blog.Posts.Count}");}遅延読み込みプロキシを有効にし、関連する子をまだ追跡していない DbContext で、Blog 100 件(各 1 Post)を取得して各 Posts を参照する SQLite 対照でも、最初の SELECT 1 回と子の SELECT 100 回、合計 101 回の SQL を確認できています。これは上の説明を発行回数で裏付ける例であり、ネットワーク遅延や処理時間の測定ではありません。
// 良い例:1 回のクエリで必要なデータをまとめて取得するvar summaries = await context.Blogs .Select(b => new BlogSummary(b.Id, b.Name, b.Posts.Count)) .ToListAsync(cancellationToken);投影 (Projection) による最適化
Section titled “投影 (Projection) による最適化”エンティティ全体ではなく、必要な列だけを取得する 投影 は最も効果的な最適化の 1 つです。
public record BlogSummary(int Id, string Name, int PostCount);
var summaries = await context.Blogs .Select(b => new BlogSummary(b.Id, b.Name, b.Posts.Count)) .ToListAsync(cancellationToken);生成される SQL は必要な列だけを SELECT し、Posts はサブクエリでカウントされます。エンティティ型ではないため変更追跡も行われません。
一覧 API では必ず件数を制限します。EF Core の公式パフォーマンスガイダンスは、これを「結果セットのサイズを制限する」として独立した指針に挙げています。既定では条件に一致する行がすべて返るため、返る行数が実際のデータ次第になり、読み込まれるデータ量・消費されるメモリ・ネットワークへの負荷のいずれも事前に見積もれません。 公式が特に注意を促しているのは、テスト用のデータベースはデータが少ないことが多く、テスト中は問題なく動くのに、実データで多くの行が返るようになった途端に性能問題が表面化するという点です。
次は、公式の「結果セットのサイズを制限する」という指針の確認例です。SQL Server 2022 に 100,000 行(Content は 500 文字)を用意し、同じ条件のクエリを Take の有無で比較した値を示します。
| クエリ | 所要時間 | マネージドヒープの増加 |
|---|---|---|
Where(...).ToListAsync() | 3,320 ミリ秒 | +230 MiB |
Where(...).Take(25).ToListAsync() | 38 ミリ秒 | +0 MiB |
メモリの値は GC.GetTotalMemory で求めた実行前後のマネージドヒープの差を、1 MiB(1,048,576 バイト)単位の整数にしたものです。ピーク使用量や総割り当て量ではなく、表示上の +0 MiB も割り当てがなかったことを意味しません。
この表の時間差やメモリの差は、上記のデータとクエリの条件に限った値です。採用判断は倍率ではなく、公式が説明する結果件数の制限の必要性に基づけます。最低限でも上限を設け、可能ならページングを実装してください。単純な方法は Skip / Take による オフセットページング です。
var page = await context.Posts .AsNoTracking() .OrderByDescending(p => p.PublishedAt) .ThenBy(p => p.Id) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken);オフセットページングは実装が簡単ですが、後方のページになるほどデータベースがスキップする行数が増え、性能が劣化します。また、ページの取得中に行が挿入・削除されると、同じ行が 2 度現れたり抜け落ちたりします。
大量データでは キーセットページング (Keyset Pagination) が推奨されます。前ページの最後の値を条件に使います。
var page = await context.Posts .AsNoTracking() .OrderBy(p => p.Id) .Where(p => p.Id > lastSeenId) .Take(pageSize) .ToListAsync(cancellationToken);| 方式 | 長所 | 短所 |
|---|---|---|
| オフセット | 任意のページに直接ジャンプできる | 深いページで遅い。行の増減でずれる |
| キーセット | 前ページの最後のキーを条件に、続く範囲を取得する。前方の行の追加・削除で位置がずれにくい | 「10 ページ目へ」のようなランダムアクセスができない |
発行される SQL を確認する
Section titled “発行される SQL を確認する”LINQ で書いたクエリも、次節で扱う SaveChangesAsync も、最終的には SQL に変換されてデータベースへ送られます。この変換は自動で行われるため、書いたコードからは実際に流れる SQL が見えません。意図しない結合や全件取得が起きていないかを確かめるために、EF Core は生成された SQL を取り出す手段を用意しています。
| 手段 | 見えるもの | クエリを実行するか |
|---|---|---|
ToQueryString() | クエリの SQL | 実行しない |
| ログ | ログレベル・カテゴリーなどの設定で記録対象となる SQL(クエリ・保存・マイグレーション) | 実行時に記録する |
| インターセプター | 実行直前の DbCommand | 実行する |
実行せずにクエリの SQL を見る
Section titled “実行せずにクエリの SQL を見る”ToQueryString() は、クエリを実行せずに生成される SQL を文字列で返します。
var url = "dotnet";var query = context.Blogs .Where(b => b.Url.Contains(url)) .OrderBy(b => b.Id);
Console.WriteLine(query.ToQueryString());本章のモデルを SQL Server プロバイダーで構成して ToQueryString() を呼ぶと、次の SQL が得られます。データベースへの接続やクエリの実行は行いません。
DECLARE @url_contains nvarchar(4000) = N'%dotnet%';
SELECT [b].[Id], [b].[CreatedAt], [b].[Name], [b].[Rating], [b].[Url]FROM [Blogs] AS [b]WHERE [b].[Url] LIKE @url_contains ESCAPE N'\'ORDER BY [b].[Id]この SQL Server の出力例には、先頭にパラメーターの DECLARE が付いています。ただし、ToQueryString() はデバッグ用であり、出力を常にそのまま実行できるとは限りません。SQL Server Management Studio などで実行プランを確認する場合は、接続先データベース、パラメーター、SQL の内容を確認してから実行してください。
実際に実行された SQL をログで見る
Section titled “実際に実行された SQL をログで見る”公式のMicrosoft.Extensions.Logging との統合の説明にあるとおり、AddDbContext で登録した DbContext は、ASP.NET Core のログ構成を使用します。appsettings.Development.json で次のカテゴリーを Information にする対照でも、保存・クエリの CommandExecuted ログを取得でき、Warning では出力されないことを確認できています。記録される範囲は、ログレベル、カテゴリー、追加のフィルターなどの構成に依存します。
{ "Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }}次は、本章のモデルで既存の Blog.Name を更新し、Post を 1 件追加する場合のログの表示形式の例です。SQL とパラメーター構成は、EF Core 10.0.11 の SQL Server プロバイダーが生成した実行直前のコマンドで確認したものです。ここでは SQL Server での保存を再実行しておらず、実行時間は <実行時間> としています。
info: Microsoft.EntityFrameworkCore.Database.Command[20101] Executed DbCommand (<実行時間>) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (Size = 4000), @p2='?' (DbType = Int32), @p3='?' (Size = 4000), @p4='?' (DbType = DateTimeOffset), @p5='?' (Size = 4000)], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Blogs] SET [Name] = @p0 OUTPUT 1 WHERE [Id] = @p1; INSERT INTO [Posts] ([BlogId], [Content], [PublishedAt], [Title]) OUTPUT INSERTED.[Id] VALUES (@p2, @p3, @p4, @p5);公式の保存のバッチ処理は、複数の変更をまとめて送信し、ラウンドトリップを減らす仕組みを説明しています。この例でも、UPDATE と INSERT が 1 つのコマンドにまとめられていることを確認できています。
4. 保存とトランザクションの基本
Section titled “4. 保存とトランザクションの基本”追加・更新・削除の基本
Section titled “追加・更新・削除の基本”EF Core の更新は、チェンジトラッカーが記録した状態をもとに SaveChangesAsync がまとめて SQL に変換する、という流れで行われます。既定のスナップショット追跡では、プロパティを代入した瞬間ではなく、変更検出によって Modified と認識されます。SaveChangesAsync は既定で、この変更検出を行ってから保存します。
flowchart LR
accTitle: エンティティの状態と SaveChanges の関係
accDescr: 追跡クエリで読み込んだ直後は Unchanged。既定のスナップショット追跡では、プロパティの変更は SaveChangesAsync 内の変更検出によって Modified と認識される。Add による Added、既存エンティティの Remove による Deleted とともに、INSERT・UPDATE・DELETE が 1 つのトランザクションで保存される。
Q["追跡クエリで読み込み<br>(State = Unchanged)"] --> M["プロパティを変更"]
A["context.Add<br>(State = Added)"] --> S
M --> S["SaveChangesAsync<br>変更検出・保存"]
R["既存エンティティを Remove<br>(State = Deleted)"] --> S
S --> SQL["INSERT / UPDATE / DELETE<br>1 つのトランザクション"]
// 追加var blog = new Blog { Name = "新しいブログ", Url = "https://example.com" };context.Blogs.Add(blog);await context.SaveChangesAsync(cancellationToken);// 保存後、blog.Id にはデータベースが採番した値が入る
// 更新(追跡されているエンティティのプロパティを変更するだけでよい)var target = await context.Blogs.FirstAsync(b => b.Id == id, cancellationToken);target.Name = "変更後の名前";await context.SaveChangesAsync(cancellationToken);
// 削除context.Blogs.Remove(target);await context.SaveChangesAsync(cancellationToken);関連エンティティを一緒に追加すると、EF Core が外部キーを解決して正しい順序で INSERT します。
var blog = new Blog{ Name = "新しいブログ", Url = "https://example.com", Posts = [ new Post { Title = "最初の投稿" }, new Post { Title = "2 番目の投稿" } ]};
context.Blogs.Add(blog);await context.SaveChangesAsync(cancellationToken);追跡されていないエンティティ(クライアントから受け取った DTO を変換したものなど)を更新する場合は、Update または Attach と状態設定を使います。以下は全体更新の例で、dto が Name、Url、Rating、CreatedAt の全更新対象の値を持つ前提です。
var blog = new Blog{ Id = id, Name = dto.Name, Url = dto.Url, Rating = dto.Rating, CreatedAt = dto.CreatedAt};context.Blogs.Update(blog); // Id 以外のスカラープロパティが Modified になるawait context.SaveChangesAsync(cancellationToken);SaveChanges の既定のトランザクション動作
Section titled “SaveChanges の既定のトランザクション動作”トランザクションをサポートするデータベースでは、既定で、SaveChangesAsync の 1 回の呼び出しによる変更はすべて適用されるか、すべてロールバックされるかのどちらかです。単一 SQL 文など、追加のトランザクション開始が不要な場合もあります。次の例の複数の変更も、1 回の保存の単位として扱われます。
context.Blogs.Add(new Blog { Name = "A", Url = "https://a.example.com" });context.Posts.Remove(existingPost);
// 2 つの変更は同一トランザクション内で実行されるawait context.SaveChangesAsync(cancellationToken);したがって、単一の作業単位で完結する処理には明示的なトランザクションは不要です。
明示的なトランザクション制御
Section titled “明示的なトランザクション制御”複数回の SaveChangesAsync や、生の SQL を含む処理を 1 つのトランザクションにまとめたい場合は、明示的にトランザクションを開始します。
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
try{ context.Blogs.Add(new Blog { Name = "A", Url = "https://a.example.com" }); await context.SaveChangesAsync(cancellationToken);
await context.Database.ExecuteSqlAsync( $"UPDATE [Statistics] SET [BlogCount] = [BlogCount] + 1", cancellationToken);
await transaction.CommitAsync(cancellationToken);}catch{ await transaction.RollbackAsync(CancellationToken.None); throw;}分離レベルを指定することもできます。
await using var transaction = await context.Database .BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken);5. マイグレーションとスキーマ管理
Section titled “5. マイグレーションとスキーマ管理”マイグレーションの仕組み
Section titled “マイグレーションの仕組み”マイグレーションは、C# のモデルとデータベーススキーマを同期させる仕組みです。
flowchart LR
accTitle: マイグレーションの生成と適用の流れ
accDescr: 現在のモデルとモデルスナップショットの差分からマイグレーションファイルが生成される。EF Core がデータベースのスキーマ変更を適用し、同じデータベース内の __EFMigrationsHistory に適用済みの行を記録する。
M1["モデル<br>(現在の C# コード)"] --> DIFF{差分検出}
SNAP["モデルスナップショット<br>〈DbContext 名〉ModelSnapshot.cs"] --> DIFF
DIFF --> MIG["マイグレーションファイル<br>Up() / Down()"]
MIG -->|"EF Core が適用"| DB[("データベースのスキーマ")]
DB -->|"EF Core が記録"| HIST["同じ DB 内の __EFMigrationsHistory<br>適用済みマイグレーションの記録"]
EF Core は、現在のモデルと直前のマイグレーションが保持する モデルスナップショット を比較して差分を検出し、マイグレーションのソースファイルを生成します。適用済みのマイグレーションは __EFMigrationsHistory テーブルに記録されるため、次回は未適用のものだけが適用されます。
履歴テーブルの準備や既適用の確認と、今回の適用済み行の記録は別です。公式のマイグレーションの管理では、通常はマイグレーションごとにトランザクションで囲むと説明されています。SQL Server 向けに生成する SQL でも、スキーマ変更、履歴行の INSERT、COMMIT の順を確認できています。図はスキーマ変更だけが先に別トランザクションで確定する、という意味ではありません。
マイグレーションの作成と適用
Section titled “マイグレーションの作成と適用”最初のマイグレーションを作成します。
dotnet ef migrations add InitialCreateプロジェクトに Migrations フォルダーが作成され、マイグレーションのソースファイルとモデルスナップショットが生成されます。これらはソース管理にコミットします。BloggingContext に対して実行すると、実際には次の 3 ファイルが作られます。
Migrations/├── 20260831070300_InitialCreate.cs マイグレーション本体(Up / Down)├── 20260831070300_InitialCreate.Designer.cs そのマイグレーション時点のモデル定義└── BloggingContextModelSnapshot.cs 最新モデルのスナップショット先頭の数字はマイグレーションを作成した日時(UTC、yyyyMMddHHmmss)で、これが適用順序を決めます。スナップショットのファイル名は <DbContext のクラス名>ModelSnapshot.cs になります。
データベースに適用します。
dotnet ef database updateモデルを変更したら、変更内容が分かる名前でマイグレーションを追加します。
dotnet ef migrations add AddPostPublishedAtdotnet ef database updateよく使うコマンドを整理します。
| コマンド | 用途 |
|---|---|
dotnet ef migrations add <Name> | マイグレーションを追加する |
dotnet ef migrations remove | 未適用の最新マイグレーションを取り消す |
dotnet ef migrations list | マイグレーションの一覧と適用状況を表示する |
dotnet ef database update | 最新まで適用する |
dotnet ef database update <Name> | 指定したマイグレーションの状態にする(間のものはまとめて適用/取り消し) |
dotnet ef migrations script | SQL スクリプトを生成する |
dotnet ef migrations bundle | マイグレーションバンドル(実行可能ファイル)を生成する |
本番環境への適用戦略
Section titled “本番環境への適用戦略”公式ドキュメントは、用途に応じて次の 4 つの戦略を挙げています。
| 戦略 | 推奨される用途 | 実行前に SQL を確認できるか | 実行時に SDK とソースが必要か |
|---|---|---|---|
| SQL スクリプト | DBA による承認・レビューが必要な運用 | できる | 不要 |
| マイグレーションバンドル | 自動デプロイ | できない | 不要 |
| EF コマンドラインツール | ローカル開発とテスト | できない | 必要 |
| 実行時マイグレーション | 起動時マイグレーションの制約を許容できるアプリケーション | できない | 不要 |
SQL スクリプトとマイグレーションバンドル
Section titled “SQL スクリプトとマイグレーションバンドル”SQL スクリプトは dotnet ef migrations script で生成します。--idempotent を付けると、各マイグレーションが未適用かどうかを確認してから実行するスクリプトになるため、適用状況が分からないデータベースにも安全に流せます。
自動デプロイには マイグレーションバンドル が推奨されます。公式ドキュメントによれば、バンドルは CI で生成でき、実行時に .NET SDK も EF Core ツールもアプリケーションのソースコードも不要な単一の実行可能ファイルです。
dotnet ef migrations script --idempotent --output artifacts/migrations.sqldotnet ef migrations bundle --output efbundle./efbundle --connection "$CONNECTION_STRING"起動時マイグレーションの是非
Section titled “起動時マイグレーションの是非”アプリケーションの起動時にマイグレーションを適用することもできます。
var app = builder.Build();
using (var scope = app.Services.CreateScope()){ var context = scope.ServiceProvider.GetRequiredService<BloggingContext>(); await context.Database.MigrateAsync();}
app.Run();この方法は手軽ですが、公式ドキュメントは次のトレードオフを挙げています。
- アプリケーションがスキーマ変更のための昇格された権限を必要とする
- 生成される SQL を確認・修正する機会がない
- 問題が起きたときのロールバックが他の戦略ほど容易ではない
- 別のアプリケーションがデータベースにアクセスしている最中にマイグレーションが走ると、深刻な問題を引き起こす可能性がある
6. 本番運用とスケールアウト
Section titled “6. 本番運用とスケールアウト”複数インスタンスでも動く理由
Section titled “複数インスタンスでも動く理由”公式の変更追跡は、エンティティを個々の DbContext が追跡する仕組みを説明しています。PaaS などで複数のアプリケーションプロセスを動かす場合も、追跡中のエンティティの状態がプロセス間で自動共有されるわけではありません。同じデータベースを使う 2 プロセスの対照でも、一方が保存しても他方の追跡済みエンティティは古い値のままで、再読み込みにより新しい値を取得できることを確認できています。共有データベース上の同時更新やキャッシュの整合性は、別途考慮する必要があります。
- 公式ドキュメントは「多くの Web アプリケーションでは HTTP リクエスト 1 回が作業単位 (unit of work) 1 つに対応するため、コンテキストの寿命をリクエストに合わせるのが適切な既定値」と説明しています。
AddDbContextはDbContextを スコープ付き (scoped) で登録し、リクエストごとに別の DI スコープ、つまり別のDbContextインスタンスが作られます - したがって変更追跡の内容はリクエスト 1 回分で完結し、リクエストやインスタンスをまたいで持ち越されません
- EF Core がプロセス内にキャッシュするのは モデルと LINQ クエリのコンパイル結果 です。これは「クエリツリーの形」をキーにしたキャッシュであり、データベースの状態とは無関係です
一方で、インスタンス数に比例して増えるもの があります。
データベースへの接続数
Section titled “データベースへの接続数”公式の接続プーリングとコンテキストプーリングの説明にあるとおり、接続プールは EF Core の機能ではなく、データベースドライバーがクライアント側で持つ仕組みです。SqlClient の公式ドキュメントは、プールがプロセスや接続文字列などによって分かれると説明しています。そのため、インスタンスを増やすとアプリケーション全体で保持し得る接続数も増えます。ただし、実際の接続数がインスタンス数に比例するとは限らず、各インスタンスの負荷・接続の使用時間・プール設定に依存します。
次は、プロセスごとにプールが分かれることの確認例です。Azure Container Apps と Azure SQL Database (S0) を使い、8 秒かかるクエリの同時実行中に sys.dm_exec_sessions で確認できている値を示します(EF Core 10.0.11、マネージド ID 認証)。
| レプリカ数 | Max Pool Size | データベース側の接続数 | 接続元ホスト数 |
|---|---|---|---|
| 1 | 100(既定) | 10 | 1 |
| 3 | 100(既定) | 64 | 3 |
| 3 | 10 | 30(頭打ち) | 3 |
この確認例では、3 レプリカで Max Pool Size=10 の場合に 30 接続を保持しています。一方、構成時の上限の見積もりは実測値からではなく、プールの分割単位と設定値から行います。公式に記載されているプール上限の既定値は 100 なので、各インスタンスが 1 プールを使う構成で 10 インスタンスなら、上限の合計は 1,000 接続です。これは実際に常時 1,000 接続が使われるという意味ではありません。
Azure SQL Database の公式のリソース制限では、サービスレベルと計算サイズごとにセッション数とワーカー数の上限を定めています。上限に達すると新しい要求が拒否され、クライアントにエラーが返ります。
一方、SqlClient の公式の接続取得の説明では、プール上限に達して利用可能な接続がない場合は待機し、タイムアウトまでに取得できなければ例外になるとされています。次は、その場合のメッセージ例です。
タイムアウトに達しました。プールから接続を取得する前にタイムアウト期間が過ぎました。プールされた接続がすべて使用中で、プール サイズの制限値に達した可能性があります。起動時マイグレーションの同時実行
Section titled “起動時マイグレーションの同時実行”起動時にマイグレーションを適用する構成で複数のインスタンスが同時に起動すると、それぞれが MigrateAsync() を呼びます。公式の適用戦略の説明にあるとおり、EF Core 9 以降はマイグレーションのロックが使われます。
次は、このロックの確認例です。SQL Server 2022 / EF Core 10.0.11 で、10 秒の WAITFOR を含むマイグレーションを 2 プロセスから実行する条件を使っています。A が MigrateAsync を呼ぶ直前の合図を基準とし、約 5 秒後に B プロセスを起動する条件です。所要時間は各 MigrateAsync 呼び出しの経過時間で、数値は 1 回の観測値であり、性能保証ではありません。
| プロセス | 開始の目安(A の合図が基準) | MigrateAsync の所要時間 |
|---|---|---|
| A | 0 秒(呼び出し直前) | 10.6 秒 |
| B | 約 5 秒後にプロセス起動 | 5.3 秒 |
この条件では、B は A のロック解放を待ち、ロックを取得した後は適用済みと判断して 10 秒の処理を実行せずに終了することを確認できています。公式が説明するロックによる保護を裏付ける例です。
ただし公式ドキュメントは、レビュー・最小権限の資格情報・協調的なロールアウト・高可用性が重要な場合は、マイグレーションを独立したデプロイ手順として分けるほうが望ましい としています。スケールアウトする本番環境では、マイグレーションバンドルや SQL スクリプトによる適用を検討してください。
同じ行への同時更新と一時的な障害
Section titled “同じ行への同時更新と一時的な障害”インスタンスが増えれば、別々のインスタンスが同じ行をほぼ同時に更新する可能性も上がります。これは EF Core 固有の問題ではなくデータベースの同時実行制御の話で、対処は付録 EF Core 4で扱う楽観的同時実行制御(IsRowVersion)です。
また、PaaS のデータベースは計画メンテナンスやフェールオーバーで短時間切断されることがあります。EnableRetryOnFailure による再試行の設定は、付録 EF Core 4「接続の回復性とトランザクションの併用」で扱っています。
7. テストとアーキテクチャ
Section titled “7. テストとアーキテクチャ”テスト戦略の選択
Section titled “テスト戦略の選択”EF Core を使うコードのテストには、大きく 2 つのアプローチがあります。
flowchart TB
accTitle: EF Core を使うコードのテスト戦略
accDescr: テストはデータベースに対して行う方法とデータベースを使わない方法に分かれる。前者は本番と同じデータベースか SQLite インメモリ、後者はリポジトリをテストダブルに差し替える。
START["EF Core を使うコードのテスト"]
START --> A["データベースに対してテストする"]
START --> B["データベースを使わずにテストする"]
A --> A1["本番と同じデータベース<br>(ローカル / Docker / Testcontainers)"]
A --> A2["SQLite インメモリ"]
B --> B1["リポジトリを導入してテストダブルに差し替える"]
公式ドキュメントは、本番環境と同じデータベースシステムに対してテストすること を第一に推奨しています。データベースごとに SQL の方言、型の扱い、大文字小文字の区別、トランザクションの挙動が異なるため、別のデータベースでテストしても本番での動作を保証できないからです。
| 手法 | 忠実度 | 速度 | 主な用途 |
|---|---|---|---|
| 本番と同じ DB(Docker / Testcontainers) | 高い | やや遅い | 統合テスト、リポジトリやクエリのテスト |
| SQLite インメモリ | 中程度 | 速い | 単純な CRUD の統合テスト |
| リポジトリのテストダブル | 該当なし(DB を使わない) | 非常に速い | ビジネスロジックの単体テスト |
| InMemory プロバイダー | 低い | 速い | 非推奨 |
InMemory プロバイダーが推奨されない理由
Section titled “InMemory プロバイダーが推奨されない理由”Microsoft.EntityFrameworkCore.InMemory は手軽ですが、公式ドキュメントは テスト用途での使用を強く非推奨 (strongly discouraged) としています。
- 外部キー制約、一意インデックス、データベースの既定値や計算列など、リレーショナルデータベース固有の動作を再現しない(すべての検証・制約が無効になるという意味ではない)
- トランザクションをサポートしない
- 生の SQL クエリを実行できない
- 大文字小文字の区別や NULL の扱いなど、実データベースとの挙動の差が多い
- パフォーマンスの最適化を目的としていない。公式ドキュメントは、一般に SQLite のインメモリモードより遅く動作すると説明しています。5,000 行に対する絞り込みクエリの比較でも約 1.5 倍遅いことを確認できていますが、この倍率は当該条件の補足値です
- 公式ドキュメントは「インメモリデータベースに新機能は追加されていません」と説明している。この開発方針と現時点の機能差を踏まえて選択する(将来のすべての機能追加を予測する主張ではない)
代替として、公式は SQLite のインメモリモードを紹介しています。リレーショナルデータベースとして制約やトランザクションを検証でき、データベースファイルを作らずメモリ内で保持します。本章の確認例でも、ファイル名が空で、接続を閉じた後の新しい接続にはテーブルが残らないことを確認できています。ただし、速度が常に優位とは限らず、SQL Server との機能差も残ります。詳しくは付録6の「SQLite インメモリを使ったテスト」、「SQLite プロバイダーの制限を把握する」を参照してください。
WebApplicationFactory を使った統合テスト
Section titled “WebApplicationFactory を使った統合テスト”ASP.NET Core の統合テストでは、WebApplicationFactory<TEntryPoint> でアプリケーション全体を起動し、ConfigureServices で DbContext の登録だけをテスト用のデータベースに差し替えます。エンドポイントからデータベースまでを通しで検証できるため、EF Core を使う API の回帰テストに向いています。具体的な実装は付録6の「WebApplicationFactory で API ごとテストする」を参照してください。
アーキテクチャ例:レイヤー構成のまとめ
Section titled “アーキテクチャ例:レイヤー構成のまとめ”ここまでの内容を踏まえた、標準的なレイヤー構成の例です。
flowchart TB
accTitle: レイヤー構成の例
accDescr: プレゼンテーション層のコントローラーがアプリケーションサービスを呼び、サービスがドメイン層のリポジトリインターフェイスとエンティティを使う。インフラストラクチャ層のリポジトリ実装が DbContext を通じてデータベースにアクセスする。
subgraph API["プレゼンテーション層(Web API プロジェクト)"]
CTRL["コントローラー / Minimal API<br>DTO の入出力"]
end
subgraph APPL["アプリケーション層"]
SVC["アプリケーションサービス<br>ユースケースとトランザクション境界"]
end
subgraph DOM["ドメイン層"]
ENT["エンティティ / 値オブジェクト<br>ビジネスルール"]
REPO_IF["リポジトリインターフェイス"]
end
subgraph INFRA["インフラストラクチャ層"]
CTX["DbContext / IEntityTypeConfiguration"]
REPO["リポジトリ実装"]
MIG["マイグレーション"]
end
DB[("データベース")]
CTRL --> SVC
SVC --> REPO_IF
SVC --> ENT
REPO -.->|実装| REPO_IF
REPO --> CTX
CTX --> DB
MIG --> DB
| 層 | 主な責務 | テスト方法 |
|---|---|---|
| プレゼンテーション層 | HTTP の入出力、DTO への変換、認可 | WebApplicationFactory による統合テスト |
| アプリケーション層 | ユースケースの調整、トランザクション境界の決定 | リポジトリのテストダブルを使った単体テスト |
| ドメイン層 | ビジネスルール、エンティティの不変条件 | 依存のない単体テスト |
| インフラストラクチャ層 | EF Core によるデータアクセス、外部サービス連携 | 実データベースに対する統合テスト |
小規模なアプリケーションでは、この 4 層をすべて分離する必要はありません。コントローラーから直接 DbContext を使う構成でも、投影・AsNoTracking・トランザクション境界を意識できていれば十分に保守可能です。層の分離は、テストの必要性やチームの規模に応じて段階的に導入してください。
8. 参考ドキュメント
Section titled “8. 参考ドキュメント”- Add と AddAsync の違い | Microsoft Learn
- HiLoValueGenerator クラス | Microsoft Learn
- Entity Framework Core | Microsoft Learn
- C# の LINQ | Microsoft Learn
- 式ツリー | Microsoft Learn
- 共通言語ランタイム (CLR) | Microsoft Learn
- EF Core 10 の新機能 | Microsoft Learn
- EF Core 10 の破壊的変更 | Microsoft Learn
- EF Core 9.0 の破壊的変更 | Microsoft Learn
- EF Core 8.0 の破壊的変更 | Microsoft Learn
- EF Core 7.0 の破壊的変更 | Microsoft Learn
- データベースプロバイダー | Microsoft Learn
- .NET ローカルツールのインストールと使用 | Microsoft Learn
- DbContext の有効期間、構成、初期化 | Microsoft Learn
- 依存関係の挿入:スコープ検証 | Microsoft Learn
- 要求サービスのスコープ管理(ASP.NET Core 10.0.11) | GitHub
- 接続文字列 | Microsoft Learn
- マイグレーションの概要 | Microsoft Learn
- マイグレーションの管理 | Microsoft Learn
- マイグレーションの適用 | Microsoft Learn
- EF Core ツールのリファレンス (.NET CLI) | Microsoft Learn
- リバースエンジニアリング | Microsoft Learn
- データのクエリ | Microsoft Learn
- ログ、イベント、診断 | Microsoft Learn
- Microsoft.Extensions.Logging の使用 | Microsoft Learn
- EntityFrameworkQueryableExtensions.ToQueryString メソッド | Microsoft Learn
- 追跡クエリと非追跡クエリ | Microsoft Learn
- 関連データの読み込み | Microsoft Learn
- 遅延読み込み | Microsoft Learn
- ページネーション | Microsoft Learn
- データの保存 | Microsoft Learn
- 関連データの保存 | Microsoft Learn
- 変更追跡 | Microsoft Learn
- トランザクションの使用 | Microsoft Learn
- 効率的なクエリ | Microsoft Learn
- 効率的な更新 | Microsoft Learn
- OUTPUT 句 | Microsoft Learn
- 高度なパフォーマンストピック | Microsoft Learn
- Azure SQL Database の論理サーバーのリソース制限 | Microsoft Learn
- 単一データベースの DTU リソース制限 | Microsoft Learn
- Azure SQL Database のリソース管理エラー | Microsoft Learn
- SQL Server の接続プール | Microsoft Learn
- EF Core アプリケーションのテスト | Microsoft Learn
- ASP.NET Core での統合テスト | Microsoft Learn