×

少一层仓储,多一点真实:EF Core 查询别再被过度包装

独孤求败 独孤求败 发表于2026-07-01 10:12:12 浏览32 评论0

抢沙发发表评论

这篇文章继续挑战 .NET 项目里常见的 Repository Pattern。它的观点和前面那篇“删除 Repository Pattern”相近,但角度更直接:EF Core 本身已经提供了很强的查询抽象,为什么还要再创建一层低配仓储接口?

作者并不是说所有 Repository 都错,而是在质疑一种常见做法:为每个实体创建仓储,然后写一堆 GetAllGetByIdGetWhereSomePropIs。这种做法通常不会让系统更清楚,反而会限制 LINQ 的表达力。

为什么团队喜欢 Repository

很多团队选择 Repository 的理由包括:

  • • 便于 mock。
  • • 隔离数据库。
  • • 保持业务层不依赖 EF Core。
  • • 未来可能替换 ORM
  • • 看起来符合分层架构。

这些理由听起来都合理,但需要落到具体项目里检查。尤其是“未来可能替换 ORM”,在大多数业务系统中发生概率很低。为了一个低概率变化,让日常查询变得复杂,不一定值得。

典型问题:仓储接口很快膨胀

看一个常见商品仓储。

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
    Task<List<Product>> GetAllAsync(CancellationToken cancellationToken);
    Task<List<Product>> GetByCategoryAsync(Guid categoryId, CancellationToken cancellationToken);
    Task<List<Product>> GetActiveAsync(CancellationToken cancellationToken);
    Task<List<Product>> GetActiveByCategoryAsync(Guid categoryId, CancellationToken cancellationToken);
    Task<List<Product>> GetActiveByCategoryOrderByPriceAsync(Guid categoryId, CancellationToken cancellationToken);
}

一开始只是几个方法,后来业务条件增加,接口就会不断膨胀。
如果还要支持搜索、分页、排序、价格区间、库存状态,方法数量会快速失控。

这本质上是在用方法名重新发明 LINQ。

EF Core 查询本来就能表达组合条件

使用 DbContext 时,可以按条件组合查询。

public sealedclassProductQueryService
{
    privatereadonly AppDbContext _dbContext;

    public ProductQueryService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<List<ProductListItemDto>> SearchAsync(
        ProductSearchRequest request,
        CancellationToken cancellationToken)
    {
        var query = _dbContext.Products
            .AsNoTracking()
            .Where(product => product.IsActive);

        if (request.CategoryId isnotnull)
        {
            query = query.Where(product => product.CategoryId == request.CategoryId);
        }

        if (!string.IsNullOrWhiteSpace(request.Keyword))
        {
            query = query.Where(product => product.Name.Contains(request.Keyword));
        }

        if (request.MinPrice isnotnull)
        {
            query = query.Where(product => product.Price >= request.MinPrice);
        }

        if (request.MaxPrice isnotnull)
        {
            query = query.Where(product => product.Price <= request.MaxPrice);
        }

        query = request.SortBy switch
        {
            ProductSortBy.PriceAsc => query.OrderBy(product => product.Price),
            ProductSortBy.PriceDesc => query.OrderByDescending(product => product.Price),
            _ => query.OrderBy(product => product.Name)
        };

        return query
            .Skip((request.PageIndex - 1) * request.PageSize)
            .Take(request.PageSize)
            .Select(product => new ProductListItemDto
            {
                Id = product.Id,
                Name = product.Name,
                Price = product.Price,
                Stock = product.Stock
            })
            .ToListAsync(cancellationToken);
    }
}

这段代码虽然长,但查询意图非常直接。你能看到每个条件如何参与 SQL 生成。
如果把它拆成多个仓储方法,反而要在调用方和实现之间来回跳转。

IQueryable 到底能不能暴露

文章中提到 IQueryable 的表达能力。这里需要谨慎。

直接从 Repository 暴露 IQueryable<Product> 有优点:调用方可以自由组合查询。
但也有风险:查询逻辑可能散落到各层,甚至 Controller 里到处拼查询。

一种折中是:不做泛型 Repository,但也不把 IQueryable 随便传到 UI 层。把查询集中在应用层查询服务或查询对象中。

public sealed record ProductSearchRequest(
    Guid? CategoryId,
    string? Keyword,
    decimal? MinPrice,
    decimal? MaxPrice,
    ProductSortBy SortBy,
    int PageIndex,
    int PageSize
)
;

public enum ProductSortBy
{
    Name,
    PriceAsc,
    PriceDesc
}

这样查询仍由 EF Core 表达,但边界是清楚的。

测试:不要只 mock 仓储返回值

Repository 常被认为便于测试,但它也容易制造假信心。

如果你 mock GetActiveByCategoryOrderByPriceAsync 返回一个列表,测试只能证明服务层处理了这个列表。它不能证明真实 SQL 是否正确,也不能证明排序、分页、包含关系是否正确。

EF Core 查询更适合使用集成测试覆盖。

public sealedclassProductQueryServiceTests
{
    public async Task Search_Should_Filter_And_Sort_Products()
    {
        awaitusingvar dbContext = TestDbContextFactory.Create();

        var categoryId = Guid.NewGuid();

        dbContext.Products.AddRange(
            Product.Create("Keyboard", categoryId, 300m, stock: 10),
            Product.Create("Mouse", categoryId, 120m, stock: 20),
            Product.Create("Monitor", Guid.NewGuid(), 900m, stock: 5));

        await dbContext.SaveChangesAsync();

        var service = new ProductQueryService(dbContext);

        var result = await service.SearchAsync(
            new ProductSearchRequest(
                categoryId,
                null,
                null,
                null,
                ProductSortBy.PriceAsc,
                1,
                20),
            CancellationToken.None);

        if (result.Count != 2 || result[0].Name != "Mouse")
        {
            thrownew Exception("商品查询过滤或排序结果不正确。");
        }
    }
}

这个测试比 mock 慢一点,但它验证了真正重要的行为。

写操作可以更关注聚合行为

反对泛型 Repository,不代表写操作可以随便散落。写操作仍然应该保护聚合不变量。

例如订单实体自己负责添加订单行、计算金额、检查状态。

public sealedclassOrder
{
    privatereadonly List<OrderLine> _lines = new();

    public Guid Id { getprivateset; } = Guid.NewGuid();
    public OrderStatus Status { getprivateset; } = OrderStatus.Draft;
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public void AddLine(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
        {
            thrownew InvalidOperationException("只有草稿订单可以添加商品。");
        }

        if (quantity <= 0)
        {
            thrownew InvalidOperationException("数量必须大于 0。");
        }

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void Submit()
    {
        if (_lines.Count == 0)
        {
            thrownew InvalidOperationException("空订单不能提交。");
        }

        Status = OrderStatus.Submitted;
    }
}

public sealed record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);

publicenum OrderStatus
{
    Draft,
    Submitted
}

应用层可以直接通过 DbContext 加载聚合并调用行为。

public sealedclassSubmitOrderHandler
{
    privatereadonly AppDbContext _dbContext;

    public SubmitOrderHandler(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task SubmitAsync(Guid orderId, CancellationToken cancellationToken)
    {
        var order = await _dbContext.Orders
            .Include(item => item.Lines)
            .SingleOrDefaultAsync(item => item.Id == orderId, cancellationToken);

        if (order isnull)
        {
            thrownew InvalidOperationException("订单不存在。");
        }

        order.Submit();
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

这里没有泛型仓储,但仍然有清晰的业务边界。

什么时候 Repository 仍然合理

Repository 合理的情况通常是它确实隐藏了复杂性,而不是转发 EF Core。

例如:

public interface IInvoiceRepository
{
    Task<Invoice?> GetLatestPaidInvoiceAsync(
        CustomerId customerId,
        CancellationToken cancellationToken);
}

如果这个查询需要同时访问数据库、缓存和外部账单系统,那么 Repository 是有价值的。调用方不需要知道底层如何组合数据。

另一个例子是领域驱动设计中的聚合仓储。如果仓储方法围绕聚合根和领域语言设计,而不是围绕表的 CRUD 设计,它也可以成立。

更实用的判断标准

是否保留 Repository,可以看这几个问题:

  1. 1. 它是否只是 DbSet 的别名?
  2. 2. 它是否让 LINQ 查询变得更难表达?
  3. 3. 它的方法是否随着筛选条件不断膨胀?
  4. 4. 测试是否因为 mock 而远离真实数据库行为?
  5. 5. 它是否隐藏了真实复杂性?
  6. 6. 它的方法名是否表达领域语言?

如果它只是 CRUD 转发层,就应该考虑删除或停止扩张。
如果它表达领域用例并隐藏复杂存储,就可以保留。

迁移方式

不要一次性删除所有仓储。更稳的方式是:

  • • 新查询优先写成查询服务或查询对象。
  • • 不再给泛型仓储增加复杂方法。
  • • 对已有复杂仓储方法补集成测试。
  • • 将纯转发方法逐步替换成 DbContext 调用。
  • • 保留真正有业务意义的仓储接口。

这样能减少风险,也能让团队逐步适应更直接的 EF Core 查询方式。

总结

这篇文章的观点可以概括为:不要为了模式而模式。

EF Core 已经提供了强大的查询抽象。很多 Repository 只是把它包成更弱、更啰嗦的方法集合。
如果这层抽象不能表达领域意图、不能隐藏复杂性、不能减少变化影响,那它就不是架构,而是噪音。

更好的做法是让查询留在清晰的应用边界内,用集成测试验证真实行为,只在 Repository 确实有价值时才保留它。


群贤毕至

访客