这篇文章继续挑战 .NET 项目里常见的 Repository Pattern。它的观点和前面那篇“删除 Repository Pattern”相近,但角度更直接:EF Core 本身已经提供了很强的查询抽象,为什么还要再创建一层低配仓储接口?
作者并不是说所有 Repository 都错,而是在质疑一种常见做法:为每个实体创建仓储,然后写一堆 GetAll、GetById、GetWhereSomePropIs。这种做法通常不会让系统更清楚,反而会限制 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 { get; privateset; } = Guid.NewGuid();
public OrderStatus Status { get; privateset; } = 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. 它是否只是 DbSet的别名?2. 它是否让 LINQ 查询变得更难表达? 3. 它的方法是否随着筛选条件不断膨胀? 4. 测试是否因为 mock 而远离真实数据库行为? 5. 它是否隐藏了真实复杂性? 6. 它的方法名是否表达领域语言?
如果它只是 CRUD 转发层,就应该考虑删除或停止扩张。
如果它表达领域用例并隐藏复杂存储,就可以保留。
迁移方式
不要一次性删除所有仓储。更稳的方式是:
• 新查询优先写成查询服务或查询对象。 • 不再给泛型仓储增加复杂方法。 • 对已有复杂仓储方法补集成测试。 • 将纯转发方法逐步替换成 DbContext调用。• 保留真正有业务意义的仓储接口。
这样能减少风险,也能让团队逐步适应更直接的 EF Core 查询方式。
总结
这篇文章的观点可以概括为:不要为了模式而模式。
EF Core 已经提供了强大的查询抽象。很多 Repository 只是把它包成更弱、更啰嗦的方法集合。
如果这层抽象不能表达领域意图、不能隐藏复杂性、不能减少变化影响,那它就不是架构,而是噪音。
更好的做法是让查询留在清晰的应用边界内,用集成测试验证真实行为,只在 Repository 确实有价值时才保留它。