关于 dotnet-efcore-architecture
面向 .NET 团队的 EF Core 数据层架构技能,帮助制定读写分离、聚合、仓储、查询限制、N+1 防范和投影策略。
主要功能
- 01读写模型分离
- 02聚合边界设计
- 03仓储或 DbContext 使用策略
- 04N+1 查询治理
- 05分页与行数限制
- 06只读查询投影
适用场景
- 01判断何时拆分读写上下文
- 02制定聚合的加载与保存规则
- 03审查数据访问中的 N+1 查询
- 04限制 API 返回的大型结果集
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-efcore-architecture安装后在 Claude Code 中输入/dotnet-efcore-architecture确认已加载。
详细介绍
dotnet-efcore-architecture
EF Core 数据层的战略架构模式。涵盖读写模型分离、聚合边界设计、Repository 与直接使用 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]。数据层集成测试请参阅 [skill:dotnet-integration-testing],了解数据库 Fixture 和 Testcontainers 模式。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 应用 | 使用单个 DbContext,并在每个查询上使用 AsNoTracking() |
| 查询复杂的读密集型 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 引用其他聚合——不要在聚合根之间创建导航属性。使用
CustomerId(外键值),而非Customer(导航属性)。 - 保持聚合精简——大型聚合会导致锁竞争和加载缓慢。如果集合会无限增长(例如审计日志),就不应将其纳入聚合。
- 每个事务处理一个聚合——在单个事务中修改多个聚合会产生耦合。跨聚合操作应使用领域事件或最终一致性。
Repository 策略
是否使用 Repository 模式或直接访问 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 | 仓储 |
|---|---|---|
| 团队规模 | 较小、意见一致 | 较大、经验各异 |
| 测试策略 | 使用真实数据库进行集成测试 | 使用模拟仓储进行单元测试 |
| 查询复杂度 | 高(报表、投影) | 低至中(CRUD、聚合) |
| 聚合规范 | 通过约定强制执行 | 通过接口强制执行 |
不要创建通用仓储(IRepository<T>)。它们只会增加抽象,却没有带来价值——通用接口无法表达特定于聚合的加载规则(要使用哪些 Include、应用哪些筛选条件)。仓储接口应针对其服务的聚合根进行定义。
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
};
}
偏移分页(简单场景)
适用于需要精确页码的管理界面或小型数据集:
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()将计算下推到数据库,并减少数据传输 - 为提升可扩展性,优先使用键集分页,而不是偏移分页
Agent 注意事项
- 不要在聚合根之间创建导航属性 —— 使用外键值(例如
CustomerId)而不是导航属性(例如Customer)。跨聚合导航属性会破坏一致性边界,并促使加载属于其他聚合的数据。 - 不要创建通用存储库(
IRepository<T>)—— 它们无法表达聚合特定的加载规则,还会成为泄漏式抽象。为每个聚合根创建一个带有显式方法的存储库接口。 - 不要使用
UseLazyLoadingProxies()—— 延迟加载会隐藏 N+1 查询,并导致性能不可预测。使用Include()进行预先加载,或使用Select()进行投影。 - 不要让存储库返回
IQueryable<T>—— 这会将持久化问题泄漏给调用方,并使查询行为不可预测(例如,多次枚举、客户端评估)。返回已物化的结果(List<T>、T?)。 - 不要对无上限的查询调用
ToListAsync()`` 而不加Take()` —— 全表扫描迟早会引发生产事故。务必限制结果集。 - 不要将审计日志或事件流放在聚合内部 —— 无上限的集合会导致加载缓慢和锁争用。应将它们建模为独立实体或专用存储。