efcore-patterns について
Entity Framework Core を使う .NET 開発者向けのパターンガイドです。クエリ、マイグレーション、再試行、DbContext のライフタイムを扱い、既定の NoTracking や一括操作、分割クエリも解説します。
主な機能
- 01NoTracking を既定にする設定
- 02CLI によるマイグレーション管理
- 03専用マイグレーションサービスのパターン
- 04一時的な障害への再試行戦略
- 05一括更新と一括削除
- 06分割クエリの設定
活用シーン
- 01読み取り専用クエリの性能改善
- 02データベースマイグレーションの作成と適用
- 03一時的なデータベース障害への対応
- 04複数のナビゲーションコレクションの読み込み
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ aaronontheweb/ dotnet-skills --skill efcore-patternsインストール後、Claude Code で/efcore-patternsを入力して読み込まれたか確認します。
詳細
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)
{
// Disable change tracking by default for better performance on read-only queries
// Use .AsTracking() explicitly for queries that need to track changes
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
}
NoTracking が有効な場合
読み取り専用クエリは通常どおり動作します:
// ✅ Fast read - no tracking overhead
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
書き込みには明示的な処理が必要です:
// ❌ WRONG - Entity not tracked, SaveChanges does nothing
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Nothing happens!
// ✅ CORRECT - Explicitly mark entity for update
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // Marks entire entity as modified
await dbContext.SaveChangesAsync();
// ✅ ALSO CORRECT - Use AsTracking() for the query
var order = await dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Works!
追跡を使う場面
| シナリオ | 追跡を使うか? | 理由 |
|---|---|---|
| UI にデータを表示する | いいえ | 読み取り専用で、更新しないため |
| API の GET エンドポイント | いいえ | データを返すだけで、変更しないため |
| 単一エンティティを更新する | はい、または明示的に Update() | 変更を保存する必要があるため |
| ナビゲーションを含む複雑な更新 | はい | 追跡によってリレーションシップが処理されるため |
| 一括操作 | いいえ+ExecuteUpdate | より効率的なため |
明示的な Add/Update パターン
public class OrderService
{
private readonly ApplicationDbContext _db;
// CREATE - Always use Add (works regardless of tracking)
public async Task<Order> CreateOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
// UPDATE - Explicitly mark as modified
public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
?? throw new NotFoundException($"Order {orderId} not found");
order.Status = newStatus;
order.UpdatedAt = DateTimeOffset.UtcNow;
// Explicitly mark as modified since DbContext uses NoTracking by default
_db.Orders.Update(order);
await _db.SaveChangesAsync();
}
// DELETE - Attach and remove
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 を除く) - マイグレーションファイルを直接削除する
- マイグレーションファイルの名前を変更する
- プロジェクト間でマイグレーションをコピーする
マイグレーションの作成
# Create a new migration
dotnet ef migrations add AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# With a specific DbContext (if you have multiple)
dotnet ef migrations add AddCustomerTable \
--context ApplicationDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
マイグレーションの削除
# Remove the last migration (if not yet applied)
dotnet ef migrations remove \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# NEVER do this:
# rm Migrations/20240101_AddCustomerTable.cs # ❌ BAD!
マイグレーションの適用
# Apply all pending migrations
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Apply to a specific migration
dotnet ef database update AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Rollback to a previous migration
dotnet ef database update PreviousMigrationName \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
SQL スクリプトの生成
# Generate SQL script for all migrations
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output migrations.sql
# Generate idempotent script (safe to run multiple times)
dotnet ef migrations script \
--idempotent \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
パターン 3:Aspire を使った専用のマイグレーションサービス
専用のマイグレーションサービスを使って、マイグレーションの実行をメインアプリケーションから分離します。これにより、次のことが保証されます:
- アプリの起動前にマイグレーションが完了する
- 関心事が明確に分離される
- テスト環境でのシード処理を制御できる
プロジェクト構成
src/
├── MyApp.AppHost/ # Aspire orchestration
├── MyApp.Api/ # Main application
├── MyApp.Infrastructure/ # DbContext and migrations
└── MyApp.MigrationService/ # Dedicated migration runner
MigrationService の Program.cs
using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Add Aspire service defaults
builder.AddServiceDefaults();
// Add PostgreSQL DbContext
var connectionString = builder.Configuration.GetConnectionString("appdb")
?? throw new InvalidOperationException("Connection string 'appdb' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));
// Add the migration worker
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("Migration service starting...");
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await RunMigrationsAsync(dbContext, stoppingToken);
_logger.LogInformation("Migration service completed successfully.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Migration service failed: {Error}", ex.Message);
throw;
}
finally
{
// Stop the application after migrations complete
_hostApplicationLifetime.StopApplication();
}
}
private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
{
// Use execution strategy for transient failure handling
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);
if (pendingMigrations.Any())
{
_logger.LogInformation("Applying {Count} pending migrations...",
pendingMigrations.Count());
await dbContext.Database.MigrateAsync(ct);
_logger.LogInformation("Migrations applied successfully.");
}
else
{
_logger.LogInformation("No pending migrations. Database is up to date.");
}
});
}
}
AppHost の構成
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");
// Migrations run first, then exit
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
.WaitFor(db)
.WithReference(db);
// API waits for migrations to complete
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WaitForCompletion(migrations) // Key: waits for migrations to finish
.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 () =>
{
// Transaction must be INSIDE the strategy callback
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// ... your operations ...
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
パターン 5:ExecuteUpdate/ExecuteDelete による一括操作
一括操作では、エンティティを読み込む代わりに、EF Core 7+ の ExecuteUpdateAsync と ExecuteDeleteAsync を使います:
// ❌ SLOW - Loads all entities into memory
var expiredOrders = await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();
// ✅ FAST - Single SQL UPDATE statement
await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
// ✅ FAST - Single SQL DELETE statement
await _db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
よくある落とし穴
1. NoTracking で更新を忘れる
// ❌ Silent failure - entity not tracked
var customer = await _db.Customers.FindAsync(id);
customer.Name = "New Name";
await _db.SaveChangesAsync(); // Does nothing!
// ✅ Explicit update
var customer = await _db.Customers.FindAsync(id);
customer.Name = "New Name";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();
2. N+1 クエリ問題
// ❌ N+1 queries - one query per order
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // Lazy load triggers query
}
// ✅ Eager loading - single query
var customers = await _db.Customers
.Include(c => c.Orders)
.ToListAsync();
3. 複数の DbContext インスタンスによる追跡の競合
// ❌ Tracking conflict - entity tracked by different context
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // May throw or behave unexpectedly
// ✅ Use single context or detach first
_db1.Entry(order1).State = EntityState.Detached;
4. 一貫して非同期処理を使わない
// ❌ Blocking call in async context
var orders = _db.Orders.ToList(); // Blocks thread
// ✅ Async all the way
var orders = await _db.Orders.ToListAsync();
5. ループ内でクエリを実行する
// ❌ Query per iteration
foreach (var orderId in orderIds)
{
var order = await _db.Orders.FindAsync(orderId);
// process order
}
// ✅ Single query
var orders = await _db.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync();
DI における DbContext のライフタイム
ASP.NET Core(デフォルトで Scoped)
// Scoped = one instance per HTTP request
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
バックグラウンドサービス(スコープを作成)
public class MyBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// ✅ Create scope for each unit of work
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// ... use dbContext ...
}
}
アクター/長期間存続するオブジェクト(ファクトリーパターン)
public class OrderActor : ReceiveActor
{
private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
public OrderActor(IDbContextFactory<ApplicationDbContext> dbFactory)
{
_dbFactory = dbFactory;
ReceiveAsync<GetOrder>(async msg =>
{
// Create fresh context for each operation
await using var db = await _dbFactory.CreateDbContextAsync();
var order = await db.Orders.FindAsync(msg.OrderId);
Sender.Tell(order);
});
}
}
// Registration
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
パターン 6:直積爆発を防ぐクエリ分割
Include() を使って複数のナビゲーションコレクションを読み込むと、EF Core は単一のクエリを生成し、直積爆発を引き起こすことがあります。注文が 10 件あり、それぞれに商品が 10 個ある場合、10 + 10 行ではなく 100 行が返されます。
グローバル設定(ほとんどの場合に推奨)
DbContext の構成で、クエリ分割をグローバルに有効にします:
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
}));
クエリ単位の上書き
より効率的だと分かっている場合は、単一クエリを使います:
// Use single query when you know the structure is well-understood
var orders = await dbContext.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSingleQuery() // Override global split behavior
.ToListAsync();
トレードオフ
| 動作 | メリット | デメリット |
|---|---|---|
| SplitQuery | カルテシアン爆発が発生せず、大きなコレクションに適しています | 複数回のラウンドトリップが必要で、一貫性に問題が生じる可能性があります |
| SingleQuery | ラウンドトリップが1回で済み、トランザクションの一貫性が保たれます | 複数のコレクションがある場合、カルテシアン爆発が発生します |
推奨事項:全体のデフォルトを SplitQuery に設定し、単一クエリのほうが適していると分かっている特定のクエリでは AsSingleQuery() で上書きします。
SingleQuery を優先するケース
- 小規模で把握しやすいナビゲーショングラフ(2~3 レベル)
- 関連データを常にすべて必要とするクエリ
- ラウンドトリップのコストがカルテシアン爆発より低い、パフォーマンスが重要な処理経路
SplitQuery を優先するケース
- 大規模、または予測が難しいナビゲーショングラフ
- 多対多のリレーションシップ
- 必ずしもすべてが必要とは限らないコレクションを読み込むクエリ
EF Core でのテスト
インメモリプロバイダー(ユニットテストのみ)
// Only for simple unit tests - doesn't match real database behavior
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new ApplicationDbContext(options);
TestContainers を使った実データベース(統合テスト)
適切なデータベーステストについては、testcontainers-integration-tests スキルを参照してください。
// Use real PostgreSQL in container
var container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await container.StartAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(container.GetConnectionString())
.Options;