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

领域驱动设计中如何对聚合子集的去重值查询进行建模?

这个问题其实戳中了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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.28 09:36:26