Entity Framework Core パターン
このスキルを使用するタイミング
以下の場合にこのスキルを使用します:
- 新しいプロジェクトで EF Core をセットアップするとき
- クエリパフォーマンスを最適化するとき
- データベースマイグレーションを管理するとき
- EF Core を .NET Aspire と統合するとき
- 変更追跡の問題をデバッグするとき
- 複数のナビゲーションコレクションを効率的に読み込むとき(クエリ分割)
基本原則
- デフォルトの NoTracking - ほとんどのクエリは読み取り専用です。追跡は明示的にオプトインします
- マイグレーションを手動で編集しない - 常に CLI コマンドを使用します
- 専用マイグレーションサービス - マイグレーションの実行をアプリケーションの起動から分離します
- 再試行のための ExecutionStrategy - 一時的なデータベース障害を処理します
- 明示的な更新 - NoTracking の場合、エンティティを明示的に更新対象としてマークします
パターン 1: デフォルトの NoTracking
DbContext を構成して、デフォルトで変更追跡を無効にします。これにより、読み取りが多いワークロードのパフォーマンスが向上します。
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
// 読み取り専用クエリのパフォーマンス向上のため、デフォルトで変更追跡を無効にする
// 変更を追跡する必要があるクエリには、明示的に .AsTracking() を使用する
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
}
NoTracking が有効な場合
読み取り専用クエリは通常通り動作します:
// ✅ 高速な読み取り - 追跡のオーバーヘッドなし
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
書き込みには明示的な処理が必要です:
// ❌ 誤り - エンティティが追跡されていないため、SaveChanges は何もしない
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 何も起きない!
// ✅ 正しい - エンティティを明示的に更新対象としてマークする
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // エンティティ全体を変更済みとしてマーク
await dbContext.SaveChangesAsync();
// ✅ これも正しい - クエリに AsTracking() を使用する
var order = await dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 動作する!
追跡を使用するタイミング
| シナリオ | 追跡を使用するか? | 理由 |
|---|---|---|
| UI にデータを表示する | いいえ | 読み取り専用、更新なし |
| API GET エンドポイント | いいえ | データを返すのみ、変更なし |
| 単一エンティティの更新 | はい または 明示的な Update() | 変更を保存する必要がある |
| ナビゲーションを含む複雑な更新 | はい | 追跡がリレーションシップを処理する |
| バッチ操作 | いいえ + ExecuteUpdate | より効率的 |
明示的な追加/更新パターン
public class OrderService
{
private readonly ApplicationDbContext _db;
// 作成 - 常に Add を使用する(追跡設定に関係なく動作する)
public async Task<Order> CreateOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
// 更新 - 明示的に変更済みとしてマークする
public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
?? throw new NotFoundException($"注文 {orderId} が見つかりません");
order.Status = newStatus;
order.UpdatedAt = DateTimeOffset.UtcNow;
// DbContext がデフォルトで NoTracking を使用するため、明示的に変更済みとしてマークする
_db.Orders.Update(order);
await _db.SaveChangesAsync();
}
// 削除 - アタッチして削除する
public async Task DeleteOrderAsync(Guid orderId)
{
var order = new Order { Id = orderId };
_db.Orders.Remove(order);
await _db.SaveChangesAsync();
}
}
パターン 2: マイグレーションを手動で編集しない
重要: EF Core CLI コマンドを使用してマイグレーションを管理してください。以下のことは絶対に行わないでください:
- マイグレーションファイルを手動で編集する(
Up()/Down()内のカスタム SQL を除く) - マイグレーションファイルを直接削除する
- マイグレーションファイルの名前を変更する
- マイグレーションをプロジェクト間でコピーする
マイグレーションの作成
# 新しいマイグレーションを作成する
dotnet ef migrations add AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 特定の DbContext を指定する場合(複数の DbContext がある場合)
dotnet ef migrations add AddCustomerTable \
--context ApplicationDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
マイグレーションの削除
# 最後のマイグレーションを削除する(まだ適用されていない場合)
dotnet ef migrations remove \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 絶対にしないでください:
# rm Migrations/20240101_AddCustomerTable.cs # ❌ 悪い例!
マイグレーションの適用
# 保留中のすべてのマイグレーションを適用する
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 特定のマイグレーションまで適用する
dotnet ef database update AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# 以前のマイグレーションにロールバックする
dotnet ef database update PreviousMigrationName \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
SQL スクリプトの生成
# すべてのマイグレーションの SQL スクリプトを生成する
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output migrations.sql
# べき等なスクリプトを生成する(複数回実行しても安全)
dotnet ef migrations script \
--idempotent \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
パターン 3: Aspire を使用した専用マイグレーションサービス
マイグレーションの実行をメインアプリケーションから分離し、専用のマイグレーションサービスを使用します。これにより以下が保証されます:
- アプリケーションの起動前にマイグレーションが完了する
- 関心事の明確な分離
- テスト環境での制御されたシード
プロジェクト構成
src/
├── MyApp.AppHost/ # Aspire オーケストレーション
├── MyApp.Api/ # メインアプリケーション
├── MyApp.Infrastructure/ # DbContext とマイグレーション
└── MyApp.MigrationService/ # 専用マイグレーションランナー
MigrationService Program.cs
using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Aspire サービスのデフォルトを追加
builder.AddServiceDefaults();
// PostgreSQL DbContext を追加
var connectionString = builder.Configuration.GetConnectionString("appdb")
?? throw new InvalidOperationException("接続文字列 'appdb' が見つかりません。");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));
// マイグレーションワーカーを追加
builder.Services.AddHostedService<MigrationWorker>();
var host = builder.Build();
host.Run();
MigrationWorker.cs
public class MigrationWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<MigrationWorker> _logger;
public MigrationWorker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime,
ILogger<MigrationWorker> logger)
{
_serviceProvider = serviceProvider;
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("マイグレーションサービスを開始しています...");
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await RunMigrationsAsync(dbContext, stoppingToken);
_logger.LogInformation("マイグレーションサービスが正常に完了しました。");
}
catch (Exception ex)
{
_logger.LogError(ex, "マイグレーションサービスが失敗しました: {Error}", ex.Message);
throw;
}
finally
{
// マイグレーション完了後にアプリケーションを停止する
_hostApplicationLifetime.StopApplication();
}
}
private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
{
// 一時的な障害処理のために実行戦略を使用
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);
if (pendingMigrations.Any())
{
_logger.LogInformation("{Count} 個の保留中のマイグレーションを適用しています...",
pendingMigrations.Count());
await dbContext.Database.MigrateAsync(ct);
_logger.LogInformation("マイグレーションが正常に適用されました。");
}
else
{
_logger.LogInformation("保留中のマイグレーションはありません。データベースは最新です。");
}
});
}
}
AppHost の構成
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");
// マイグレーションが最初に実行され、その後終了する
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
.WaitFor(db)
.WithReference(db);
// API はマイグレーションの完了を待機する
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WaitForCompletion(migrations) // 重要:マイグレーションが完了するのを待つ
.WithReference(db);
パターン 4: 一時的な障害のための ExecutionStrategy
一時的に失敗する可能性のある操作には、常に CreateExecutionStrategy() を使用します:
public async Task UpdateWithRetryAsync(Guid id, Action<Order> update)
{
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var order = await _dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return;
update(order);
await _dbContext.SaveChangesAsync();
});
}
重要: CreateExecutionStrategy() をユーザーが開始したトランザクションと一緒に使用することはできません。再試行付きのトランザクションが必要な場合:
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// トランザクションは戦略コールバックの内部になければならない
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// ... 操作 ...
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
パターン 5: ExecuteUpdate/ExecuteDelete を使用したバルク操作
バルク操作には、エンティティをロードする代わりに EF Core 7 以降の ExecuteUpdateAsync と ExecuteDeleteAsync を使用します:
// ❌ 低速 - すべてのエンティティをメモリにロードする
var expiredOrders = await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();
// ✅ 高速 - 単一の SQL UPDATE ステートメント
await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
// ✅ 高速 - 単一の SQL DELETE ステートメント
await _db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
よくある落とし穴
1. NoTracking 時の更新を忘れる
// ❌ 暗黙の失敗 - エンティティが追跡されていない
var customer = await _db.Customers.FindAsync(id);
customer.Name = "新しい名前";
await _db.SaveChangesAsync(); // 何もしない!
// ✅ 明示的な更新
var customer = await _db.Customers.FindAsync(id);
customer.Name = "新しい名前";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();
2. N+1 クエリ問題
// ❌ N+1 クエリ - 注文ごとに 1 つのクエリ
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // 遅延ロードがクエリをトリガー
}
// ✅ 一括読み込み - 単一のクエリ
var customers = await _db.Customers
.Include(c => c.Orders)
.ToListAsync();
3. 複数の DbContext インスタンスによる追跡の競合
// ❌ 追跡の競合 - エンティティが異なるコンテキストで追跡されている
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // 例外がスローされるか、予期しない動作をする可能性がある
// ✅ 単一のコンテキストを使用するか、最初にデタッチする
_db1.Entry(order1).State = EntityState.Detached;
4. Async を一貫して使用しない
// ❌ async コンテキストでのブロッキング呼び出し
var orders = _db.Orders.ToList(); // スレッドをブロックする
// ✅ すべて非同期で
var orders = await _db.Orders.ToListAsync();
5. ループ内でのクエリ
// ❌ 反復ごとにクエリ
foreach (var orderId in orderIds)
{
var order = await _db.Orders.FindAsync(orderId);
// 注文を処理
}
// ✅ 単一のクエリ
var orders = await _db.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync();
DI における DbContext のライフタイム
ASP.NET Core(デフォルトで Scoped)
// Scoped = HTTP リクエストごとに 1 つのインスタンス
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
…
[翻訳のため省略]