DDD领域驱动设计中领域对象返回列表的正确实现方式
你两个直觉都是对的:
- 给
Product实体类加GetProductsByCategory这类批量查询方法完全违反单一职责原则 - 为每个列表查询单独建
XxxFetcher类完全没必要,属于设计过度导致的类爆炸
很多基础教程只说“数据和行为要封装”,但没说这个原则的适用边界:行为只会封装在对象持有的自身数据的相关逻辑上。
你写的GetFinalPrice()就是完全符合要求的实体行为:它只依赖当前Product实例自身的Price、TaxRate字段,不需要访问外部任何其他对象的状态,和单个产品的核心属性强绑定,放在Product类里天经地义。
而“查询某分类下所有产品”这个行为,本质是对产品集合的筛选操作,执行这个操作的时候根本不需要依赖某一个已经存在的Product实例的内部状态,甚至不需要提前存在任何Product实例——它的输入是分类ID,输出是多个Product实例,把这个逻辑塞给单个Product实体,本质是让一个只需要管自己状态的对象,越权去管整个产品集合的检索,完全违背职责划分。
DDD里专门给这类“聚合根的集合级查询、持久化操作”定义了对应的模式:仓储(Repository)。
你完全不需要为每个查询场景单独建类,一个聚合根对应一个仓储接口就够了,所有和这个聚合根相关的单查、列表查询、增删改操作,都可以放在对应的仓储里。
仓储对外模拟的是一个内存中的领域对象集合,领域层代码调用仓储方法的时候,根本不需要关心底层是查数据库、调第三方接口还是读缓存,所有存储相关的细节都被封装在基础设施层的仓储实现里。
先给你修正下原有Product类的不合理之处,更符合DDD实体的设计要求:
public class Product { // 属性用private set,禁止外部直接随意修改实体状态,保证封装性 public Guid Id { get; private set; } public Guid CategoryId { get; private set; } // 金额相关字段必须用decimal,用double会出现精度误差 public decimal TaxRate { get; private set; } public decimal Price { get; private set; } // 通过构造函数做初始化校验,保证实体创建出来就处于合法状态,不会出现无效数据 public Product(Guid id, Guid categoryId, decimal taxRate, decimal price) { if (taxRate < 0) throw new ArgumentException("税率不能为负值"); if (price < 0) throw new ArgumentException("价格不能为负值"); Id = id; CategoryId = categoryId; TaxRate = taxRate; Price = price; } // 单实例相关的领域行为留在实体内 public decimal GetFinalPrice() { // 注意原示例的计算逻辑错误:最终价格=原价*(1+税率),不是直接乘税率 return Price * (1 + TaxRate); } public void AdjustPrice(decimal newPrice) { if (newPrice < 0) throw new ArgumentException("调整后价格不能为负值"); Price = newPrice; } }
对应的Product仓储接口定义在领域层,实现放在基础设施层即可:
// 领域层定义仓储抽象,不依赖具体存储实现 public interface IProductRepository { // 单实例查询 Task<Product?> GetByIdAsync(Guid productId, CancellationToken ct); // 你需要的按分类查询所有产品的方法,直接放在仓储中即可 Task<List<Product>> GetAllByCategoryIdAsync(Guid categoryId, CancellationToken ct); // 其他同聚合的列表查询直接往这里加,不需要单独建类 Task<List<Product>> GetAllOnSaleAsync(decimal maxPrice, CancellationToken ct); // 持久化操作 Task AddAsync(Product product, CancellationToken ct); Task UpdateAsync(Product product, CancellationToken ct); }
只有两种场景你需要把查询逻辑从仓储里拆出来,也不会造成类泛滥:
- 当逻辑涉及多个不同聚合根的协作,不属于某一个聚合的仓储职责时,把逻辑放到领域服务中
- 当你采用CQRS模式做读写分离时,针对列表页这类不需要完整领域实体、只需要扁平DTO的读场景,可以单独按模块组织查询类,比如建一个
ProductQueries类,把所有只读的、直接返回DTO的查询逻辑都放进去,一个类承载所有产品相关的读场景查询,根本不会出现一个查询一个类的问题。
- 实体/值对象:只承载自身状态、和自身状态强相关的单实例行为,绝对不碰集合查询、跨实体的逻辑
- 仓储:一个聚合根对应一个仓储,承载该聚合所有的增删改查操作,封装存储层细节
- 领域服务:承载跨多个聚合根、不属于单个实体职责的领域逻辑
- CQRS读查询:针对展示场景的轻量化只读查询,直接返回DTO,不经过领域实体和仓储的完整逻辑
内容的提问来源于stack exchange,提问作者PepeDeLew

