dotnet-efcore-architecture について
EF Core のデータレイヤーを設計する .NET チーム向けのアーキテクチャスキルです。読み書き分離、集約、リポジトリ、クエリ制限、N+1 対策、プロジェクションの方針を扱います。
主な機能
- 01読み書きモデルの分離
- 02集約境界の設計
- 03リポジトリか DbContext かの方針
- 04N+1 クエリの管理
- 05ページネーションと行数制限
- 06読み取りクエリのプロジェクション
活用シーン
- 01読み書きコンテキストを分ける時期の判断
- 02集約のロードと保存ルールの策定
- 03データアクセスにおける N+1 のレビュー
- 04API の大規模な結果セットの制限
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-efcore-architectureインストール後、Claude Code で/dotnet-efcore-architectureを入力して読み込まれたか確認します。
詳細
dotnet-efcore-architecture
EF Core のデータ層における戦略的なアーキテクチャパターンです。読み取り/書き込みモデルの分離、集約境界の設計、リポジトリと DbContext の直接利用に関する方針、N+1 クエリの管理、行数制限の適用、プロジェクションパターンについて解説します。これらのパターンは、個々のクエリの書き方ではなく、データ層の構成方法を示します(実践的な使い方については [skill:dotnet-efcore-patterns] を参照してください)。
対象外: EF Core の実践的な使い方(DbContext のライフサイクル、AsNoTracking、マイグレーション、インターセプター、コンパイル済みクエリ)については、[skill:dotnet-efcore-patterns] で解説しています。EF Core、Dapper、ADO.NET の選択については、[skill:dotnet-data-access-strategy] で解説しています。DI コンテナーの仕組みについては、[skill:dotnet-csharp-dependency-injection] を参照してください。非同期パターンについては、[skill:dotnet-csharp-async-patterns] を参照してください。データ層の統合テストについては、データベースフィクスチャと Testcontainers のパターンを解説する [skill:dotnet-integration-testing] を参照してください。CI/CD パイプラインについては、[skill:dotnet-gha-patterns] と [skill:dotnet-ado-patterns] を参照してください。
相互参照:[skill:dotnet-efcore-patterns] では DbContext の実践的な使い方とマイグレーション、[skill:dotnet-data-access-strategy] ではテクノロジーの選択、[skill:dotnet-csharp-dependency-injection] ではサービスの登録、[skill:dotnet-csharp-async-patterns] では非同期クエリパターンについて解説しています。
パッケージの前提条件
このスキルの例では PostgreSQL(UseNpgsql)を使用します。使用するデータベースに対応するプロバイダーパッケージに置き換えてください。
| データベース | プロバイダーパッケージ |
|---|---|
| PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL |
| SQL Server | Microsoft.EntityFrameworkCore.SqlServer |
| SQLite | Microsoft.EntityFrameworkCore.Sqlite |
すべての例で、コアの Microsoft.EntityFrameworkCore パッケージも必要です(プロバイダーパッケージによって推移的に取り込まれます)。
読み取り/書き込みモデルの分離
読み取りモデル(クエリ)と書き込みモデル(コマンド)を分けることで、それぞれの処理を個別に最適化できます。これは完全な CQRS ではなく、EF Core の機能を活用した実用的な分離です。
アプローチ:DbContext の型を分ける
// Write context: full change tracking, navigation properties, interceptors
public sealed class WriteDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(WriteDbContext).Assembly);
}
}
// Read context: no-tracking by default, optimized for projections
public sealed class ReadDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReadDbContext).Assembly);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Note: this is supplemental -- primary config is in DI registration
}
}
登録
// Write context: standard tracking, connection resiliency
builder.Services.AddDbContext<WriteDbContext>(options =>
options.UseNpgsql(connectionString, npgsql =>
npgsql.EnableRetryOnFailure(maxRetryCount: 3)));
// Read context: no-tracking, optionally pointed at a read replica
builder.Services.AddDbContext<ReadDbContext>(options =>
options.UseNpgsql(readReplicaConnectionString ?? connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
分離する場合
| シナリオ | 推奨事項 |
|---|---|
| シンプルな CRUD アプリ | クエリごとに AsNoTracking() を指定した単一の DbContext |
| 複雑なクエリを持つ読み取り中心の API | 読み取り用と書き込み用のコンテキストを分ける |
| 読み取りレプリカデータベース | 接続文字列を変えてコンテキストを分ける |
| CQRS アーキテクチャ | コンテキストを分け、必要に応じてモデルも分ける |
まずはシンプルに始めましょう。 接続文字列の違い、モデル形状の乖離、専用の読み取りモデルを設ける必要があるほどのクエリの複雑さなど、分離する具体的な理由ができるまでは、単一の DbContext を使い、クエリごとに AsNoTracking() を指定してください。
集約境界
集約とは、整合性を保つ境界として常にまとめて読み込まれ、保存されるエンティティのまとまりです。ナビゲーションプロパティが集約境界に沿っていれば、EF Core は集約を中心とした設計と相性よく機能します。
集約の定義
// Order is the aggregate root -- it owns OrderItems
public sealed class Order
{
public int Id { get; private set; }
public string CustomerId { get; private set; } = default!;
public OrderStatus Status { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// Owned collection -- part of the Order aggregate
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public void AddItem(int productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a non-draft order.");
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
}
// OrderItem belongs to the Order aggregate -- no independent access
public sealed class OrderItem
{
public int Id { get; private set; }
public int ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
internal OrderItem(int productId, int quantity, decimal unitPrice)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
private OrderItem() { } // EF Core constructor
}
集約向けの EF Core 設定
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.CustomerId).IsRequired().HasMaxLength(50);
builder.Property(o => o.Status).HasConversion<string>();
// Owned collection navigation -- cascade delete, no independent DbSet
builder.OwnsMany(o => o.Items, items =>
{
items.WithOwner().HasForeignKey("OrderId");
items.Property(i => i.ProductId).IsRequired();
});
// Alternatively, if OrderItem needs its own table with explicit FK:
// builder.HasMany(o => o.Items)
// .WithOne()
// .HasForeignKey("OrderId")
// .OnDelete(DeleteBehavior.Cascade);
//
// builder.Navigation(o => o.Items)
// .UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
集約の設計ルール
- 集約全体を読み込む -- 集約の一部だけを読み込まないでください。所有するコレクションには
Include()を使います。 - 集約ルートを通じて保存する -- 子エンティティを個別に保存するのではなく、ルートに対して
SaveChangesAsync()を呼び出します。 - 他の集約は ID で参照する -- 集約ルート間にナビゲーションプロパティを作成しないでください。
Customer(ナビゲーションプロパティ)ではなく、CustomerId(外部キー値)を使います。 - 集約は小さく保つ -- 大きな集約はロック競合を招き、読み込みを遅くします。コレクションが際限なく増える場合(監査ログなど)、そのコレクションは集約に含めるべきではありません。
- 1 トランザクションにつき 1 集約にする -- 1 つのトランザクションで複数の集約を変更すると、結合度が高まります。集約をまたぐ操作には、ドメインイベントまたは結果整合性を使います。
リポジトリの方針
リポジトリパターンを使うか、DbContext に直接アクセスするかはチームで決めます。.NET ではどちらの方法も有効です。
オプション A:DbContext への直接アクセス
public sealed class CreateOrderHandler(WriteDbContext db)
{
public async Task<int> HandleAsync(
CreateOrderCommand command,
CancellationToken ct)
{
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
メリット: シンプルで、抽象化によるオーバーヘッドがなく、LINQ の機能をすべて利用でき、デバッグも容易です。 デメリット: ビジネスロジックがクエリメソッドに入り込む可能性があり、データベースなしでは単体テストが難しくなります。
オプション B:集約ルートごとのリポジトリ
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
public sealed class OrderRepository(WriteDbContext db) : IOrderRepository
{
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
return await db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task AddAsync(Order order, CancellationToken ct)
{
await db.Orders.AddAsync(order, ct);
}
public Task SaveChangesAsync(CancellationToken ct)
{
return db.SaveChangesAsync(ct);
}
}
メリット: データベースなしでテストでき、クエリロジックをカプセル化し、集約の読み込みルールを適用できます。 デメリット: 抽象化レイヤーが増え、LINQ を公開するとリーキーな抽象化になり得ます。また、集約ごとにリポジトリを作成すると数が増える可能性があります。
選択ガイド
| 要素 | DbContext への直接アクセス | リポジトリ |
|---|---|---|
| チームの規模 | 小規模で、意見が揃っている | 大規模で、経験がさまざま |
| テスト戦略 | 実際の DB を使った結合テスト | モックリポジトリを使った単体テスト |
| クエリの複雑さ | 高い(レポート、プロジェクション) | 低~中程度(CRUD、集約) |
| 集約の規律 | 規約によって適用 | インターフェイスによって適用 |
ジェネリックリポジトリ(IRepository<T>)は作成しないでください。価値をもたらさないまま抽象化が増えます。ジェネリックインターフェイスでは、集約固有の読み込みルール(使用する Includes、適用するフィルター)を表現できません。リポジトリインターフェイスは、対象となる集約ルートに固有のものにしてください。
N+1 クエリの管理
N+1 クエリは、EF Core で最もよくあるパフォーマンス問題です。コレクションを反復処理し、すべてのデータを事前に読み込むのではなく、要素ごとにクエリを実行すると発生します。
検出
開発環境で機密情報を含むログ記録を有効にし、SQL クエリを確認します。
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // Development only
.EnableDetailedErrors()); // Development only
よくある N+1 パターンと修正方法
パターン 1:ループ内での遅延読み込み
// BAD: N+1 -- each order.Items triggers a query
var orders = await db.Orders.ToListAsync(ct);
foreach (var order in orders)
{
var total = order.Items.Sum(i => i.Quantity * i.UnitPrice); // Lazy load!
}
// GOOD: Eager load with Include
var orders = await db.Orders
.Include(o => o.Items)
.ToListAsync(ct);
パターン 2:ループ内でのクエリ
// BAD: N+1 -- one query per customer
foreach (var customerId in customerIds)
{
var orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
// ...
}
// GOOD: Single query with Contains
var orders = await db.Orders
.Where(o => customerIds.Contains(o.CustomerId))
.ToListAsync(ct);
パターン 3:プロジェクションの欠落
// BAD: Loads full entity graph, then maps in memory
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Customer)
.ToListAsync(ct);
var dtos = orders.Select(o => new OrderDto(...));
// GOOD: Project in the query -- no tracking, no extra data loaded
var dtos = await db.Orders
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.Items.Count,
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice)
})
.ToListAsync(ct);
管理チェックリスト
- 遅延読み込みを無効にする —
Microsoft.EntityFrameworkCore.Proxiesをインストールしたり、UseLazyLoadingProxies()を設定したりしないでください。Include()による先行読み込み、またはSelect()によるプロジェクションを使うと、データアクセスが明示的になります。 - コードレビューでクエリを確認する — ナビゲーションプロパティにアクセスするループや、要素ごとに
FindAsync/FirstOrDefaultAsyncを呼び出すコードを確認してください。 - クエリタグを使用する —
db.Orders.TagWith("GetOrderSummary")を使うと、ログやプロファイリングツールでクエリを特定できます。 - 開発環境で EF Core のログ記録を設定する — すべての遅延読み込みや予期しないクエリをコンソール出力で確認できます。
行数制限とページネーション
上限のないクエリは、本番環境でリスクとなります。返される行数は常に制限してください。
キーセットページネーション(推奨)
キーセットページネーション(カーソルベースまたはシークページネーションとも呼ばれます)は、大規模なデータセットでオフセットページネーションよりも効率的です。
public async Task<PagedResult<OrderSummary>> GetOrdersAsync(
string customerId,
int? afterId,
int pageSize,
CancellationToken ct)
{
const int maxPageSize = 100;
pageSize = Math.Min(pageSize, maxPageSize);
var query = db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId);
if (afterId.HasValue)
{
query = query.Where(o => o.Id > afterId.Value);
}
var items = await query
.OrderBy(o => o.Id)
.Take(pageSize + 1) // Fetch one extra to detect "has next page"
.Select(o => new OrderSummary
{
Id = o.Id,
Status = o.Status,
CreatedAt = o.CreatedAt,
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice)
})
.ToListAsync(ct);
var hasNext = items.Count > pageSize;
if (hasNext)
{
items.RemoveAt(items.Count - 1);
}
return new PagedResult<OrderSummary>
{
Items = items,
HasNextPage = hasNext,
NextCursor = hasNext ? items[^1].Id : null
};
}
オフセットページネーション(シンプルなケース)
正確なページ番号が重要となる管理 UI や小規模なデータセットでは、次の方法を使用します。
var page = await db.Orders
.AsNoTracking()
.OrderBy(o => o.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
警告: オフセットページネーションは、規模が大きくなるとパフォーマンスが低下します。OFFSET 10000 では、データベースが 10,000 行をスキャンして破棄する必要があります。ユーザー向け API では、キーセットページネーションを優先してください。
行数制限の適用
意図しない全テーブルスキャンを防ぐため、すべてのクエリに厳格な上限を設定します。
// Interceptor approach: enforce max rows at the DbContext level
public sealed class RowLimitInterceptor : IQueryExpressionInterceptor
{
private const int MaxRows = 1000;
public Expression QueryCompilationStarting(
Expression queryExpression,
QueryExpressionEventData eventData)
{
// This is a simplified illustration -- actual implementation requires
// expression tree analysis to detect existing Take() calls.
// Consider using a code review rule or analyzer instead.
return queryExpression;
}
}
実践的な方法: 実行時インターセプターを使うのではなく、次の方法で行数制限を適用します。
- コードレビューの規約 — すべての
ToListAsync()にTake(N)を付けるか、Take(N)を伴うSelect()プロジェクションにしてください。 - API レベルでのページサイズ上限 — リクエストパイプラインで、
pageSizeがクエリに渡される前に検証してください。 - クエリタグ — クエリに
TagWith()を付け、監視で上限のないクエリを特定できるようにしてください。
プロジェクションのパターン
プロジェクション(Select())は、読み取りクエリの最も効果的な最適化方法です。データ転送量を削減し、変更追跡を省略し、N+1 のリスクをなくします。
型付きプロジェクション
public sealed record OrderSummary
{
public int Id { get; init; }
public string CustomerName { get; init; } = default!;
public int ItemCount { get; init; }
public decimal Total { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
var summaries = await db.Orders
.Select(o => new OrderSummary
{
Id = o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.Items.Count,
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice),
CreatedAt = o.CreatedAt
})
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.ToListAsync(ct);
エンティティの読み込みと比較した利点
| 懸念事項 | エンティティ + Include | プロジェクション(Select) |
|---|---|---|
| 変更追跡 | はい(AsNoTracking の場合を除く) | いいえ |
| 転送されるデータ | すべての列 | 選択した列のみ |
| N+1 のリスク | あり(遅延読み込みのナビゲーションプロパティ) | なし(SQL で計算) |
| 直積によるデータの膨張 | あり(複数の Include) | なし(単一クエリ) |
| 型安全性 | エンティティ型 | DTO/レコード型 |
ルール: DTO を返す読み取り専用エンドポイントでは、すべてプロジェクションを使用してください。エンティティの読み込みは、データを変更するコマンド用に限定してください。
主な原則
- 最適化のニーズが異なる場合は、読み取りと書き込みの経路を分離してください。単一のモデルに両方を担わせないでください
- 集約はデータベースのテーブルではなく、一貫性の境界を中心に設計してください
- 他の集約は ID で参照してください。集約ルート間のナビゲーションプロパティは結合を生みます
- 遅延読み込みは禁止してください。すべてのデータアクセスを
Include()またはSelect()で明示してください - 行数を制限してください。リストを返すすべてのクエリに上限を設けてください
- 早い段階でプロジェクションしてください。
Select()を使ってデータベースに計算を任せ、データ転送量を減らしてください - スケーラビリティのため、オフセットページネーションよりキセットページネーションを優先してください
エージェントが注意すべき点
- 集約ルート間にナビゲーションプロパティを作成しないでください。ナビゲーションプロパティ(例:
Customer)ではなく、外部キーの値(例:CustomerId)を使用してください。集約をまたぐナビゲーションプロパティは一貫性の境界を破り、別の集約に属するデータの読み込みを促します。 - 汎用リポジトリ(
IRepository<T>)を使用しないでください。集約固有の読み込みルールを表現できず、漏れのある抽象化になります。各集約ルートに対して、明示的なメソッドを持つリポジトリインターフェイスを作成してください。 UseLazyLoadingProxies()を使用しないでください。遅延読み込みは N+1 クエリを隠し、パフォーマンスを予測しにくくします。先行読み込みにはInclude()を、プロジェクションにはSelect()を使用してください。- リポジトリから
IQueryable<T>を返さないでください。永続化に関する懸念が呼び出し元に漏れ、クエリの動作が予測しにくくなります(例:複数回の列挙、クライアント側評価)。具体化した結果(List<T>、T?)を返してください。 - 上限のないクエリで
Take()を付けずにToListAsync()を呼び出さないでください。本番環境での問題につながるテーブル全体のスキャンは、いつ発生してもおかしくありません。常に結果セットを制限してください。 - 監査ログやイベントストリームを集約内に入れないでください。上限のないコレクションは読み込みを遅くし、ロックの競合を引き起こします。別のエンティティまたは専用のストアとしてモデル化してください。