mcpField
D
dotnet-efcore-architecture

dotnet-efcore-architecture

github.com/wshaddix开发8693 次安装35 周前更新
代码仓库

关于 dotnet-efcore-architecture

面向 .NET 团队的 EF Core 数据层架构技能,帮助制定读写分离、聚合、仓储、查询限制、N+1 防范和投影策略。

主要功能

  1. 01读写模型分离
  2. 02聚合边界设计
  3. 03仓储或 DbContext 使用策略
  4. 04N+1 查询治理
  5. 05分页与行数限制
  6. 06只读查询投影

适用场景

  1. 01判断何时拆分读写上下文
  2. 02制定聚合的加载与保存规则
  3. 03审查数据访问中的 N+1 查询
  4. 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)。请根据数据库替换相应的提供程序包:

数据库提供程序包
PostgreSQLNpgsql.EntityFrameworkCore.PostgreSQL
SQL ServerMicrosoft.EntityFrameworkCore.SqlServer
SQLiteMicrosoft.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);
    }
}
聚合设计规则
  1. 加载整个聚合——不要加载不完整的聚合。使用 Include() 加载所属集合。
  2. 通过聚合根保存——在聚合根上调用 SaveChangesAsync(),不要单独保存子实体。
  3. 通过 ID 引用其他聚合——不要在聚合根之间创建导航属性。使用 CustomerId(外键值),而非 Customer(导航属性)。
  4. 保持聚合精简——大型聚合会导致锁竞争和加载缓慢。如果集合会无限增长(例如审计日志),就不应将其纳入聚合。
  5. 每个事务处理一个聚合——在单个事务中修改多个聚合会产生耦合。跨聚合操作应使用领域事件或最终一致性。

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;
    }
}

实用方法: 与其使用运行时拦截器,不如通过以下方式强制执行行数限制:

  1. 代码审查约定——每个 ToListAsync() 都必须包含 Take(N),或者是包含 Take(N) 的 Select() 投影。
  2. API 层面的页大小上限——在请求管道中验证 pageSize,确保其在到达查询前符合限制。
  3. 查询标记——使用 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 注意事项

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

参考资料

评论

支持 Markdown