mcpField
E
efcore-patterns

efcore-patterns

github.com/wshaddixデータ・データベース86106 回インストール35 週間前に更新
リポジトリ

efcore-patterns について

efcore-patternsは、Entity Framework Coreを使う.NET開発者向けのスキルです。クエリ、マイグレーション、信頼性、パフォーマンスに関する実践知識をまとめています。

主な機能

  1. 01非追跡クエリを既定にする指針
  2. 02分割クエリによるコレクション読み込み
  3. 03CLIを使ったマイグレーション管理
  4. 04再試行戦略とインターセプター
  5. 05一括操作とコンパイル済みクエリ

活用シーン

  1. 01新規プロジェクトでのEF Core導入
  2. 02複数コレクションを含むクエリの最適化
  3. 03本番データベースのマイグレーション計画
  4. 04変更追跡の問題の調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill efcore-patterns

インストール後、Claude Code で/efcore-patternsを入力して読み込まれたか確認します。

詳細

Entity Framework Core のパターン

このスキルを使用するタイミング

次の場合にこのスキルを使用してください。

  • 新しいプロジェクトで EF Core を設定するとき
  • クエリのパフォーマンスを最適化するとき
  • データベースのマイグレーションを管理するとき
  • EF Core を .NET Aspire と統合するとき
  • 変更追跡に関する問題をデバッグするとき
  • 複数のナビゲーションコレクションを効率よく読み込むとき(クエリ分割)

基本原則

  1. デフォルトでは NoTracking - ほとんどのクエリは読み取り専用です。追跡は必要な場合に有効にしてください
  2. マイグレーションを手動で編集しない - 常に CLI コマンドを使用してください
  3. 専用のマイグレーションサービス - マイグレーションの実行をアプリケーションの起動から分離してください
  4. 再試行には ExecutionStrategy を使用 - 一時的なデータベース障害に対処してください
  5. 明示的に更新する - NoTracking の場合は、エンティティを明示的に更新対象としてマークしてください

DbContext のライフサイクル

DbContext は作業単位であり、短命である必要があります。ASP.NET Core ではスコープとして登録します(リクエストごとに 1 つ)。

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
ライフタイムのルール
シナリオライフタイム登録
Web API / MVC のリクエストScoped(デフォルト)AddDbContext<T>()
バックグラウンドサービスファクトリー経由の ScopedAddDbContextFactory<T>()
Blazor Serverファクトリー経由の ScopedAddDbContextFactory<T>()
コンソールアプリTransient または手動new AppDbContext(options)
長期間稼働するサービスでの DbContextFactory

バックグラウンドサービスや Blazor Server の回路は、単一のスコープより長く存続します。IDbContextFactory<T> を使用してください。

public sealed class OrderProcessor(IDbContextFactory<AppDbContext> contextFactory)
{
    public async Task ProcessBatchAsync(CancellationToken ct)
    {
        await using var db = await contextFactory.CreateDbContextAsync(ct);

        var pending = await db.Orders
            .Where(o => o.Status == OrderStatus.Pending)
            .ToListAsync(ct);

        foreach (var order in pending)
        {
            order.Status = OrderStatus.Processing;
        }

        await db.SaveChangesAsync(ct);
    }
}

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
プーリング

AddDbContextPool<T>() は DbContext インスタンスを再利用し、割り当てのオーバーヘッドを削減します。

builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseNpgsql(connectionString), poolSize: 128);

プーリングの制約: DbContext のサブクラスにリクエストごとの状態を保存しないでください。コンストラクターにスコープ付きサービスを注入しないでください。


パターン 1:デフォルトでは NoTracking

変更追跡をデフォルトで無効にするよう、DbContext を設定します。

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
        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();

書き込みには明示的な処理が必要です。

// 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);
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();
追跡を使用するタイミング
シナリオ追跡を使用するか理由
UI でデータを表示いいえ読み取り専用で、更新しないため
API の GET エンドポイントいいえデータを返すだけで、変更しないため
単一エンティティの更新はい、または明示的に Update()変更を保存する必要があるため
ナビゲーションを含む複雑な更新はい追跡によってリレーションシップが処理されるため
バッチ操作いいえ + ExecuteUpdateより効率的なため
クエリごとの NoTracking
var orders = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(ct);

var ordersWithItems = await db.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Items)
    .Where(o => o.Status == OrderStatus.Active)
    .ToListAsync(ct);

パターン 2:直積爆発を防ぐクエリ分割

Include() で複数のナビゲーションコレクションを読み込むと、EF Core は直積爆発を引き起こす可能性のある単一のクエリを生成します。

問題
// Single query: produces Cartesian product of OrderItems x Payments
var orders = await db.Orders
    .Include(o => o.Items)      // N items
    .Include(o => o.Payments)   // M payments
    .ToListAsync(ct);
// Result set: N x M rows per order
解決策
var orders = await db.Orders
    .Include(o => o.Items)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .ToListAsync(ct);
// Executes 3 separate queries: Orders, Items, Payments
グローバルなデフォルト設定
options.UseNpgsql(connectionString, npgsql =>
    npgsql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
トレードオフ
方法長所短所
単一クエリ(デフォルト)アトミックなスナップショット、1 回のラウンドトリップ複数の Include で直積爆発が発生
分割クエリ直積爆発がなく、データ転送量が少ない複数回のラウンドトリップ、アトミック性の保証なし

目安: 2 つ以上のコレクションナビゲーションを含める場合は AsSplitQuery() を使用してください。


パターン 3:マイグレーション管理

重要: マイグレーションの管理には、必ず EF Core CLI コマンドを使用してください。マイグレーションファイルを手動で編集しないでください(Up()/Down() 内のカスタム SQL を除く)。

マイグレーションの作成
dotnet ef migrations add AddCustomerTable \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

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
マイグレーションの適用
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 スクリプトの生成
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
本番環境向けマイグレーションバンドル
dotnet ef migrations bundle \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api \
    --output efbundle \
    --self-contained

./efbundle --connection "Host=prod-db;Database=myapp;Username=deploy;Password=..."
マイグレーションのベストプラクティス
  1. 本番環境向けには常に冪等スクリプトを生成する(--idempotent フラグ)
  2. 本番環境では起動時に Database.Migrate() を呼び出さない――マイグレーションバンドルまたはスクリプトを使用してください
  3. マイグレーションは追加的な変更にする――デフォルト値付きの列、テーブル、インデックスを追加します
  4. 生成されたコードをレビューする――EF Core のスキャフォールディングでは、予期しない SQL が生成されることがあります
  5. マイグレーション用のプロジェクトを分ける――マイグレーションはインフラストラクチャプロジェクトに配置してください
データシード
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<OrderStatus>().HasData(
        new OrderStatus { Id = 1, Name = "Pending" },
        new OrderStatus { Id = 2, Name = "Processing" },
        new OrderStatus { Id = 3, Name = "Completed" },
        new OrderStatus { Id = 4, Name = "Cancelled" });
}

パターン 4:Aspire を使用した専用のマイグレーションサービス

マイグレーションの実行をメインアプリケーションから分離します。

プロジェクト構成
src/
├── MyApp.AppHost/           # Aspire orchestration
├── MyApp.Api/               # Main application
├── MyApp.Infrastructure/    # DbContext and migrations
└── MyApp.MigrationService/  # Dedicated migration runner
MigrationWorker.cs
public class MigrationWorker : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IHostApplicationLifetime _hostApplicationLifetime;
    private readonly ILogger<MigrationWorker> _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
        {
            _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("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");

var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
    .WaitFor(db)
    .WithReference(db);

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WaitForCompletion(migrations)
    .WithReference(db);

パターン 5:一時的な障害に対する 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();
    });
}
トランザクションを使用する場合
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;
    }
});
プロバイダー固有の構成
// PostgreSQL
options.UseNpgsql(connectionString, npgsql =>
    npgsql.EnableRetryOnFailure(
        maxRetryCount: 3,
        maxRetryDelay: TimeSpan.FromSeconds(30),
        errorCodesToAdd: null));

// SQL Server
options.UseSqlServer(connectionString, sqlServer =>
    sqlServer.EnableRetryOnFailure(
        maxRetryCount: 3,
        maxRetryDelay: TimeSpan.FromSeconds(30),
        errorNumbersToAdd: null));

パターン 6:インターセプター

EF Core のインターセプターを使うと、エンティティのロジックを変更せずに横断的関心事に対応できます。

監査タイムスタンプインターセプター
public sealed class AuditTimestampInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        if (eventData.Context is null)
            return ValueTask.FromResult(result);

        var now = DateTimeOffset.UtcNow;

        foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditable>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = now;
                    entry.Entity.UpdatedAt = now;
                    break;
                case EntityState.Modified:
                    entry.Entity.UpdatedAt = now;
                    break;
            }
        }

        return ValueTask.FromResult(result);
    }
}

public interface IAuditable
{
    DateTimeOffset CreatedAt { get; set; }
    DateTimeOffset UpdatedAt { get; set; }
}
論理削除インターセプター
public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        if (eventData.Context is null)
            return ValueTask.FromResult(result);

        foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = DateTimeOffset.UtcNow;
            }
        }

        return ValueTask.FromResult(result);
    }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .HasQueryFilter(p => !p.IsDeleted);
}
登録
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
    options.UseNpgsql(connectionString)
           .AddInterceptors(
               sp.GetRequiredService<AuditTimestampInterceptor>(),
               sp.GetRequiredService<SoftDeleteInterceptor>()));

builder.Services.AddSingleton<AuditTimestampInterceptor>();
builder.Services.AddSingleton<SoftDeleteInterceptor>();

パターン 7:コンパイル済みクエリ

非常に頻繁に実行されるクエリの場合:

public static class CompiledQueries
{
    public static readonly Func<AppDbContext, int, Task<Order?>>
        GetOrderById = EF.CompileAsyncQuery(
            (AppDbContext db, int orderId) =>
                db.Orders
                    .AsNoTracking()
                    .Include(o => o.Items)
                    .FirstOrDefault(o => o.Id == orderId));

    public static readonly Func<AppDbContext, string, IAsyncEnumerable<Order>>
        GetOrdersByCustomer = EF.CompileAsyncQuery(
            (AppDbContext db, string customerId) =>
                db.Orders
                    .AsNoTracking()
                    .Where(o => o.CustomerId == customerId)
                    .OrderByDescending(o => o.CreatedAt));
}

var order = await CompiledQueries.GetOrderById(db, orderId);

await foreach (var o in CompiledQueries.GetOrdersByCustomer(db, customerId)
    .WithCancellation(ct))
{
}

使用する場面: 1 秒あたり数千回実行されるクエリです。一般的な CRUD では、標準の LINQ で十分です。


パターン 8:一括操作

EF Core 7 以降の ExecuteUpdateAsync と ExecuteDeleteAsync を使用します。

// WRONG - 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();

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

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. 追跡の競合
// Tracking conflict
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync();

// Use single context or detach
_db1.Entry(order1).State = EntityState.Detached;
4. ループ内でのクエリ実行
// Query per iteration
foreach (var orderId in orderIds)
{
    var order = await _db.Orders.FindAsync(orderId);
}

// Single query
var orders = await _db.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync();

EF Core を使用したテスト

In-Memory プロバイダー(ユニットテストのみ)
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
    .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
    .Options;

using var context = new ApplicationDbContext(options);
TestContainers を使用した実データベース
var container = new PostgreSqlBuilder()
    .WithImage("postgres:16-alpine")
    .Build();

await container.StartAsync();

var options = new DbContextOptionsBuilder<ApplicationDbContext>()
    .UseNpgsql(container.GetConnectionString())
    .Options;

エージェントに関する注意点

  1. シングルトンサービスに DbContext を注入しないでください -- 代わりに IDbContextFactory<T> を使用してください。
  2. CancellationToken の伝播を忘れないでください -- すべての非同期 EF Core メソッドに ct を渡してください。
  3. マイグレーションと併せて Database.EnsureCreated() を使用しないでください -- テストシナリオでのみ使用してください。
  4. 複数回の呼び出しにわたって SaveChangesAsync がトランザクションを維持すると想定しないでください -- 明示的なトランザクションで囲んでください。
  5. 接続文字列をハードコードしないでください -- 構成から読み込んでください。
  6. Validator.TryValidateObject には常に validateAllProperties: true を渡してください。

参考資料

コメント