事件溯源中的价值对象

Value object in event sourcing

在事件源域模型中是否有值对象的位置?

让我们将值对象定义为具有不可变状态的对象,它保护其不变量并且没有特定的标识符。

在此上下文中,事件源域模型是一个完全或部分事件源的域,这意味着它的当前状态可以通过应用发生在过去。事件本身被认为是不可变的,即使随着时间的推移也是如此。

关于 the validity of using value objects within events 的争论已经发生 - 这个问题稍微进一步:值对象在事件源域中是否占有一席之地 ?

使用值对象的(潜在)问题是,以不变量收紧的方式更改域变得相当棘手。

这种情况的一个例子是有一个 Username 值对象,唯一的限制是名称必须介于 2 到 16 个字符之间。

虽然这已经运行了一段时间,但公司决定只允许至少 5 个字符的用户名。 迁移期开始,姓名少于 5 个字符的用户将被要求更新他们的姓名。

可以说这个过程是成功的,应用了更正事件,每个人都很高兴。 我们收紧了对 Username 值对象的限制,要求至少包含 5 个字符。

有一段时间每个人都很高兴,但后来我们发现快照有问题并重播所有事件。

我们现在面临 Username 对象的异常:通过加载历史数据,我们打破了域的不变量。

值对象的规则追溯适用 - 这是否使它们本质上不适合事件溯源?是否值得应用值对象的版本控制?有没有更简单的方法来避免此类问题?

我想说的是,目前您重新定义了 Username 的含义, 您并没有以某种方式迁移历史数据,您实际上已经创建了 2 个不同的Username 个含义。

因为这个词有两种不同的含义,所以你必须以某种方式在代码中明确说明。 "Versioning" 是一种方法,虽然我不会使用这种通用解决方案,但有不同的建模选项。

您可以明确表示 "username" 的历史就是这样,一段历史。因此,例如创建一个 HistoricUsername,它是事件源对象,如果需要,甚至可以是值对象。并创建一个 Username,它始终是具有最新规则的用户名,它根本不会持久化,但如果可以的话,它是从 HistoricUsername 创建的。

有些人建议有时从对象中提取 "rules",然后再重新应用。这样对象本身在任何时候都是有效的,你可以要求它根据可能改变的规则来验证自己。我不太喜欢这些解决方案,但它是一个选项,Username 仍然是一个值对象。

所以问题不在于值对象不适合事件溯源,而在于建模必须更加准确。

Do value objects have a place in event sourced domains at all?

是的。

Is there a simpler way of avoiding such problems?

"Don't do that."

您所描述的问题实际上是一个关于消息传递的问题 - 如果我们对我们的消息进行向后不兼容的更改,那么事情就会崩溃。

(更准确地说,您有一条 "Username" 消息,并且您正尝试使用一组新的约束来重新使用该消息,这些约束会拒绝该消息以前的一些有效使用)。

答案是您不会引入向后不兼容的更改 - 而是引入符合新要求的新名称,并弃用旧名称。

也就是说,添加对新消息的支持和删除对旧消息的支持,成为两个单独管理的选项。

Greg Young 的书 Versioning in an Event Sourced System dedicates some chapters to this idea. Also, Rich Hickey ends up touching on these important ideas in most of his talks -- I'd suggest starting from Spec-ulation

"value object",意思是域模型的当前实现用来移动信息的类型,是一个独立于消息的关注点。我们在内存中使用的数据结构不需要耦合到我们的序列化格式。

线上信息的表示不同于内存中信息的表示,而后者又不同于操作内存中信息的抽象。

具有挑战性的是,在项目开始时,关于不同表示何时会出现分歧的信息最少。

虽然已经回答了,但我确实发现这是一个有趣的情况。

我同意其他人的观点,事件数据应该基于记录,因此,只不过是一个可用于重构聚合的数据容器。

也就是说,当规则发生变化时,域也会发生变化。领域驱动设计的主要部分是根据需要捕获尽可能多的领域 (rules/structure)。如果是这样的话,规则的变化是不是也应该保留?

例如,如果我们有一个 Username Value Object 并且它以 2 到 16 个字符的规则开头,那么编码如下:

public class Username
{
    public string Value { get; }

    public Username(string value)
    {
        if (value.Length < 2 || value.Length > 16)
        {
            throw new DomainException("Username must be between 2 and 16 characters");
        }

        Value = value;
    }
}

现在到了 2018 年 3 月 1 日,规则发生了变化。我们可以保留规则:

public class Username
{
    public string Value { get; }

    public Username(string value, DateTime registrationDate)
    {
        if (registrationDate < new Date(2018, 3, 1) &&
            (value.Length < 2 || value.Length > 16))
        {
            throw new DomainException("Username must be between 2 and 16 characters");
        }

        if (registrationDate >= new Date(2018, 3, 1) &&
            (value.Length < 5 || value.Length > 16))
        {
            throw new DomainException("Username must be between 5 and 16 characters");
        }

        Value = value;
    }
}

这是基本思路。通过这种方式,我们也保留了 "old" 规则。这可能会变得很麻烦,但我没有足够的经验可以说。追溯更改我们的规则可能会引入一些非常棘手的情况,因此我想需要根据具体情况进行评估。

只是一个想法。

我们用稍微不同的方式解决了这个问题。通过将值对象的 public API 与内部(仅限域)API 分开,我们能够在不影响另一个的情况下发展一个.

例如:

public class Username
{
    private readonly string value;

    // Domain-only (internal) constructor.
    // Does not enforce constriants and can only be called within the domain.
    internal Username(string value)
    {
        this.value = value;
    }

    // Public factory method.
    // Enforces business constraints. Used by consumers of the domain (application layer etc.)
    // to create new instances of the value object.
    public static Username Create(string value)
    {
        // Business constraints. These will evolve and grow over time.
        if (value == null)
        {
            // throw exception etc.
        }

        if (value.Length < 2)
        {
            // throw exception etc.
        }

        return new Username(value);
    }
}

域的使用者必须使用静态 Create 方法来创建值对象的新实例。此工厂方法包含我们所有的业务约束并防止在无效状态下创建实例。

在域内,classes 可以访问内部(无约束)构造函数。由于这不会强制执行任何业务约束,因此始终可以以这种方式创建值对象的实例(无论其值如何)。通过在重放事件时使用此构造函数,我们可以确保历史数据始终成功。

这种设计的好处是:

  • 单个class用于表示域概念(不需要多个classes、版本控制等)。
  • 业务规则可以随时间自由发展。
  • 历史数据总是有用的。一年前的 Username 仍然是用户名 ,即使我们的规则已更改。