dotnet-efcore-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

dotnet-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 (
UseNpgsql
). Substitute the provider package for your database:
DatabaseProvider Package
PostgreSQL
Npgsql.EntityFrameworkCore.PostgreSQL
SQL Server
Microsoft.EntityFrameworkCore.SqlServer
SQLite
Microsoft.EntityFrameworkCore.Sqlite
All examples also require the core
Microsoft.EntityFrameworkCore
package (pulled in transitively by provider packages).

本文档中的示例使用PostgreSQL(
UseNpgsql
)。请根据你的数据库替换对应的提供程序包:
数据库提供程序包
PostgreSQL
Npgsql.EntityFrameworkCore.PostgreSQL
SQL Server
Microsoft.EntityFrameworkCore.SqlServer
SQLite
Microsoft.EntityFrameworkCore.Sqlite
所有示例还需要核心包
Microsoft.EntityFrameworkCore
(由提供程序包自动引入)。

Read/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

何时分离

ScenarioRecommendation
Simple CRUD appSingle
DbContext
with per-query
AsNoTracking()
Read-heavy API with complex queriesSeparate read/write contexts
Read replica databaseSeparate contexts with different connection strings
CQRS architectureSeparate contexts, possibly separate models
Start simple. Use a single
DbContext
and per-query
AsNoTracking()
until you have a concrete reason to split (different connection strings, divergent model shapes, or query complexity that justifies dedicated read models).

场景建议
简单CRUD应用使用单个
DbContext
,并在查询时按需使用
AsNoTracking()
读密集型API且包含复杂查询分离读写上下文
使用只读副本数据库使用不同连接字符串分离上下文
CQRS架构分离上下文,甚至可使用独立模型
从简单开始。先使用单个
DbContext
和查询级别的
AsNoTracking()
,直到你有明确的理由进行拆分(如不同的连接字符串、差异化的模型结构,或查询复杂度足以证明需要专用读模型)。

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

聚合设计规则

  1. Load the entire aggregate -- do not load partial aggregates. Use
    Include()
    for the owned collections.
  2. Save through the aggregate root -- call
    SaveChangesAsync()
    on the root, not on child entities independently.
  3. Reference other aggregates by ID -- do not create navigation properties between aggregate roots. Use
    CustomerId
    (foreign key value), not
    Customer
    (navigation property).
  4. 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.
  5. One aggregate per transaction -- modifying multiple aggregates in a single transaction creates coupling. Use domain events or eventual consistency for cross-aggregate operations.

  1. 加载完整聚合——不要加载部分聚合。使用
    Include()
    加载所属集合。
  2. 通过聚合根保存——调用聚合根对应的
    SaveChangesAsync()
    ,而非独立保存子实体。
  3. 通过ID引用其他聚合——不要在聚合根之间创建导航属性。使用
    CustomerId
    (外键值)而非
    Customer
    (导航属性)。
  4. 保持聚合小巧——大型聚合会导致锁竞争和加载缓慢。如果某个集合无限制增长(如审计日志),则它不属于该聚合。
  5. 单个事务仅修改一个聚合——在单个事务中修改多个聚合会产生耦合。跨聚合操作请使用领域事件或最终一致性。

Repository Policy

仓储策略

Whether to use the repository pattern or access
DbContext
directly is a team decision. Both approaches are valid in .NET.
是否使用仓储模式还是直接访问
DbContext
是团队决策。两种方式在.NET中都是可行的。

Option 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

决策指南

FactorDirect DbContextRepository
Team sizeSmall, alignedLarge, varied experience
Test strategyIntegration tests with real DBUnit tests with mocked repos
Query complexityHigh (reports, projections)Low-medium (CRUD, aggregates)
Aggregate disciplineEnforced by conventionEnforced by interface
Do not create generic repositories (
IRepository<T>
). 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.

因素直接使用DbContext使用仓储模式
团队规模小型、目标一致大型、成员经验多样
测试策略使用真实数据库的集成测试使用模拟仓储的单元测试
查询复杂度高(报表、投影)中低(CRUD、聚合)
聚合规范执行通过约定执行通过接口执行
不要创建通用仓储
IRepository<T>
)。它们仅增加抽象而无实际价值——通用接口无法表达聚合特定的加载规则(如使用哪些Include、应用哪些筛选条件)。仓储接口应针对其服务的聚合根进行定制。

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
    Microsoft.EntityFrameworkCore.Proxies
    or configure
    UseLazyLoadingProxies()
    . Eager loading via
    Include()
    or projection via
    Select()
    makes data access explicit.
  • Review queries in code review -- look for loops that access navigation properties or call
    FindAsync
    /
    FirstOrDefaultAsync
    per element.
  • Use query tags --
    db.Orders.TagWith("GetOrderSummary")
    makes queries identifiable in logs and profiling tools.
  • 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 --
OFFSET 10000
forces the database to scan and discard 10,000 rows. Prefer keyset pagination for user-facing APIs.
对于管理界面或需要精确页码的小数据集:
csharp
var page = await db.Orders
    .AsNoTracking()
    .OrderBy(o => o.CreatedAt)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);
警告: 偏移分页在数据量较大时性能会下降——
OFFSET 10000
会强制数据库扫描并丢弃10000行数据。面向用户的API优先使用键集分页。

Row 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:
  1. Code review convention -- every
    ToListAsync()
    must have
    Take(N)
    or be a
    Select()
    projection with
    Take(N)
    .
  2. API-level page size caps -- validate
    pageSize
    in the request pipeline before it reaches the query.
  3. Query tags -- annotate queries with
    TagWith()
    to identify unbounded queries in monitoring.

为所有查询设置硬上限,防止意外全表扫描:
csharp
// 拦截器方式:在DbContext层面强制最大行数
public sealed class RowLimitInterceptor : IQueryExpressionInterceptor
{
    private const int MaxRows = 1000;

    public Expression QueryCompilationStarting(
        Expression queryExpression,
        QueryExpressionEventData eventData)
    {
        // 这是简化示例——实际实现需要分析表达式树以检测已有的Take()调用。
        // 建议使用代码审查规则或分析器替代。
        return queryExpression;
    }
}
实用方案: 与其使用运行时拦截器,不如通过以下方式实施行限制:
  1. 代码审查约定——每个
    ToListAsync()
    必须搭配
    Take(N)
    ,或在
    Select()
    投影中使用
    Take(N)
  2. API层面的页大小上限——在请求管道中验证
    pageSize
    后再传入查询。
  3. 查询标签——使用
    TagWith()
    标注查询,以便在监控中识别无界查询。

Projection Patterns

投影模式

Projections (
Select()
) are the most effective optimization for read queries. They reduce data transfer, skip change tracking, and eliminate N+1 risks.
投影(
Select()
)是优化读查询最有效的方式。它们减少数据传输、跳过变更跟踪,并消除N+1查询风险。

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

相较于实体加载的优势

ConcernEntity + IncludeProjection (Select)
Change trackingYes (unless AsNoTracking)No
Data transferredAll columnsOnly selected columns
N+1 riskYes (lazy nav props)No (computed in SQL)
Cartesian explosionYes (multiple Includes)No (single query)
Type safetyEntity typesDTO/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
    Include()
    or
    Select()
  • Enforce row limits -- every query that returns a list must have an upper bound
  • Project early -- use
    Select()
    to push computation to the database and reduce data transfer
  • Prefer keyset pagination over offset pagination for scalability

  • 当优化需求不同时,分离读写路径——不要强制单个模型同时服务于读写场景
  • 围绕一致性边界设计聚合——而非围绕数据库表
  • 通过ID引用其他聚合——聚合根之间的导航属性会产生耦合
  • 禁止延迟加载——通过
    Include()
    Select()
    使所有数据访问显式化
  • 实施行限制——每个返回列表的查询必须有上限
  • 尽早投影——使用
    Select()
    将计算推送到数据库,减少数据传输
  • 优先使用键集分页而非偏移分页以提升可扩展性

Agent Gotchas

注意事项(Agent避坑指南)

  1. Do not create navigation properties between aggregate roots -- use foreign key values (e.g.,
    CustomerId
    ) instead of navigation properties (e.g.,
    Customer
    ). Cross-aggregate navigation properties break the consistency boundary and encourage loading data that belongs to another aggregate.
  2. Do not create generic repositories (
    IRepository<T>
    ) -- they cannot express aggregate-specific loading rules and become leaky abstractions. Create one repository interface per aggregate root with explicit methods.
  3. Do not use
    UseLazyLoadingProxies()
    -- lazy loading hides N+1 queries and makes performance unpredictable. Use
    Include()
    for eager loading or
    Select()
    for projections.
  4. Do not return
    IQueryable<T>
    from repositories
    -- it leaks persistence concerns to callers and makes query behavior unpredictable (e.g., multiple enumeration, client-side evaluation). Return materialized results (
    List<T>
    ,
    T?
    ).
  5. Do not write
    ToListAsync()
    without
    Take()
    on unbounded queries
    -- full table scans are a production incident waiting to happen. Always limit the result set.
  6. 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.

  1. 不要在聚合根之间创建导航属性——使用外键值(如
    CustomerId
    )而非导航属性(如
    Customer
    )。跨聚合导航属性会破坏一致性边界,并导致加载属于其他聚合的数据。
  2. 不要创建通用仓储
    IRepository<T>
    )——它们无法表达聚合特定的加载规则,会成为抽象泄漏点。为每个聚合根创建一个专用的仓储接口,并定义显式方法。
  3. 不要使用
    UseLazyLoadingProxies()
    ——延迟加载会隐藏N+1查询,导致性能不可预测。使用
    Include()
    进行预先加载或
    Select()
    进行投影。
  4. 不要从仓储返回
    IQueryable<T>
    ——这会将持久化细节泄露给调用方,导致查询行为不可预测(如多次枚举、客户端评估)。返回具体化结果(
    List<T>
    T?
    )。
  5. 无界查询不要调用
    ToListAsync()
    而不搭配
    Take()
    ——全表扫描随时可能引发生产事故。始终限制结果集。
  6. 不要将审计日志或事件流放在聚合内部——无限制增长的集合会导致加载缓慢和锁竞争。将它们建模为独立实体或专用存储。

References

参考资料