ef-core について
ef-core は、EF Core(.NET 10)でデータアクセス層を構築する開発者向けのスキルです。クエリ、永続化、マイグレーション、モデル設計を扱い、射影クエリとマイグレーションのレビューを重視します。
主な機能
- 01DbContext の設定と登録
- 02射影クエリとページングのパターン
- 03一括操作とコンパイル済みクエリ
- 04インターセプターとグローバルクエリフィルター
- 05マイグレーション手順とアンチパターン
活用シーン
- 01CRUD データアクセス層の構築
- 02頻繁に実行する読み取りクエリの調整
- 03論理削除やマルチテナントの管理
- 04データベースマイグレーションの確認と適用
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill ef-coreインストール後、Claude Code で/ef-coreを入力して読み込まれたか確認します。
詳細
EF Core (.NET 10)
基本原則
- EF CoreをデフォルトのORMとして使用します — 特別な理由(極端なパフォーマンス要件、外部キー制約のないレガシーデータベース)がない限り、EF Coreを使用してください。ADR-003を参照してください。
- DbContextは作業単位です — 別のUoW抽象化でラップしないでください。EF Coreには、Unit of WorkパターンとRepositoryパターンがすでに内部的に実装されています。
- クエリではプロジェクションを使用します — エンティティ全体を読み込む代わりに、
.Select()を使用してDTOにプロジェクションしてください。これにより、過剰なデータ取得やN+1問題を防げます。 - マイグレーションはコードです — ほかのソースコードと同様に扱ってください。レビューとテストを行い、本番環境では決して自動適用しないでください。
パターン
DbContextの構成
エンティティの構成を分離し、見つけやすくするために、IEntityTypeConfiguration<T>を使用してください。
// Persistence/AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
// Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Total)
.HasPrecision(18, 2);
builder.HasMany(o => o.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(o => o.CustomerId);
builder.HasIndex(o => o.CreatedAt);
}
}
登録
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
クエリのプロジェクション(過剰なデータ取得を回避)
// GOOD — project to DTO, only loads needed columns
public async Task<OrderResponse?> GetOrderAsync(Guid id, CancellationToken ct)
{
return await db.Orders
.Where(o => o.Id == id)
.Select(o => new OrderResponse(
o.Id,
o.Total,
o.CreatedAt,
o.Items.Select(i => new OrderItemResponse(i.ProductName, i.Quantity, i.Price)).ToList()))
.FirstOrDefaultAsync(ct);
}
ページネーション
public async Task<PagedList<OrderSummary>> ListOrdersAsync(int page, int pageSize, CancellationToken ct)
{
var query = db.Orders
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderSummary(o.Id, o.CustomerName, o.Total, o.Status));
var totalCount = await query.CountAsync(ct);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedList<OrderSummary>(items, totalCount, page, pageSize);
}
ExecuteUpdateAsync / ExecuteDeleteAsync
パフォーマンス向上のため、変更追跡をバイパスする一括操作です。
// Update without loading entities
await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, clock.GetUtcNow()),
ct);
// Delete without loading entities
await db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < archiveCutoff)
.ExecuteDeleteAsync(ct);
インターセプター
監査証跡や論理削除など、横断的な関心事にはインターセプターを使用してください。
public class AuditInterceptor(TimeProvider clock) : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context;
if (context is null) return ValueTask.FromResult(result);
var now = clock.GetUtcNow();
foreach (var entry in 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);
}
}
// Registration
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
options
.UseNpgsql(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
コンパイル済みクエリ
同じ形のクエリを頻繁に実行するホットパスで使用してください。
public class OrderQueries
{
public static readonly Func<AppDbContext, Guid, CancellationToken, Task<Order?>> GetById =
EF.CompileAsyncQuery((AppDbContext db, Guid id, CancellationToken ct) =>
db.Orders
.Include(o => o.Items)
.FirstOrDefault(o => o.Id == id));
}
// Usage
var order = await OrderQueries.GetById(db, orderId, ct);
値コンバーター
// Store enum as string
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
// Strongly-typed IDs
public readonly record struct OrderId(Guid Value);
builder.Property(o => o.Id)
.HasConversion(id => id.Value, value => new OrderId(value));
マイグレーションのワークフロー
# Create a migration
dotnet ef migrations add AddOrderIndex --project src/MyApp.Infrastructure --startup-project src/MyApp.Api
# Review the generated migration — ALWAYS review before applying
# Check for data loss, index strategy, constraint names
# Apply to development database
dotnet ef database update --project src/MyApp.Infrastructure --startup-project src/MyApp.Api
# Generate SQL script for production
dotnet ef migrations script --idempotent --output migrations.sql
グローバルクエリフィルター
// Soft delete filter
builder.HasQueryFilter(o => !o.IsDeleted);
// Multi-tenant filter
builder.HasQueryFilter(o => o.TenantId == _tenantProvider.TenantId);
// Bypass when needed
var allOrders = await db.Orders.IgnoreQueryFilters().ToListAsync(ct);
アンチパターン
DbContextをリポジトリでラップしない
// BAD — unnecessary abstraction that limits EF Core's power
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id);
Task AddAsync(Order order);
Task SaveChangesAsync();
}
// GOOD — use DbContext directly in handlers
public class Handler(AppDbContext db)
{
public async Task<Order?> Handle(GetOrder.Query query, CancellationToken ct)
{
return await db.Orders.FindAsync([query.Id], ct);
}
}
遅延読み込みを使用しない
// BAD — lazy loading causes N+1 queries and hides data access
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseLazyLoadingProxies()); // DON'T
// GOOD — explicit loading with Include or projection
var orders = await db.Orders
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
.ToListAsync()の後にメモリ上でフィルターしない
// BAD — loads ALL orders, filters in C#
var orders = await db.Orders.ToListAsync(ct);
var pending = orders.Where(o => o.Status == OrderStatus.Pending);
// GOOD — filter in the database
var pending = await db.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync(ct);
非同期メソッドのawaitを忘れない
// BAD — missing await, returns before save completes
public void Handle(CreateOrder.Command command)
{
db.Orders.Add(order);
db.SaveChangesAsync(); // Fire-and-forget BUG
}
// GOOD
public async Task Handle(CreateOrder.Command command, CancellationToken ct)
{
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
}
判断ガイド
| シナリオ | 推奨事項 |
|---|---|
| 標準的なCRUD | プロジェクションを使用したDbContext |
| 一括更新(100行以上) | ExecuteUpdateAsync / ExecuteDeleteAsync |
| ホットパスの読み取りクエリ | コンパイル済みクエリ |
| 複雑なレポートクエリ | FromSqlInterpolatedを使用した生SQL、またはDapper |
| 監査証跡 | SaveChangesInterceptor |
| マルチテナンシー | グローバルクエリフィルター |
| 論理削除 | グローバルクエリフィルター + インターセプター |
| 強く型付けされたID | 値コンバーター |
| 本番環境のマイグレーション | 冪等性のあるSQLスクリプトを使用し、自動マイグレーションは行わない |
コメント
Markdown 対応