dotnet-efcore-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesedotnet-efcore-architecture
.NET EF Core 数据层架构设计
Strategic architectural patterns for EF Core data layers. Covers read/write model separation, aggregate boundary design, repository vs direct DbContext policy, N+1 query governance, row limit enforcement, and projection patterns. These patterns guide how to structure a data layer -- not how to write individual queries (see [skill:dotnet-efcore-patterns] for tactical usage).
EF Core数据层的战略架构模式。涵盖读写模型分离、聚合边界设计、仓储模式vs直接使用DbContext的策略、N+1查询治理、行限制实施以及投影模式。这些模式指导如何构建数据层——而非如何编写单个查询(战术用法请参考[skill:dotnet-efcore-patterns])。
Scope
适用范围
- Read/write model separation and CQRS patterns
- Aggregate boundary design and repository policy
- N+1 query governance and row limit enforcement
- Projection patterns and query optimization strategy
- 读写模型分离与CQRS模式
- 聚合边界设计与仓储策略
- N+1查询治理与行限制实施
- 投影模式与查询优化策略
Out of scope
不适用范围
- Tactical EF Core usage (DbContext lifecycle, AsNoTracking, migrations, interceptors) -- see [skill:dotnet-efcore-patterns]
- Data access technology selection (EF Core vs Dapper vs ADO.NET) -- see [skill:dotnet-data-access-strategy]
- DI container mechanics -- see [skill:dotnet-csharp-dependency-injection]
- Async patterns -- see [skill:dotnet-csharp-async-patterns]
- Integration testing data layers -- see [skill:dotnet-integration-testing]
Cross-references: [skill:dotnet-efcore-patterns] for tactical DbContext usage and migrations, [skill:dotnet-data-access-strategy] for technology selection, [skill:dotnet-csharp-dependency-injection] for service registration, [skill:dotnet-csharp-async-patterns] for async query patterns.
- EF Core战术用法(DbContext生命周期、AsNoTracking、迁移、拦截器)——请参考[skill:dotnet-efcore-patterns]
- 数据访问技术选型(EF Core vs Dapper vs ADO.NET)——请参考[skill:dotnet-data-access-strategy]
- DI容器机制——请参考[skill:dotnet-csharp-dependency-injection]
- 异步模式——请参考[skill:dotnet-csharp-async-patterns]
- 数据层集成测试——请参考[skill:dotnet-integration-testing]
交叉引用:[skill:dotnet-efcore-patterns] 提供DbContext战术用法与迁移相关内容,[skill:dotnet-data-access-strategy] 提供技术选型指导,[skill:dotnet-csharp-dependency-injection] 提供服务注册相关内容,[skill:dotnet-csharp-async-patterns] 提供异步查询模式相关内容。
Package Prerequisites
包依赖项
Examples in this skill use PostgreSQL (). Substitute the provider package for your database:
UseNpgsql| Database | Provider Package |
|---|---|
| PostgreSQL | |
| SQL Server | |
| SQLite | |
All examples also require the core package (pulled in transitively by provider packages).
Microsoft.EntityFrameworkCore本文档中的示例使用PostgreSQL()。请根据你的数据库替换对应的提供程序包:
UseNpgsql| 数据库 | 提供程序包 |
|---|---|
| PostgreSQL | |
| SQL Server | |
| SQLite | |
所有示例还需要核心包(由提供程序包自动引入)。
Microsoft.EntityFrameworkCoreRead/Write Model Separation
读写模型分离
Separate read models (queries) from write models (commands) to optimize each path independently. This is not full CQRS -- it is a practical separation using EF Core features.
将读模型(查询)与写模型(命令)分离,以便独立优化每条路径。这并非完整的CQRS——而是利用EF Core特性实现的实用分离方案。
Approach: Separate DbContext Types
实现方式:分离DbContext类型
csharp
// 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
}
}csharp
// 写上下文:完整变更跟踪、导航属性、拦截器
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);
}
}
// 读上下文:默认无跟踪,针对投影优化
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)
{
// 注意:这是补充配置——主要配置在DI注册中
}
}Registration
注册配置
csharp
// 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));csharp
// 写上下文:标准跟踪、连接弹性
builder.Services.AddDbContext<WriteDbContext>(options =>
options.UseNpgsql(connectionString, npgsql =>
npgsql.EnableRetryOnFailure(maxRetryCount: 3)));
// 读上下文:无跟踪,可选指向只读副本
builder.Services.AddDbContext<ReadDbContext>(options =>
options.UseNpgsql(readReplicaConnectionString ?? connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));When to Separate
何时分离
| Scenario | Recommendation |
|---|---|
| Simple CRUD app | Single |
| Read-heavy API with complex queries | Separate read/write contexts |
| Read replica database | Separate contexts with different connection strings |
| CQRS architecture | Separate contexts, possibly separate models |
Start simple. Use a single and per-query until you have a concrete reason to split (different connection strings, divergent model shapes, or query complexity that justifies dedicated read models).
DbContextAsNoTracking()| 场景 | 建议 |
|---|---|
| 简单CRUD应用 | 使用单个 |
| 读密集型API且包含复杂查询 | 分离读写上下文 |
| 使用只读副本数据库 | 使用不同连接字符串分离上下文 |
| CQRS架构 | 分离上下文,甚至可使用独立模型 |
从简单开始。先使用单个和查询级别的,直到你有明确的理由进行拆分(如不同的连接字符串、差异化的模型结构,或查询复杂度足以证明需要专用读模型)。
DbContextAsNoTracking()Aggregate Boundaries
聚合边界
An aggregate is a cluster of entities that are always loaded and saved together as a consistency boundary. EF Core maps well to aggregate-oriented design when navigation properties follow aggregate boundaries.
聚合是一组作为一致性边界被整体加载和保存的实体集群。当导航属性遵循聚合边界时,EF Core能很好地适配面向聚合的设计。
Defining Aggregates
定义聚合
csharp
// 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
}csharp
// Order是聚合根——它拥有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; }
// 所属集合——属于Order聚合
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("无法向非草稿状态的订单添加商品。");
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
}
// OrderItem属于Order聚合——无独立访问权限
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构造函数
}EF Core Configuration for Aggregates
聚合的EF Core配置
csharp
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);
}
}csharp
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>();
// 所属集合导航——级联删除,无独立DbSet
builder.OwnsMany(o => o.Items, items =>
{
items.WithOwner().HasForeignKey("OrderId");
items.Property(i => i.ProductId).IsRequired();
});
// 或者,如果OrderItem需要独立表和显式外键:
// builder.HasMany(o => o.Items)
// .WithOne()
// .HasForeignKey("OrderId")
// .OnDelete(DeleteBehavior.Cascade);
//
// builder.Navigation(o => o.Items)
// .UsePropertyAccessMode(PropertyAccessMode.Field);
}
}Aggregate Design Rules
聚合设计规则
- Load the entire aggregate -- do not load partial aggregates. Use for the owned collections.
Include() - Save through the aggregate root -- call on the root, not on child entities independently.
SaveChangesAsync() - Reference other aggregates by ID -- do not create navigation properties between aggregate roots. Use (foreign key value), not
CustomerId(navigation property).Customer - Keep aggregates small -- large aggregates cause lock contention and slow loads. If a collection grows unbounded (e.g., audit logs), it does not belong in the aggregate.
- One aggregate per transaction -- modifying multiple aggregates in a single transaction creates coupling. Use domain events or eventual consistency for cross-aggregate operations.
- 加载完整聚合——不要加载部分聚合。使用加载所属集合。
Include() - 通过聚合根保存——调用聚合根对应的,而非独立保存子实体。
SaveChangesAsync() - 通过ID引用其他聚合——不要在聚合根之间创建导航属性。使用(外键值)而非
CustomerId(导航属性)。Customer - 保持聚合小巧——大型聚合会导致锁竞争和加载缓慢。如果某个集合无限制增长(如审计日志),则它不属于该聚合。
- 单个事务仅修改一个聚合——在单个事务中修改多个聚合会产生耦合。跨聚合操作请使用领域事件或最终一致性。
Repository Policy
仓储策略
Whether to use the repository pattern or access directly is a team decision. Both approaches are valid in .NET.
DbContext是否使用仓储模式还是直接访问是团队决策。两种方式在.NET中都是可行的。
DbContextOption A: Direct DbContext Access
选项A:直接使用DbContext
csharp
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;
}
}Pros: Simple, no abstraction overhead, full LINQ power, easy to debug.
Cons: Business logic can leak into query methods, harder to unit test without a database.
csharp
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能力、易于调试。
缺点: 业务逻辑可能泄露到查询方法中,无数据库时难以进行单元测试。
Option B: Repository per Aggregate Root
选项B:每个聚合根对应一个仓储
csharp
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);
}
}Pros: Testable without a database, encapsulates query logic, enforces aggregate loading rules.
Cons: Extra abstraction layer, can become a leaky abstraction if LINQ is exposed, repository per aggregate can proliferate.
csharp
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会成为抽象泄漏点,每个聚合对应一个仓储可能导致类数量激增。
Decision Guide
决策指南
| Factor | Direct DbContext | Repository |
|---|---|---|
| Team size | Small, aligned | Large, varied experience |
| Test strategy | Integration tests with real DB | Unit tests with mocked repos |
| Query complexity | High (reports, projections) | Low-medium (CRUD, aggregates) |
| Aggregate discipline | Enforced by convention | Enforced by interface |
Do not create generic repositories (). They add abstraction without value -- the generic interface cannot express aggregate-specific loading rules (which Includes to use, which filters to apply). Repository interfaces should be specific to the aggregate root they serve.
IRepository<T>| 因素 | 直接使用DbContext | 使用仓储模式 |
|---|---|---|
| 团队规模 | 小型、目标一致 | 大型、成员经验多样 |
| 测试策略 | 使用真实数据库的集成测试 | 使用模拟仓储的单元测试 |
| 查询复杂度 | 高(报表、投影) | 中低(CRUD、聚合) |
| 聚合规范执行 | 通过约定执行 | 通过接口执行 |
不要创建通用仓储()。它们仅增加抽象而无实际价值——通用接口无法表达聚合特定的加载规则(如使用哪些Include、应用哪些筛选条件)。仓储接口应针对其服务的聚合根进行定制。
IRepository<T>N+1 Query Governance
N+1查询治理
N+1 queries are the most common EF Core performance problem. They occur when code iterates over a collection and executes a query per element, instead of loading all data upfront.
N+1查询是EF Core最常见的性能问题。当代码遍历集合并为每个元素执行一次查询,而非预先加载所有数据时,就会出现这种情况。
Detection
检测方法
Enable sensitive logging in development to see SQL queries:
csharp
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // Development only
.EnableDetailedErrors()); // Development only在开发环境中启用敏感日志以查看SQL查询:
csharp
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging() // 仅在开发环境使用
.EnableDetailedErrors()); // 仅在开发环境使用Common N+1 Patterns and Fixes
常见N+1模式及修复方案
Pattern 1: Lazy loading in a loop
csharp
// 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);Pattern 2: Querying inside a loop
csharp
// 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);Pattern 3: Missing projection
csharp
// 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);模式1:循环中使用延迟加载
csharp
// 错误:N+1查询——每个order.Items都会触发一次查询
var orders = await db.Orders.ToListAsync(ct);
foreach (var order in orders)
{
var total = order.Items.Sum(i => i.Quantity * i.UnitPrice); // 延迟加载!
}
// 正确:使用Include预先加载
var orders = await db.Orders
.Include(o => o.Items)
.ToListAsync(ct);模式2:循环内执行查询
csharp
// 错误:N+1查询——每个客户执行一次查询
foreach (var customerId in customerIds)
{
var orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
// ...
}
// 正确:使用Contains单次查询
var orders = await db.Orders
.Where(o => customerIds.Contains(o.CustomerId))
.ToListAsync(ct);模式3:缺少投影
csharp
// 错误:加载完整实体图,然后在内存中映射
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Customer)
.ToListAsync(ct);
var dtos = orders.Select(o => new OrderDto(...));
// 正确:在查询中使用投影——无跟踪、无额外数据加载
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);Governance Checklist
治理清单
- Disable lazy loading -- do not install or configure
Microsoft.EntityFrameworkCore.Proxies. Eager loading viaUseLazyLoadingProxies()or projection viaInclude()makes data access explicit.Select() - Review queries in code review -- look for loops that access navigation properties or call /
FindAsyncper element.FirstOrDefaultAsync - Use query tags -- makes queries identifiable in logs and profiling tools.
db.Orders.TagWith("GetOrderSummary") - Set up EF Core logging in development -- every lazy load or unexpected query is visible in the console output.
- 禁用延迟加载——不要安装或配置
Microsoft.EntityFrameworkCore.Proxies。通过UseLazyLoadingProxies()进行预先加载或通过Include()进行投影,使数据访问显式化。Select() - 代码审查时检查查询——查找遍历集合时访问导航属性,或逐个元素调用/
FindAsync的代码。FirstOrDefaultAsync - 使用查询标签——可在日志和分析工具中识别查询。
db.Orders.TagWith("GetOrderSummary") - 在开发环境中启用EF Core日志——所有延迟加载或意外查询都会显示在控制台输出中。
Row Limits and Pagination
行限制与分页
Unbounded queries are a production risk. Always limit the number of rows returned.
无界查询是生产环境的风险。始终限制返回的行数。
Keyset Pagination (Recommended)
键集分页(推荐)
Keyset pagination (also called cursor-based or seek pagination) is more efficient than offset pagination for large datasets:
csharp
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
};
}键集分页(也称为基于游标或Seek分页)在大数据集上比偏移分页更高效:
csharp
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) // 多获取一条数据以检测是否有下一页
.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
};
}Offset Pagination (Simple Cases)
偏移分页(简单场景)
For admin UIs or small datasets where exact page numbers matter:
csharp
var page = await db.Orders
.AsNoTracking()
.OrderBy(o => o.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);Warning: Offset pagination degrades at scale -- forces the database to scan and discard 10,000 rows. Prefer keyset pagination for user-facing APIs.
OFFSET 10000对于管理界面或需要精确页码的小数据集:
csharp
var page = await db.Orders
.AsNoTracking()
.OrderBy(o => o.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);警告: 偏移分页在数据量较大时性能会下降——会强制数据库扫描并丢弃10000行数据。面向用户的API优先使用键集分页。
OFFSET 10000Row Limit Enforcement
行限制实施
Set a hard upper bound on all queries to prevent accidental full-table scans:
csharp
// 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;
}
}Practical approach: Rather than a runtime interceptor, enforce row limits through:
- Code review convention -- every must have
ToListAsync()or be aTake(N)projection withSelect().Take(N) - API-level page size caps -- validate in the request pipeline before it reaches the query.
pageSize - Query tags -- annotate queries with to identify unbounded queries in monitoring.
TagWith()
为所有查询设置硬上限,防止意外全表扫描:
csharp
// 拦截器方式:在DbContext层面强制最大行数
public sealed class RowLimitInterceptor : IQueryExpressionInterceptor
{
private const int MaxRows = 1000;
public Expression QueryCompilationStarting(
Expression queryExpression,
QueryExpressionEventData eventData)
{
// 这是简化示例——实际实现需要分析表达式树以检测已有的Take()调用。
// 建议使用代码审查规则或分析器替代。
return queryExpression;
}
}实用方案: 与其使用运行时拦截器,不如通过以下方式实施行限制:
- 代码审查约定——每个必须搭配
ToListAsync(),或在Take(N)投影中使用Select()。Take(N) - API层面的页大小上限——在请求管道中验证后再传入查询。
pageSize - 查询标签——使用标注查询,以便在监控中识别无界查询。
TagWith()
Projection Patterns
投影模式
Projections () are the most effective optimization for read queries. They reduce data transfer, skip change tracking, and eliminate N+1 risks.
Select()投影()是优化读查询最有效的方式。它们减少数据传输、跳过变更跟踪,并消除N+1查询风险。
Select()Typed Projections
类型化投影
csharp
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);csharp
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);Advantages Over Entity Loading
相较于实体加载的优势
| Concern | Entity + Include | Projection (Select) |
|---|---|---|
| Change tracking | Yes (unless AsNoTracking) | No |
| Data transferred | All columns | Only selected columns |
| N+1 risk | Yes (lazy nav props) | No (computed in SQL) |
| Cartesian explosion | Yes (multiple Includes) | No (single query) |
| Type safety | Entity types | DTO/record types |
Rule: Use projections for all read-only endpoints that return DTOs. Reserve entity loading for commands that modify data.
| 关注点 | 实体+Include | 投影(Select) |
|---|---|---|
| 变更跟踪 | 是(除非使用AsNoTracking) | 否 |
| 传输数据量 | 所有列 | 仅选中列 |
| N+1风险 | 是(延迟加载导航属性) | 否(在SQL中计算) |
| 笛卡尔积爆炸 | 是(多个Include) | 否(单次查询) |
| 类型安全 | 实体类型 | DTO/记录类型 |
规则: 所有返回DTO的只读端点都使用投影。仅在修改数据的命令中使用实体加载。
Key Principles
核心原则
- Separate read and write paths when you have different optimization needs -- do not force a single model to serve both
- Design aggregates around consistency boundaries -- not around database tables
- Reference other aggregates by ID -- navigation properties between aggregate roots create coupling
- Ban lazy loading -- make all data access explicit through or
Include()Select() - Enforce row limits -- every query that returns a list must have an upper bound
- Project early -- use to push computation to the database and reduce data transfer
Select() - Prefer keyset pagination over offset pagination for scalability
- 当优化需求不同时,分离读写路径——不要强制单个模型同时服务于读写场景
- 围绕一致性边界设计聚合——而非围绕数据库表
- 通过ID引用其他聚合——聚合根之间的导航属性会产生耦合
- 禁止延迟加载——通过或
Include()使所有数据访问显式化Select() - 实施行限制——每个返回列表的查询必须有上限
- 尽早投影——使用将计算推送到数据库,减少数据传输
Select() - 优先使用键集分页而非偏移分页以提升可扩展性
Agent Gotchas
注意事项(Agent避坑指南)
- Do not create navigation properties between aggregate roots -- use foreign key values (e.g., ) instead of navigation properties (e.g.,
CustomerId). Cross-aggregate navigation properties break the consistency boundary and encourage loading data that belongs to another aggregate.Customer - Do not create generic repositories () -- they cannot express aggregate-specific loading rules and become leaky abstractions. Create one repository interface per aggregate root with explicit methods.
IRepository<T> - Do not use -- lazy loading hides N+1 queries and makes performance unpredictable. Use
UseLazyLoadingProxies()for eager loading orInclude()for projections.Select() - Do not return from repositories -- it leaks persistence concerns to callers and makes query behavior unpredictable (e.g., multiple enumeration, client-side evaluation). Return materialized results (
IQueryable<T>,List<T>).T? - Do not write without
ToListAsync()on unbounded queries -- full table scans are a production incident waiting to happen. Always limit the result set.Take() - Do not put audit logs or event streams inside aggregates -- unbounded collections cause slow loads and lock contention. Model them as separate entities or dedicated stores.
- 不要在聚合根之间创建导航属性——使用外键值(如)而非导航属性(如
CustomerId)。跨聚合导航属性会破坏一致性边界,并导致加载属于其他聚合的数据。Customer - 不要创建通用仓储()——它们无法表达聚合特定的加载规则,会成为抽象泄漏点。为每个聚合根创建一个专用的仓储接口,并定义显式方法。
IRepository<T> - 不要使用——延迟加载会隐藏N+1查询,导致性能不可预测。使用
UseLazyLoadingProxies()进行预先加载或Include()进行投影。Select() - 不要从仓储返回——这会将持久化细节泄露给调用方,导致查询行为不可预测(如多次枚举、客户端评估)。返回具体化结果(
IQueryable<T>、List<T>)。T? - 无界查询不要调用而不搭配
ToListAsync()——全表扫描随时可能引发生产事故。始终限制结果集。Take() - 不要将审计日志或事件流放在聚合内部——无限制增长的集合会导致加载缓慢和锁竞争。将它们建模为独立实体或专用存储。