领域驱动设计中如何对聚合子集的去重值查询进行建模?
这个问题其实戳中了DDD里一个常见的痛点——如何在保持领域模型纯净的同时,满足查询侧的动态筛选需求。你的初始思路方向对了一半,但把查询结果封装成聚合根就有点走偏了,因为DistinctFooProperties本质上是一个读模型(查询结果容器),而非有业务行为的聚合。下面给你几个更贴合DDD思想的标准解决方案:
方案1:用CQRS分离查询模型,通过共享值对象/元数据保证一致性
DDD里的CQRS原则很适合这种场景:命令侧专注维护Foo聚合的业务规则和数据一致性,查询侧则可以根据前端需求专门构建读模型,不用严格遵循聚合的边界。
为了避免Foo聚合和查询模型的属性脱节,你可以:
- 为
Foo的每个可筛选属性定义值对象,比如FooProp1、FooProp2,这些值对象包含属性的约束(比如格式、可选范围的基础规则)。 - 构建一个
FooFilterOptions读模型(注意这不是聚合根,只是DTO),用这些值对象的集合来存储去重选项。
示例代码:
// 共享值对象:定义Foo属性的约束 class FooProp1 extends ValueObject { private string $value; public function __construct(string $value) { // 这里可以加属性的业务校验,比如长度、格式 $this->value = $value; } public function toString(): string { return $this->value; } } // 查询用的DTO:读模型,不是聚合 class FooFilterOptions { /** @var FooProp1[] */ private array $prop1Options; /** @var FooProp2[] */ private array $prop2Options; /** @var FooProp3[] */ private array $prop3Options; public function __construct(array $prop1Options, array $prop2Options, array $prop3Options) { $this->prop1Options = $prop1Options; $this->prop2Options = $prop2Options; $this->prop3Options = $prop3Options; } public function getProp1Options(): array { return array_map(fn(FooProp1 $p) => $p->toString(), $this->prop1Options); } // 其他getter同理 } // 查询服务:专门处理筛选选项的查询 class FooFilterQueryService { public function __construct(private FooRepository $fooRepo) {} public function getFilterOptions(?FooSearchObject $search): FooFilterOptions { // 根据搜索条件构建查询,获取去重的属性值 // 这里可以用Foo聚合的属性元数据来避免硬编码字段名 $prop1Values = $this->fooRepo->findDistinctProp1Values($search); $prop2Values = $this->fooRepo->findDistinctProp2Values($search); $prop3Values = $this->fooRepo->findDistinctProp3Values($search); return new FooFilterOptions( array_map(fn(string $v) => new FooProp1($v), $prop1Values), array_map(fn(string $v) => new FooProp2($v), $prop2Values), array_map(fn(string $v) => new FooProp3($v), $prop3Values) ); } }
这样一来,Foo聚合和查询模型通过共享值对象保持属性约束的一致性,同时查询模型不用承担聚合的业务职责,复杂度大大降低。
方案2:用领域元数据统一管理可筛选属性
如果不想引入太多值对象,你可以定义一个FooPropertyMetadata的领域对象,专门记录Foo的可筛选属性信息,让查询逻辑基于这个元数据来构建,避免硬编码字段名导致的不一致。
示例代码:
// 领域元数据:统一管理Foo的可筛选属性 class FooPropertyMetadata { public const PROP1 = 'prop1'; public const PROP2 = 'prop2'; public const PROP3 = 'prop3'; public static function getFilterableProperties(): array { return [self::PROP1, self::PROP2, self::PROP3]; } } // Foo聚合里也引用这个元数据(可选,用于约束业务逻辑) class Foo extends AggregateRoot { private string $id; private string $prop1; private string $prop2; private string $prop3; // 业务方法... // 比如验证属性修改时,用元数据确保属性存在 public function updateProperty(string $propName, string $value): void { if (!in_array($propName, FooPropertyMetadata::getFilterableProperties())) { throw new InvalidArgumentException("Invalid property: $propName"); } // 后续修改逻辑 } } // 查询服务基于元数据构建查询 class FooFilterQueryService { public function __construct(private PDO $pdo) {} public function getFilterOptions(FooSearchObject $search): array { $whereClause = $this->buildWhereClause($search); $options = []; foreach (FooPropertyMetadata::getFilterableProperties() as $prop) { $stmt = $this->pdo->prepare("SELECT DISTINCT $prop FROM Foo $whereClause"); $stmt->execute($search->getParameters()); $options[$prop] = $stmt->fetchAll(PDO::FETCH_COLUMN); } return $options; } private function buildWhereClause(FooSearchObject $search): string { // 根据搜索条件构建WHERE子句,同样基于元数据避免硬编码 $conditions = []; foreach (FooPropertyMetadata::getFilterableProperties() as $prop) { $value = $search->getPropertyValue($prop); if ($value) { $conditions[] = "$prop = :$prop"; } } return $conditions ? "WHERE " . implode(" AND ", $conditions) : ""; } }
这种方式的好处是,所有和Foo属性相关的代码都基于同一个元数据,不会出现属性名拼写错误或者新增属性时漏改查询逻辑的情况,同时保持了Foo聚合的纯净。
方案3:不要把查询结果当成聚合根
你最初的DistinctFooProperties其实没必要做成聚合根——聚合根的核心是封装业务行为和保证业务规则,而这个类只是存储查询出来的去重选项,没有任何业务逻辑,本质上就是一个查询DTO。
你可以把它改成一个简单的DTO,然后通过共享常量或枚举来和Foo聚合保持属性一致,比如在Foo聚合里定义属性名常量,查询DTO和查询逻辑都引用这些常量,这样就能避免属性不一致的问题。
为什么不推荐把查询结果做成聚合?
聚合根需要有唯一标识、业务行为,并且要保证聚合内的一致性,但DistinctFooProperties没有这些特性——它只是一个临时的查询结果,不需要持久化,也没有业务规则需要维护,强行做成聚合只会增加不必要的复杂度。
总结一下,最推荐的是方案1(CQRS+共享值对象),它既遵循了DDD的领域模型原则,又能灵活满足前端的筛选需求,同时保证了属性的一致性。如果觉得值对象太繁琐,方案2的元数据方式也是一个轻量的选择。
内容的提问来源于stack exchange,提问作者Allenph

