コンテンツにスキップ

第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、リポジトリパターン

  1. 概要と設計方針
  2. DbContext と ASP.NET Core への組み込み
  3. クエリの基本
  4. 保存とトランザクションの基本
  5. マイグレーションとスキーマ管理
  6. 本番運用とスケールアウト
  7. テストとアーキテクチャ
  8. 参考ドキュメント

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) リリースです。

細かい説明はこのあとの節で行いますが、先に「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); // 1
Console.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] >= 3
ORDER 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] = @p0
OUTPUT 1
WHERE [Id] = @p1;

.NET でデータベースにアクセスする手段は EF Core だけではありません。用途に応じて選択します。

手段特徴向いている場面
EF CoreLINQ、変更追跡、マイグレーションを備えたフル機能の 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 AnalyticsMicrosoft.EntityFrameworkCore.SqlServerEF Core プロジェクト (Microsoft)8, 9, 10
SQLiteMicrosoft.EntityFrameworkCore.SqliteEF Core プロジェクト (Microsoft)8, 9, 10
Azure Cosmos DB for NoSQLMicrosoft.EntityFrameworkCore.CosmosEF Core プロジェクト (Microsoft)8, 9, 10
PostgreSQLNpgsql.EntityFrameworkCore.PostgreSQLNpgsql Development Team8, 9
MySQL / MariaDBPomelo.EntityFrameworkCore.MySqlPomelo Foundation Project8, 9

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 を生成します。

Terminal window
dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True" Microsoft.EntityFrameworkCore.SqlServer --output-dir Models

パッケージの追加とツールの準備

Section titled “パッケージの追加とツールの準備”

Web API プロジェクトに SQL Server プロバイダーを追加します。

Terminal window
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design

Microsoft.EntityFrameworkCore.Design は、マイグレーションの生成やスキャフォールディングといった 設計時 (design-time) の操作に必要です。実行時には不要ですが、dotnet ef コマンドを使うプロジェクトには追加しておきます。

続いて EF Core のコマンドラインツールをインストールします。

Terminal window
dotnet tool install --global dotnet-ef

すでにインストール済みの場合は更新します。

Terminal window
dotnet tool update --global dotnet-ef

インストールを確認します。

Terminal window
dotnet ef --version

2. DbContext と ASP.NET Core への組み込み

Section titled “2. DbContext と ASP.NET Core への組み込み”

エンティティは特別な基底クラスを継承しない、単なる 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 は、エンティティのセット(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 から構成を受け取るために必要です。

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);
}

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 で扱います。

高スループットのアプリケーションでは、AddDbContext の代わりに AddDbContextPool を使うと、DbContext インスタンスを再利用するプールが有効になり、インスタンスの生成コストを削減できます。ただしプールされたインスタンスは再利用されるため、リクエストごとに変わる状態をフィールドに保持する設計とは相性が悪くなります。詳しくは付録5の「DbContext プーリングでインスタンスを使い回す」を参照してください。

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()

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);

既定では、クエリが返したエンティティは 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);

ナビゲーションプロパティを一緒に読み込むには 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 の「関連データを読み込まずに数える」を参照してください。

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);

エンティティ全体ではなく、必要な列だけを取得する 投影 は最も効果的な最適化の 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 ページ目へ」のようなランダムアクセスができない

LINQ で書いたクエリも、次節で扱う SaveChangesAsync も、最終的には SQL に変換されてデータベースへ送られます。この変換は自動で行われるため、書いたコードからは実際に流れる SQL が見えません。意図しない結合や全件取得が起きていないかを確かめるために、EF Core は生成された SQL を取り出す手段を用意しています。

手段見えるものクエリを実行するか
ToQueryString()クエリの SQL実行しない
ログログレベル・カテゴリーなどの設定で記録対象となる SQL(クエリ・保存・マイグレーション)実行時に記録する
インターセプター実行直前の DbCommand実行する

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. 保存とトランザクションの基本”

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. マイグレーションとスキーマ管理”

マイグレーションは、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 “マイグレーションの作成と適用”

最初のマイグレーションを作成します。

Terminal window
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 になります。

データベースに適用します。

Terminal window
dotnet ef database update

モデルを変更したら、変更内容が分かる名前でマイグレーションを追加します。

Terminal window
dotnet ef migrations add AddPostPublishedAt
dotnet 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 scriptSQL スクリプトを生成する
dotnet ef migrations bundleマイグレーションバンドル(実行可能ファイル)を生成する

公式ドキュメントは、用途に応じて次の 4 つの戦略を挙げています。

戦略推奨される用途実行前に SQL を確認できるか実行時に SDK とソースが必要か
SQL スクリプトDBA による承認・レビューが必要な運用できる不要
マイグレーションバンドル自動デプロイできない不要
EF コマンドラインツールローカル開発とテストできない必要
実行時マイグレーション起動時マイグレーションの制約を許容できるアプリケーションできない不要

SQL スクリプトとマイグレーションバンドル

Section titled “SQL スクリプトとマイグレーションバンドル”

SQL スクリプトは dotnet ef migrations script で生成します。--idempotent を付けると、各マイグレーションが未適用かどうかを確認してから実行するスクリプトになるため、適用状況が分からないデータベースにも安全に流せます。

自動デプロイには マイグレーションバンドル が推奨されます。公式ドキュメントによれば、バンドルは CI で生成でき、実行時に .NET SDK も EF Core ツールもアプリケーションのソースコードも不要な単一の実行可能ファイルです。

Terminal window
dotnet ef migrations script --idempotent --output artifacts/migrations.sql
dotnet 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 を確認・修正する機会がない
  • 問題が起きたときのロールバックが他の戦略ほど容易ではない
  • 別のアプリケーションがデータベースにアクセスしている最中にマイグレーションが走ると、深刻な問題を引き起こす可能性がある

複数インスタンスでも動く理由

Section titled “複数インスタンスでも動く理由”

公式の変更追跡は、エンティティを個々の DbContext が追跡する仕組みを説明しています。PaaS などで複数のアプリケーションプロセスを動かす場合も、追跡中のエンティティの状態がプロセス間で自動共有されるわけではありません。同じデータベースを使う 2 プロセスの対照でも、一方が保存しても他方の追跡済みエンティティは古い値のままで、再読み込みにより新しい値を取得できることを確認できています。共有データベース上の同時更新やキャッシュの整合性は、別途考慮する必要があります。

  • 公式ドキュメントは「多くの Web アプリケーションでは HTTP リクエスト 1 回が作業単位 (unit of work) 1 つに対応するため、コンテキストの寿命をリクエストに合わせるのが適切な既定値」と説明しています。AddDbContext は DbContext を スコープ付き (scoped) で登録し、リクエストごとに別の DI スコープ、つまり別の DbContext インスタンスが作られます
  • したがって変更追跡の内容はリクエスト 1 回分で完結し、リクエストやインスタンスをまたいで持ち越されません
  • EF Core がプロセス内にキャッシュするのは モデルと LINQ クエリのコンパイル結果 です。これは「クエリツリーの形」をキーにしたキャッシュであり、データベースの状態とは無関係です

一方で、インスタンス数に比例して増えるもの があります。

公式の接続プーリングとコンテキストプーリングの説明にあるとおり、接続プールは EF Core の機能ではなく、データベースドライバーがクライアント側で持つ仕組みです。SqlClient の公式ドキュメントは、プールがプロセスや接続文字列などによって分かれると説明しています。そのため、インスタンスを増やすとアプリケーション全体で保持し得る接続数も増えます。ただし、実際の接続数がインスタンス数に比例するとは限らず、各インスタンスの負荷・接続の使用時間・プール設定に依存します。

次は、プロセスごとにプールが分かれることの確認例です。Azure Container Apps と Azure SQL Database (S0) を使い、8 秒かかるクエリの同時実行中に sys.dm_exec_sessions で確認できている値を示します(EF Core 10.0.11、マネージド ID 認証)。

レプリカ数Max Pool Sizeデータベース側の接続数接続元ホスト数
1100(既定)101
3100(既定)643
31030(頭打ち)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 の所要時間
A0 秒(呼び出し直前)10.6 秒
B約 5 秒後にプロセス起動5.3 秒

この条件では、B は A のロック解放を待ち、ロックを取得した後は適用済みと判断して 10 秒の処理を実行せずに終了することを確認できています。公式が説明するロックによる保護を裏付ける例です。

ただし公式ドキュメントは、レビュー・最小権限の資格情報・協調的なロールアウト・高可用性が重要な場合は、マイグレーションを独立したデプロイ手順として分けるほうが望ましい としています。スケールアウトする本番環境では、マイグレーションバンドルや SQL スクリプトによる適用を検討してください。

同じ行への同時更新と一時的な障害

Section titled “同じ行への同時更新と一時的な障害”

インスタンスが増えれば、別々のインスタンスが同じ行をほぼ同時に更新する可能性も上がります。これは EF Core 固有の問題ではなくデータベースの同時実行制御の話で、対処は付録 EF Core 4で扱う楽観的同時実行制御(IsRowVersion)です。

また、PaaS のデータベースは計画メンテナンスやフェールオーバーで短時間切断されることがあります。EnableRetryOnFailure による再試行の設定は、付録 EF Core 4「接続の回復性とトランザクションの併用」で扱っています。

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・トランザクション境界を意識できていれば十分に保守可能です。層の分離は、テストの必要性やチームの規模に応じて段階的に導入してください。