You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 03:54:23