这是我们关于领域驱动设计 (DDD) 的深入系列文章的第二部分。第一部分讨论了战略性领域驱动设计,而在第三部分中,您将学习如何将领域驱动设计应用于使用 Java 和 Vaadin 的工作软件。2023 年更新。

在本文中,我们将学习战术领域驱动设计。
战术 DDD 是一组设计模式和构建基块,可用于设计域驱动系统。即使对于不是域驱动的项目,您也可以从使用一些战术 DDD 模式中受益。

与战略领域驱动设计相比,战术设计更具实践性,更接近实际代码。战略设计处理抽象整体,而战术设计处理类和模块。
战术设计旨在将领域模型优化到可以将其转换为工作代码的阶段。

设计是一个迭代过程,因此将战略设计和战术设计结合起来是有意义的。你从战略设计开始,然后是战术设计。
最大的领域模型设计启示和突破可能会发生在战术设计期间,而这反过来会影响战略设计,因此您需要重复该过程。

同样,内容很大程度上基于Eric Evans的**《Domain-Driven Design: Tackling Complexity in the Heart of Software** 》和Vaughn Vernon的 《Implementing Domain-Driven Design 》这两 本书 。我强烈建议您阅读它们。就像在上一篇文章中一样,我选择尽可能多地用我自己的话进行解释,在适当的时候注入我自己的想法、想法和经验。

有了这个简短的介绍,是时候拿出战术 DDD 工具箱并看看里面有什么了。

 值对象

战术 DDD 中最重要的概念之一是值对象。这也是我在非 DDD 项目中使用最多的 DDD 构建块,我希望您在阅读本文后也能理解。

值对象是其值具有重要意义的对象。这意味着具有完全相同值的两个值对象可以被视为相同的值对象,因此可以互换。因此,值对象应始终保持 不可变。不是更改值对象的状态,而是将其替换为新实例。对于复杂的值对象,请考虑使用 构建器 或 本质 模式。

值对象不仅是数据的容器,它们还可以包含业务逻辑。值对象也是不可变的,这一事实使得业务操作既线程安全又无副作用。
这就是我如此喜欢值对象的原因之一,也是为什么你应该尝试将尽可能多的领域概念建模为值对象的原因之一。此外,尝试使值对象尽可能小且连贯,使它们更易于维护和重用。

创建值对象的一个很好的起点是获取所有具有业务意义的单值属性,并将它们包装为值对象。例如:

  • 不要使用 BigDecimal 来表示货币值,而应使用 包装 BigDecimalMoney 值对象。如果要处理多种货币,则可能还需要创建一个 Currency 值对象,并使 Money 对象包装为 BigDecimal-Currency 对。

  • 不要对电话号码和电子邮件地址使用字符串,而应使用 包装字符串的 PhoneNumber 和 EmailAddress 值对象。

使用这样的值对象有几个优点。首先,它们为价值带来了背景信息。您不需要知道特定字符串是否包含电话号码、电子邮件地址、名字或邮政编码,也不需要知道 BigDecimal 是 货币值、百分比还是完全不同的东西。类型本身会立即告诉您正在处理什么。

其次,您可以将可以对特定类型的值执行的所有业务操作添加到值对象本身。例如,Money  对象可以包含用于添加和减去货币总和或计算百分比的操作,同时确保基础 BigDecimal 的精度始终正确,并且操作中涉及的所有 Money 对象都具有相同的货币。

第三,您可以确保值对象始终包含有效值。例如,可以在 EmailAddress 值对象的 构造函数中验证电子邮件地址输入字符串。

代码示例

 Java 中的 Money 值对象可能看起来像这样(代码未经测试,为了清楚起见,省略了一些方法实现):

Money.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class Money implements Serializable, Comparable<Money> {
    private final BigDecimal amount; // 金额
    private final Currency currency;   // 货币类型,Currency是一个枚举或另一个值对象

    // 构造函数
    public Money(BigDecimal amount, Currency currency) {
        this.currency = Objects.requireNonNull(currency);
        this.amount = Objects.requireNonNull(amount).setScale(currency.getScale(), currency.getRoundingMode());
    }

    // 向当前Money对象添加另一个Money对象
    public Money add(Money other) {
        assertSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }

    // 从当前Money对象减去另一个Money对象
    public Money subtract(Money other) {
        assertSameCurrency(other);
        return new Money(amount.subtract(other.amount), currency);
    }

    // 确保两个Money对象具有相同的货币类型
    private void assertSameCurrency(Money other) {
        if (!other.currency.equals(this.currency)) {
            throw new IllegalArgumentException("Money objects must have the same currency");
        }
    }

    // 检查两个Money对象是否相等
    public boolean equals(Object o) {
        // 检查货币类型和金额是否相同
    }

    // 基于货币类型和金额计算哈希码
    public int hashCode() {
        // 计算哈希码
    }

    // 比较两个Money对象的大小
    public int compareTo(Money other) {
        // 基于货币类型和金额进行比较
    }
}

 Java 中的 StreetAddress 值对象和相应的构建器可能看起来像这样(代码未经测试,为了清楚起见,省略了一些方法实现):

StreetAddress.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class StreetAddress implements Serializable, Comparable<StreetAddress> {
    private final String streetAddress; // 街道地址
    private final PostalCode postalCode;   // 邮政编码,PostalCode是另一个值对象
    private final String city;             // 城市
    private final Country country;         // 国家,Country是一个枚举

    // 构造函数
    public StreetAddress(String streetAddress, PostalCode postalCode, String city, Country country) {
        // 验证必需参数不为null
        // 将参数值分配给相应的字段
        this.streetAddress = streetAddress;
        this.postalCode = postalCode;
        this.city = city;
        this.country = country;
    }

    // Getters和可能的业务逻辑方法省略

    // 检查两个StreetAddress对象是否相等
    public boolean equals(Object o) {
        // 检查字段是否相等
    }

    // 基于所有字段计算哈希码
    public int hashCode() {
        // 计算哈希码
    }

    // 比较两个StreetAddress对象
    public int compareTo(StreetAddress other) {
        // 根据需要进行比较
    }

    // Builder类用于构建StreetAddress对象
    public static class Builder {
        private String streetAddress;
        private PostalCode postalCode;
        private String city;
        private Country country;

        // 用于创建新的StreetAddresses
        public Builder() {
        }

        // 用于"修改"现有的StreetAddresses
        public Builder(StreetAddress original) {
            this.streetAddress = original.streetAddress;
            this.postalCode = original.postalCode;
            this.city = original.city;
            this.country = original.country;
        }

        // 设置街道地址的方法
        public Builder withStreetAddress(String streetAddress) {
            this.streetAddress = streetAddress;
            return this;
        }

        // 其他'with...'方法省略

        // 构建StreetAddress对象
        public StreetAddress build() {
            return new StreetAddress(streetAddress, postalCode, city, country);
        }
    }
}

在领域驱动设计(Domain-Driven Design, DDD)中,值对象(Value Object)是一种没有独立存在意义的类,它仅通过它的属性来表达其概念,并通常用于描述领域模型中的特定方面或属性集合。

值对象的特点:

  1. 不可变性:值对象一旦被创建,其状态(属性)就不能被改变。如果需要修改,会创建一个新的实例。

  2. 相等性:值对象的相等性是基于它的属性来定义的,而不是它的身份。如果两个值对象的属性完全相同,那么它们就被认为是相等的。

  3. 无标识性:值对象没有唯一标识符,它们不是通过ID来区分的。

  4. 可替换性:值对象是可替换的,这意味着在任何需要使用该值对象的地方,都可以使用它的一个副本而不会影响业务逻辑。

  5. 语义化:值对象通常具有丰富的语义和行为,这些行为定义了它在领域中的作用。

  6. 轻量级:值对象通常是轻量级的,并且可以被轻松地在领域中传递。

值对象的用途:

  • 表示不能独立存在的概念,比如尺寸、颜色、货币金额等。
  • 作为领域实体的属性,比如一个人的姓名、地址等。
  • 作为参数传递给领域服务或实体的方法,以表达某个特定概念。

值对象的实现示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public final class Money {
    private final BigDecimal amount; // 金额
    private final Currency currency; // 货币类型

    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // 重写equals方法,基于金额和货币类型
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return Objects.equals(amount, money.amount) &&
               Objects.equals(currency, money.currency);
    }

    // 重写hashCode方法,基于金额和货币类型
    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }

    // 其他方法,比如货币转换、加法等
    public Money add(Money another) {
        // 实现加法逻辑
    }

    // ... 省略getter和toString等方法
}

public enum Currency {
    USD, EUR, CNY // 举例:美元、欧元、人民币
}

在这个例子中,Money 类是一个值对象,它有两个属性:金额和货币类型。它重写了 equalshashCode 方法,以确保基于属性的相等性。此外,Money 类可以包含一些行为,比如货币的加法操作。

值对象是DDD中非常重要的概念,它们帮助我们以一种清晰和精确的方式来表达领域模型中的各种概念。

实体

战术 DDD 中的第二个重要概念和值对象的同级概念是 实体。实体是其 身份 很重要的对象。为了能够确定实体的身份,每个实体都有一个唯一的 ID,该 ID 是在创建实体时分配的,并且在实体的整个生命周期内保持不变。

即使所有其他属性都不同,具有相同类型和相同 ID 的两个实体也被视为同一实体。
同样,相同类型、具有相同属性但 ID 不同的两个实体被视为不同的实体,就像两个同名的个体不被视为相同实体一样。

与值对象相反,实体是可变的。但是,这并不意味着您应该为每个属性创建 setter 方法。尝试将所有状态改变操作建模为对应于业务操作的动词。
设置者只会告诉您要更改的属性,而不会告诉您为什么。例如:假设您有一个 EmploymentContract 实体,并且它有一个 endDate 属性。雇佣合同可能会终止,因为它们只是暂时的,首先,因为从一个公司分支机构内部转移到另一个分支机构,因为员工辞职,或者因为雇主解雇了员工。在所有这些情况下, endDate 都会更改,但原因非常不同。此外,根据合同终止的原因,可能需要采取其他措施。terminateContract (reason, finalDay) 方法已经不仅仅是 setEndDate(finalDay) 方法所讲述的更多内容。

也就是说,二传手在 DDD 中仍然占有一席之地。在上面的示例中,私有方法可以确保结束日期在设置开始日期之后。此 setter 将被其他实体方法使用,但不会向外界公开。
对于主数据和引用数据,以及在不改变实体业务状态的情况下描述实体的属性,使用 setter 比尝试将操作调整为动词更有意义。名为 setDescription(..) 的方法 可以说比 describe(..) 更具可读性。

我将用另一个例子来说明这一点。假设您有一个 代表一个人的 Person实体。此人具有 firstName 和 lastName属性。现在,如果这只是一个简单的地址簿,您可以让用户根据需要更改此信息,并且您可以使用 setFirstName(..) 设置器。  和 setLastName(..)。但是,如果您正在建立正式的政府公民登记册,则更改姓名会更复杂。您最终可能会得到类似 changeName(firstName, lastName, reason, effectiveAsOfDate) 的内容。同样,上下文就是一切。

 关于 getter 的说明

Getter 方法作为 JavaBean 规范的一部分被引入到 Java 中。此规范在 Java 的第一个版本中不存在,这就是为什么您可以在标准 Java API 中找到一些不符合它的方法(例如 String.length() 而不是 String.getLength())。

就我个人而言,我希望看到 Java 中对真实属性的支持。尽管他们可以在幕后使用 getter 和 setter,但我希望以与它只是一个普通字段相同的方式访问属性值: mycontact.phoneNumber。我们还不能在 Java 中做到这一点,但我们可以通过省略 getter 的 get 后缀来非常接近。在我看来,这使代码更加流畅,特别是如果您需要更深入地了解对象层次结构以获取某些内容: mycontact.address().streetNumber()。

但是,摆脱吸气剂也有一个缺点,那就是工具支持。
所有 Java IDE 和许多库都依赖于 JavaBean 标准,这意味着您最终可能会手动编写本可以为您自动生成的代码,并添加可以通过遵守约定来避免的注释。

 实体对象还是值对象?

要知道是将某物建模为值对象还是实体,并不总是那么容易。相同的现实世界概念可以在一个上下文中建模为实体,在另一个上下文中建模为值对象。让我们以街道地址为例。

如果您正在构建发票系统,则街道地址只是您打印在发票上的内容。只要发票上的文本正确无误,使用什么对象实例并不重要。在本例中,街道地址是一个值对象。

如果您正在为公用事业构建系统,您需要确切地知道什么天然气管线或电力线进入给定的公寓。在这种情况下,街道地址是一个实体,甚至可以拆分为较小的实体,如建筑物或公寓。

值对象更易于使用,因为它们是不可变的且很小的。因此,您应该以具有少量实体和许多值对象的设计为目标。

代码示例

 Java 中的 Person 实体可能看起来像这样(代码未经测试,为了清楚起见,省略了一些方法实现):

Person.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class Person {
    private final PersonId personId;      // 个人ID
    private final EventLog changeLog;     // 变更日志
    private PersonName name;              // 个人姓名
    private LocalDate birthDate;          // 出生日期
    private StreetAddress address;        // 地址
    private EmailAddress email;           // 电子邮件地址
    private PhoneNumber phoneNumber;      // 电话号码

    // 构造函数
    public Person(PersonId personId, PersonName name) {
        this.personId = Objects.requireNonNull(personId);
        this.changeLog = new EventLog();
        changeName(name, "initial name");
    }

    // 更改姓名
    public void changeName(PersonName name, String reason) {
        Objects.requireNonNull(name);
        this.name = name;
        this.changeLog.register(new NameChangeEvent(name), reason);
    }

    // 获取姓名历史记录
    public Stream<PersonName> getNameHistory() {
        return this.changeLog.eventsOfType(NameChangeEvent.class)
                             .map(NameChangeEvent::getNewName);
    }

    // 其他getter方法省略

    // 检查两个Person对象是否相等
    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (o == null || o.getClass() != getClass()) {
            return false;
        }
        return personId.equals(((Person) o).personId);
    }

    // 基于personId计算哈希码
    @Override
    public int hashCode() {
        return personId.hashCode();
    }
}

领域驱动设计(DDD)与CRUD模式概述

值对象的运用

在领域驱动设计中,值对象是表示不变数据的简单对象。以下是一些示例:

  • PersonId: 用于标识特定人员的ID。虽然可以使用UUID、字符串或长整型,但值对象的使用明确了其用途。- PersonName: 表示人员的姓名。- LocalDate: 表示日期,尽管它是Java API的一部分,但在此上下文中,它被当作值对象使用。- StreetAddress: 表示街道地址。- EmailAddress: 表示电子邮件地址。- PhoneNumber: 表示电话号码。

实体与值对象的区别

实体具有唯一标识符,并且可以随着时间改变其状态。值对象则不具备唯一标识符,它们是不可变的,并且只通过其属性来定义。

实体的操作

  • 不使用setter:以更改实体的属性,而是通过业务方法来实现,这样可以记录操作的上下文和原因。- 业务方法:例如,更改名称的操作不仅更新名称,还记录更改的原因。- 事件日志:记录所有重要的业务活动,以支持审计和历史跟踪。

实体的属性访问

  • Getter方法:用于获取实体的属性,例如获取名称更改的历史记录。- equals和hashCode:只基于实体ID进行比较,确保实体的相等性基于其唯一标识符。

CRUD与DDD

CRUD代表创建(Create)、检索(Retrieve)、更新(Update)和删除(Delete),是企业应用程序中常见的操作模式。在DDD中,CRUD操作应该与业务逻辑紧密结合,以确保数据的一致性和完整性。

创建(Create)- 创建新的实体实例,并赋予其初始状态。

检索(Retrieve)- 根据ID或其他业务相关的查询条件检索实体。

更新(Update)- 通过业务方法更新实体的状态,而不是直接修改属性。

删除(Delete)- 根据业务规则删除实体,可能涉及记录删除操作或标记为已删除。

结论

领域驱动设计强调业务逻辑和数据模型的一致性,而CRUD提供了一种操作数据的标准模式。在DDD中实现CRUD时,应该考虑业务规则和实体行为,以确保系统的健壮性和可维护性。 Example of a CRUD user interface

领域驱动设计中的CRUD与业务流程

在传统的CRUD(创建、读取、更新、删除)应用程序中,用户界面通常由以下几个部分组成:

  1. 检索:主视图通常是一个网格,用户可以在其中通过过滤和排序功能查找实体。2. 创建:在主视图中,有一个按钮用于创建新实体。点击此按钮会弹出一个空表单,提交后新实体将显示在网格中。3. 更新:有一个按钮用于编辑所选实体。点击后会弹出一个包含实体数据的表单,提交表单后实体将更新为新信息。4. 删除:还有一个按钮用于删除所选实体,点击后实体将从网格中删除。 然而,在领域驱动的应用程序中,CRUD模式应该是例外,而不是常态。这是因为CRUD应用仅关注数据的构建、显示和编辑,而不支持底层的业务流程。在CRUD系统中,用户输入、更改或删除内容背后的业务原因往往丢失,导致业务流程仅存在于用户的脑海中。

领域驱动的用户界面

一个真正的领域驱动用户界面将基于通用语言的操作,并将业务流程内置在系统中。这使得系统比纯CRUD应用更健壮,但可能在灵活性上有所欠缺。以下是领域驱动与CRUD方法的对比示例:

A公司:领域驱动的员工管理系统

  1. 经理在系统中查找员工记录。2. 选择“终止雇佣合同”操作。3. 系统询问终止日期和原因。4. 经理输入所需信息并点击“终止合同”。5. 系统自动更新员工记录,撤销凭证和密钥,并向工资系统发送通知。

B公司:CRUD驱动的方法

  1. 经理查找员工记录。2. 在“合同终止”复选框中打勾,输入终止日期,点击“保存”。3. 管理员禁用用户账户。4. 经理禁用办公室密钥。5. 发送电子邮件通知工资部门。

聚合的概念

在领域驱动设计中,除了实体和值对象,聚合也是一个重要概念。它由以下特点组成:

  • 聚合作为一个整体被创建、检索和存储。- 聚合始终处于一致状态。- 聚合由一个称为聚合根的实体拥有,该实体的ID用于标识聚合本身。 聚合是领域模型的基础,确保数据的完整性和一致性。

关键要点

  • 不是所有应用程序都适合领域驱动设计。- 领域驱动的应用程序应具有领域驱动的后端和用户界面。 通过上述内容,我们可以看到领域驱动设计如何帮助我们更好地理解和实现业务流程,以及如何通过聚合来维护数据的一致性和完整性。 Example of an aggregate with an aggregate root

此外,关于聚合还有两个重要的限制:

  • 聚合只能通过其根从外部引用。聚合外部的对象不得  引用聚合内部的任何其他实体。
  • 聚合根负责在聚合内部强制执行业务不变 性,确保聚合始终处于一致状态。 

Example of allowed and prohibited references between aggregates 在领域驱动设计(DDD)中,实体的设计至关重要,它们可以是聚合根或本地实体。聚合根负责管理和协调其内部的本地实体,确保业务规则的一致性。以下是对聚合设计的一些关键点的梳理:

实体类型- 聚合根:具有全局唯一标识,独立存在,可被直接访问和修改。- 本地实体:存在于聚合内部,拥有局部唯一标识,不能被外部直接访问。

存储方式的影响- 在关系数据库中,统一使用主键生成机制。- 在文档数据库中,本地实体可使用本地ID。

判断聚合根的标准- 访问方式:是否通过ID或搜索直接访问。- 引用情况:是否被其他聚合引用。- 修改独立性:是否可以独立于其他实体进行修改。

业务不变性的强制- 聚合根通过以下方式强制业务不变性: - 所有状态更改通过聚合根执行。 - 本地实体状态变化时通知聚合根。

聚合设计准则1. 保持聚合小:减少数据读取写入量,提高系统性能,便于业务规则的强制。2. 通过ID引用其他聚合:避免直接引用,使用包装ID的值对象,维护聚合的一致性边界。

个人实践- 我倾向于设计聚合以立即且始终强制执行业务不变性。- 可以通过严格的数据验证实现相同的效果,但这更多是个人偏好。

结论聚合设计是DDD中的一个基础而关键的环节,合理的设计可以提高系统的可维护性和性能。

Refer to other aggregate roots by ID 在某些情况下,如果确实需要访问其他聚合的数据,可能需要打破常规的准则。尽管有其他方法,但它们可能并不总是最佳解决方案。一种替代方法是使用持久性框架的延迟加载功能,然而,根据我的个人经验,这种方法可能会带来更多问题而不是解决方案。 为了更明确地控制数据访问,可以考虑采用一种编码工作量较大但更直接的方法。具体来说,可以将存储库作为方法参数传入。这将在稍后的部分详细介绍,但基本思想是利用存储库的直接访问能力,以确保数据的一致性和准确性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Invoice extends AggregateRoot<InvoiceId> {
    private CustomerId customerId; // 客户ID

    // 其他方法和字段省略

    // 将客户信息复制到发票上
    public void copyCustomerInformationToInvoice(CustomerRepository repository) {
        Customer customer = repository.findById(customerId);
        setCustomerName(customer.getName()); // 设置客户名称
        setCustomerAddress(customer.getAddress()); // 设置客户地址
        // 其他客户信息设置省略
    }
    
    // 以下是一些可能的方法定义,用于设置发票上的客户信息
    private void setCustomerName(String name) {
        // 实现设置客户名称的逻辑
    }

    private void setCustomerAddress(String address) {
        // 实现设置客户地址的逻辑
    }

    // AggregateRoot类是一个泛型类,这里假设它有一个泛型参数InvoiceId
    // 这里也省略了AggregateRoot类的实现细节
}

准则 3:更改每个事务的一个聚合

在设计领域驱动设计(DDD)中的操作时,应遵循以下原则:确保每个事务仅对一个聚合体进行更改。这一做法有助于维护数据的一致性,并减少在事务处理过程中出现意外副作用的风险。此外,它还为将来可能的系统分布式部署提供了便利。

聚合与事务的关系

在DDD中,聚合是指一组紧密相关的对象集合,它们作为一个整体被管理以保证数据的一致性。事务则是数据库操作中的一个单元,确保了操作的原子性。将两者结合使用时,应保证在单个事务中只对一个聚合体进行修改,以避免跨聚合体的复杂依赖和潜在的数据问题。

跨聚合体的操作

如果业务需求需要跨多个聚合体进行操作,可以利用领域事件和最终一致性模式来实现。领域事件允许系统的不同部分在发生特定业务活动时进行通信,而最终一致性则允许系统在一定时间后达到一致的状态,而不是立即。这些机制将在后续内容中详细介绍。

避免副作用和简化系统使用

遵循准则3,可以减少因事务操作不当而产生的副作用,例如数据不一致或更新冲突。此外,当系统需要使用不支持事务的文档数据库时,这种设计原则也使得数据操作更加容易实现。

结论

通过将每个事务限制在一个聚合体上,我们不仅提高了系统的健壮性和可维护性,也为未来的系统扩展和分布式部署打下了基础。 Modifying two aggregates in separate transactions 在软件开发过程中,引入域事件可以增加系统复杂性。然而,通过合理设置基础架构,可以确保域事件的可靠处理。特别是在单体应用程序中,域事件的同步调度可以在同一个线程和事务中完成,从而减少这种复杂性。 我认为,一个有效的方法是继续使用域事件来对其他聚合进行更改,但这些更改应该在同一事务中完成。这样,我们可以在保持系统灵活性的同时,减少因域事件处理不当而带来的风险。 Modifying two aggregates in a single transaction 在领域驱动设计(DDD)中,聚合是一个核心概念,它代表了一个数据修改的一致性单元。以下是对聚合、乐观锁定以及与用户界面交互的一些关键点的重新整理和阐述:

聚合与业务不变性聚合是DDD中的一个关键概念,它封装了一系列相关对象,确保它们作为一个单元进行修改,从而维护数据的一致性和业务规则的不变性。聚合根是聚合中的核心对象,负责管理聚合的生命周期。

乐观锁定为了防止在并发环境下的数据冲突,乐观锁定是一种常用的技术。它假设多个事务在同一数据上的操作不会发生冲突,只有在提交更改时才会进行检查。如果检测到冲突,事务可以被拒绝并重试。

避免直接修改其他聚合在设计系统时,应避免从一个聚合中直接修改另一个聚合的状态。这样做有助于保持聚合的自治性和封装性。

与用户界面的交互在用户界面上,如何处理聚合的不变性和表单绑定是一个挑战。以下是一些策略:

  1. 推迟不变性执行:直到聚合保存前才执行不变性检查,但这可能导致业务逻辑泄露到UI层。2. 领域模型映射:将表单及其内容映射到领域模型中,例如,使用MembershipApplication来收集创建Membership所需的信息。3. 本质模式:创建一个可变的、包含实体或值对象相同信息的本质对象,用于表单绑定。本质对象在收集完所有必要信息后,可以用来创建实际的领域对象。

实践中的注意事项- 保持聚合的小巧,以降低并发冲突的风险。

  • 谨慎使用乐观锁定,确保它适合你的业务场景。
  • 在设计UI时,考虑如何将表单与领域模型自然地结合起来,而不是简单地将数据从UI传递到领域模型。

通过上述方法,可以在保持DDD原则的同时,有效地与用户界面进行交互。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Person extends AggregateRoot<PersonId> {
    private final DateOfBirth dateOfBirth; // 出生日期

    // 其他字段省略

    // 构造函数,使用名字、姓氏和出生日期创建Person对象
    public Person(String firstName, String lastName, LocalDate dateOfBirth) {
        setDateOfBirth(dateOfBirth); // 设置出生日期
        // 填充其他字段
    }

    // 构造函数,使用Essence对象创建Person对象
    public Person(Person.Essence essence) {
        setDateOfBirth(essence.getDateOfBirth()); // 从Essence对象设置出生日期
        // 填充其他字段
    }

    // 设置出生日期的私有方法
    private void setDateOfBirth(LocalDate dateOfBirth) {
        this.dateOfBirth = Objects.requireNonNull(dateOfBirth, "dateOfBirth must not be null");
    }

    // Essence类,使用Lombok注解@Data自动生成getter和setter
    @Data // Lombok注解,自动生成getter、setter等
    public static class Essence {
        private String firstName;       // 名字
        private String lastName;        // 姓氏
        private LocalDate dateOfBirth; // 出生日期
        private String streetAddress;   // 街道地址
        private String postalCode;      // 邮政编码
        private String city;            // 城市
        private Country country;        // 国家

        // 从Essence创建Person对象的方法
        public Person createPerson() {
            validate(); // 验证所有必要信息是否已输入
            return new Person(this); // 创建Person对象
        }

        // 验证方法,确保所有必要信息已输入
        private void validate() {
            // 如果必要信息未输入,则抛出异常
        }
    }
}

如果你愿意,如果你更熟悉这种模式,你可以用构建器替换本质。最终结果将是相同的。

代码示例

下面是一个聚合根 (Order) 和具有本地标识的本地实体 (OrderItem) 的示例(代码未经测试,为了清楚起见,省略了一些方法实现):

Order.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class Order extends AggregateRoot<OrderId> {
    // 以泛型参数传递的ID类型

    private CustomerId customer;               // 客户ID
    private String shippingName;               // 运输名称
    private PostalAddress shippingAddress;    // 运输地址
    private String billingName;                // 账单名称
    private PostalAddress billingAddress;     // 账单地址
    private Money total;                      // 总金额
    private Long nextFreeItemId;               // 下一个免费的项目ID
    private List<OrderItem> items = new ArrayList<>(); // 订单项目列表

    // 构造函数,使用客户对象创建Order对象
    public Order(Customer customer) {
        super(OrderId.createRandomUnique());
        Objects.requireNonNull(customer); // 确保客户对象不为null
        // 私有设置器,确保传入的参数有效
        setCustomer(customer.getId());
        setShippingName(customer.getName());
        setShippingAddress(customer.getAddress());
        setBillingName(customer.getName());
        setBillingAddress(customer.getAddress());
        nextFreeItemId = 1L;
        recalculateTotals();
    }

    // 更改运输地址
    public void changeShippingAddress(String name, PostalAddress address) {
        setShippingName(name);
        setShippingAddress(address);
    }

    // 更改账单地址
    public void changeBillingAddress(String name, PostalAddress address) {
        setBillingName(name);
        setBillingAddress(address);
    }

    // 获取下一个免费的项目ID
    private Long getNextFreeItemId() {
        return nextFreeItemId++;
    }

    // 重新计算订单总额
    void recalculateTotals() {
        // 包级可见性,使该方法可以从OrderItem访问
        this.total = items.stream()
                          .map(OrderItem::getSubTotal)
                          .reduce(Money.ZERO, Money::add);
    }

    // 添加商品到订单
    public OrderItem addItem(Product product) {
        OrderItem item = new OrderItem(getNextFreeItemId(), this);
        item.setProductId(product.getId());
        item.setDescription(product.getName());
        this.items.add(item);
        return item;
    }

    // 省略了getter方法、私有设置器和其他方法
}

OrderItem.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class OrderItem extends LocalEntity<Long> {
    // 以泛型参数传递的ID类型

    private Order order;           // 订单
    private ProductId product;     // 商品ID
    private String description;    // 商品描述
    private int quantity;          // 数量
    private Money price;           // 单价
    private Money subTotal;        // 小计

    // 构造函数,使用ID和订单对象创建OrderItem对象
    public OrderItem(Long id, Order order) {
        super(id);                // 调用父类的构造函数
        this.order = Objects.requireNonNull(order); // 确保订单对象不为null
        this.quantity = 0;        // 初始化数量为0
        this.price = Money.ZERO;  // 初始化价格为0
        recalculateSubTotal();    // 重新计算小计
    }

    // 重新计算小计的私有方法
    private void recalculateSubTotal() {
        Money oldSubTotal = this.subTotal;
        this.subTotal = price.multiply(quantity); // 计算新的小计
        if (oldSubTotal != null && !oldSubTotal.equals(this.subTotal)) {
            this.order.recalculateTotals(); // 如果小计发生变化,调用订单的重计算方法
        }
    }

    // 设置数量的公共方法
    public void setQuantity(int quantity) {
        if (quantity < 0) {
            throw new IllegalArgumentException("Quantity cannot be negative"); // 确保数量不为负
        }
        this.quantity = quantity;
        recalculateSubTotal(); // 更新数量后重新计算小计
    }

    // 设置价格的公共方法
    public void setPrice(Money price) {
        Objects.requireNonNull(price, "price must not be null"); // 确保价格不为null
        this.price = price;
        recalculateSubTotal(); // 更新价格后重新计算小计
    }

    // 省略了getter方法和其他setter方法
}

域事件概述

在领域驱动设计(Domain-Driven Design, DDD)中,除了研究领域模型中的“事物”外,描述模型状态变化的过程同样重要。这就需要用到域事件

域事件定义域事件

是领域模型中发生的状态改变,它可能引起系统其他部分的关注。事件可以是粗粒度的,比如创建一个聚合根;也可以是细粒度的,比如修改聚合根的某个属性。

域事件特性

  • 不变性:事件一旦发生,就无法更改,代表已经发生的过去。
  • 时间戳:每个事件都有发生的时间标记。
  • 唯一标识:可能拥有唯一ID,以区分不同的事件。
  • 发布者:通常由聚合根或领域服务发布。

域事件的作用

一旦发布,域事件可以被一个或多个域事件侦听器接收。这些侦听器可能会触发额外的处理,甚至产生新的域事件。发布者和侦听器之间是解耦的,发布者发布事件后不知晓后续会发生什么,侦听器也不应影响发布者。

事件溯源(Event Sourcing)

事件溯源是一种设计模式,系统中的状态以一系列事件日志的形式存在。每个事件都改变系统状态,当前状态可以通过重播事件日志来计算。

事件分发事件的有效性取决于能否可靠地分发给侦听器。

在单体架构中,可以使用观察者模式;但在分布式系统中,则需要更复杂的机制。

通过消息队列分发

使用外部消息传递系统(如AMQP或JMS)来保证事件的发布-订阅和可靠交付。事件发布者将事件发送到消息队列,而侦听器订阅并接收这些事件。

结论域事件使系统

具备扩展性,可以在不修改现有代码的基础上,通过添加侦听器来引入新的业务逻辑。事件溯源为需要时提供了一种持久化模型,但并不适用于所有系统。简化事件发布过程,并在需要时添加事件是推荐的做法。 Domain event distribution through an MQ 在分布式系统中,事件日志分发模型以其快速性和易于实现的特点而受到青睐。它依赖于成熟的信息传递解决方案,但同时也带来了一些挑战。以下是该模型的优缺点概述:

优点

  1. 速度:事件日志分发模型能够快速响应事件,因为它直接将事件记录到日志中。
  2. 易实现:该模型不需要复杂的设置,可以快速部署到现有的系统中。
  3. 依赖成熟技术:它利用了久经考验的消息传递技术,减少了技术风险。

缺点

  1. 维护成本:需要设置和维护消息队列(MQ)解决方案,这可能涉及到额外的工作量和成本。
  2. 历史事件问题:当新的消费者订阅时,它们无法接收到之前发生的事件,这可能限制了系统的灵活性。

实现方式

  • 事件日志:当发布域事件时,事件将被追加记录到日志中。
  • 轮询机制:域事件侦听器会定期检查日志,寻找新事件。
  • 事件跟踪:侦听器还会跟踪已经处理的事件,避免重复处理,提高效率。 这种模型适用于需要快速响应事件且希望简化实现过程的场景。然而,开发者需要权衡其维护成本和对历史事件处理的需求。 Domain event distribution through an event log

模型优势与挑战

优势

  • 无需额外组件:模型本身自足,无需依赖外部组件。
  • 完整的事件历史记录:能够为新事件侦听器提供事件历史重播,增强了系统的可追溯性。

挑战

  • 实现复杂性:需要一定的工作量来实现模型。
  • 延迟问题:事件发布与侦听器接收之间存在最大轮询间隔的延迟。

最终一致性的概念

在分布式系统中,数据一致性是一个常见问题,特别是在多个数据存储参与同一逻辑事务时。

强一致性

  • 高级应用服务器:支持分布式事务,但配置维护复杂。
  • 适用场景:当业务需求绝对要求数据强一致性时,分布式事务是必要的。

最终一致性

  • 定义:系统数据最终达到一致状态,但过程中可能存在非同步状态。
  • 业务意义:从业务角度出发,强一致性并非总是必需的。
  • 设计思维:需要采用与强一致性不同的设计和思维方式。

弹性与可扩展性

  • 优势:系统设计为最终一致性可以提高系统的弹性和可扩展性。

域驱动设计中的事件

在域驱动设计系统中,使用域事件来实现最终一致性是一种有效的方法。

  • 事件订阅:系统或模块可以订阅其他系统或模块的域事件,以便在事件发生时进行自我更新。

Eventual consistency through domain events

在上面的示例中,对系统 A 所做的任何更改_最终_ 都将 通过域事件传播到系统 B、C 和 D。每个系统都将使用自己的本地事务来实际更新数据存储。
根据事件分发机制和系统的负载,传播时间可以从不到一秒(所有系统都运行在同一网络中,事件立即推送给订阅者)到几个小时甚至几天(某些系统处于离线状态,只是偶尔连接到网络以下载自上次签入以来发生的所有域事件)。

为了成功实现最终一致性,您必须有一个可靠的系统来分发域事件,即使某些订阅者在首次发布事件时当前不在线,这些事件也能正常工作。
您还需要围绕以下假设来设计业务逻辑和用户界面:任何数据片段都可能随时过时一段时间。您还需要制定关于数据不一致的时间长度的约束。
您可能会惊讶地发现,某些数据可能会在几天内保持一致,而其他数据必须在几秒钟甚至更短的时间内更新。

代码示例

下面是一个聚合根 (Order) 的示例,该聚合根在订单发货时发布域事件 (OrderShipped)。域侦听器 (InvoiceCreator) 将接收事件并在单独的事务中创建新发票。
假设存在一种机制,该机制在保存聚合根时发布所有已注册的事件(代码未经测试,并且为了清楚起见,省略了一些方法实现):

OrderShipped.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OrderShipped implements DomainEvent {
    // 订单ID
    private final OrderId order;
    // 事件发生的时间点
    private final Instant occurredOn;

    // 构造函数,使用订单ID和事件发生的时间创建OrderShipped事件
    public OrderShipped(OrderId order, Instant occurredOn) {
        this.order = order;
        this.occurredOn = occurredOn;
    }

    // 省略了getter方法
    // 通常,对于final字段,会提供对应的getter方法来访问它们的值
    public OrderId getOrder() {
        return order;
    }

    public Instant getOccurredOn() {
        return occurredOn;
    }
}

Order.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Order extends AggregateRoot<OrderId> {
    // 其他方法省略

    // 发货方法
    public void ship() {
        // 执行一些业务逻辑
        // ...

        // 注册OrderShipped事件,表示订单已发货
        registerEvent(new OrderShipped(this.getId(), Instant.now()));
    }
}

InvoiceCreator.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class InvoiceCreator {
    final OrderRepository orderRepository; // 订单仓库
    final InvoiceRepository invoiceRepository; // 发票仓库

    // 构造函数省略

    // 使用@DomainEventListener注解标记此方法为领域事件监听器
    // 使用@Transactional注解确保此方法在事务的上下文中执行
    @DomainEventListener
    @Transactional
    public void onOrderShipped(OrderShipped event) {
        var order = orderRepository.find(event.getOrderId()); // 根据事件中的订单ID查找订单
        var invoice = invoiceFactory.createInvoiceFor(order); // 使用订单创建发票
        invoiceRepository.save(invoice); // 保存发票到发票仓库
    }
}

领域驱动设计中的可移动与静态对象

可移动对象在领域驱动设计(DDD)中,可移动对象指的是那些可以在应用程序的不同部分之间传递的对象。这些对象可以有多个实例,并且它们是领域模型中的关键部分。可移动对象的类型包括:

  • 值对象:表示没有独立存在意义的属性集合。- 实体:具有唯一标识和生命周期的对象。- 域事件:表示领域中重要事件的对象。

静态对象与可移动对象相对的是静态对象,它们通常是单例或池化资源,位于固定位置,由应用程序的其他部分调用。静态对象的类型包括:

  • 存储库:提供对领域对象集合的访问和管理。- 领域服务:执行不自然属于任何实体或值对象的领域逻辑。- 工厂:负责创建复杂的聚合或对象。

对象之间的关系可移动对象可以引用其他可移动对象,但它们永远不能引用静态对象。如果需要与静态对象交互,应将静态对象作为参数传递给方法。这种设计使得可移动对象更加自包含和可移植。

其他域对象在DDD实践中,我们可能会遇到一些不符合值对象、实体或域事件模式的类。这些情况通常包括:

  • 来自外部系统的信息,具有全局ID且不可变。- 用于描述其他实体数据的标准类型,具有全局ID,但对应用程序而言是不可变的。- 框架或基础设施级别的实体,如审计条目或域事件记录。

处理方法为了处理这些特殊的域对象,可以采用基类和接口的层次结构,从DomainObject开始。DomainObject是与领域模型相关的任何可移动对象。如果对象不符合纯粹的值对象或实体的定义,可以将其声明为DomainObject,并在JavaDocs中说明其作用和存在的理由。

结构化内容以下是对上述内容的结构化梳理:

  • 可移动对象:领域模型中可以传递的对象,包括值对象、实体和域事件。- 静态对象:应用程序中的单例或池化资源,如存储库、领域服务和工厂。- 对象关系:可移动对象可以相互引用,但不能引用静态对象。- 其他域对象:不符合标准模式的类,如外部系统信息、标准类型和框架实体。- 处理策略:使用DomainObject基类和接口层次结构来组织这些对象。 通过这种方式,我们可以清晰地理解和应用领域驱动设计中的不同对象类型及其关系。 Hierarchy of base classes and interfaces for different domain objects 在领域驱动设计(Domain-Driven Design, DDD)中,接口和类的结构是至关重要的,它们定义了领域模型的骨架。以下是对领域模型中接口和类的一些基本介绍和结构化描述:

领域对象接口- DomainObject: 所有领域对象的顶级标记接口,用于标识领域对象。- DomainEvent: 所有领域事件的接口,通常包含事件的元数据,如时间戳,也可以是标记接口。- ValueObject: 所有值对象的标记接口,要求实现不可变性以及equals()hashCode()方法。

特定领域对象接口- IdentifiableDomainObject: 可在某些上下文中唯一标识的领域对象接口,通常设计为泛型接口,以容纳不同的ID类型。- StandardType: 标准类型的标记接口,用于标识领域中的通用数据类型。

实体和聚合根- Entity: 实体的抽象基类,通常包含ID字段,并实现equals()hashCode()方法。根据使用的持久性框架,可能包含乐观锁定信息。- LocalEntity: 本地实体的抽象基类,如果使用本地标识,则包含管理该标识的代码。- AggregateRoot: 聚合根的抽象基类,包含生成新本地ID的代码,调度领域事件,以及可能包含乐观锁定和审计信息。

边界上下文示例在提供的代码示例中,我们有两个边界上下文:Identity ManagementEmployee Management。每个边界上下文都有其特定的模型和语言,但它们之间可能存在交互和集成需求。

边界上下文关系- 边界上下文之间的关系可以通过多种方式定义,包括合作伙伴关系、共享内核、客户-供应商关系等。

集成模式- 确定边界上下文如何集成是设计过程中的关键部分,包括定义上下文边界、技术通信方式、领域模型之间的映射,以及如何防止不希望的变更。

结构化思考通过使用接口和抽象类,我们可以构建一个灵活且可扩展的领域模型。同时,识别和定义边界上下文及其关系,有助于我们更好地理解系统的复杂性,并为未来的扩展和维护打下基础。

注意事项- 接口应设计为尽可能通用,以支持不同上下文的需求。- 值对象的不可变性和正确的equals()hashCode()实现对于领域模型的一致性和可靠性至关重要。- 聚合根作为交易的边界,包含了业务逻辑和领域事件的调度,是领域模型中的关键角色。

通过上述结构化的内容,我们可以更清晰地理解领域模型的构建和边界上下文的管理。 The <em>identity management</em> and <em>employee management</em> contexts

员工管理上下文需要来自身份管理上下文的部分(但不是全部)有关用户的信息。有一个 REST 端点用于此,数据被序列化为 JSON。

在身份管理上下文中, 用户 表示如下:

User.java(身份管理)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class User extends AggregateRoot<UserId> {
    private String userName;               // 用户名
    private String firstName;               // 名字
    private String lastName;                // 姓氏
    private Instant validFrom;              // 账户生效起始时间
    private Instant validTo;                // 账户生效结束时间
    private boolean disabled;               // 是否禁用账户
    private Instant nextPasswordChange;     // 下次密码更改时间
    private List<Password> passwordHistory; // 密码历史记录列表

    // 构造函数、业务逻辑、getter和setter方法省略
    // 这些方法通常包括对类属性的验证和设置逻辑
}

我们只需要员工管理上下文中的用户 ID 和名称。ID 将唯一标识用户,但名称会显示在 UI 中。我们显然不能更改任何用户信息,因此用户信息是不可变的。代码如下所示:

User.java(员工管理)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class User implements IdentifiableDomainObject<UserId> {
    private final UserId userId;           // 用户ID
    private final String firstName;        // 名字
    private final String lastName;         // 姓氏

    // 使用@JsonCreator注解,允许从JSON直接反序列化到这个类的实例
    public User(String userId, String firstName, String lastName) {
        // 填充字段,将传入的userId字符串参数转换为UserId值对象实例
        this.userId = new UserId(userId); // 假设UserId类有一个接受字符串参数的构造函数
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getFullName() {
        return String.format("%s %s", firstName, lastName); // 返回全名
    }

    // 其他getter方法省略

    // equals方法,仅检查userId
    @Override
    public boolean equals(Object o) {
        // 实现userId的比较逻辑
    }

    // hashCode方法,基于userId计算
    @Override
    public int hashCode() {
        // 基于userId的hashCode计算
    }
}

 存储 库

现在,我们已经涵盖了域模型的所有可移动对象,现在是时候转向静态对象了。第一个静态对象是 存储库。存储库是聚合的持久性容器。保存到存储库中的任何聚合都可以在以后从那里检索,即使在系统重新启动后也是如此。

至少,存储库应具有以下功能:

  • 能够在某种数据存储中将聚合完整保存
  • 能够根据聚合的 ID 检索整个聚合
  • 能够根据聚合的 ID 完全删除聚合

在大多数情况下,存储库还需要更高级的查询方法才能真正可用。

在实践中,存储库是连接到外部数据存储(如关系数据库、NoSQL 数据库、目录服务甚至文件系统)的域感知接口。
即使实际存储隐藏在存储库后面,其存储语义通常会泄露并对存储库的外观施加限制。因此,存储库通常是 面向 集合的,或者 是面向持久性的

面向集合的存储库旨在模拟内存中的对象集合。将聚合添加到集合中后,对其所做的任何更改都将自动保留,直到从存储库中删除聚合。
换句话说,面向集合的存储库将具有 add() 和 remove() 等 方法,但没有用于保存的方法。

另一方面,面向持久性的存储库不会尝试模仿集合。相反,它充当外部持久性解决方案的外观,并包含 insert()、 update() 和 delete() 等 方法。对聚合所做的任何更改都必须通过调用 update() 方法显式保存到存储库中。

在项目开始时正确获取存储库类型非常重要,因为它们在语义上完全不同。通常,面向持久性的存储库更易于实现,并且与大多数现有的持久性框架配合使用。
面向集合的存储库更难实现,除非底层持久性框架开箱即用地支持它。

代码示例

此示例演示了面向集合的存储库和面向持久性的存储库之间的差异。

面向集合的存储库

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public interface OrderRepository {
    // 根据ID检索订单,返回Optional包装的订单对象
    Optional<Order> get(OrderId id);
    // 检查存储库是否包含指定ID的订单
    boolean contains(OrderID id);
    // 向存储库添加订单
    void add(Order order);
    // 从存储库删除订单
    void remove(Order order);
    // 根据搜索条件分页搜索订单
    Page<Order> search(OrderSpecification specification, int offset, int size);
}

面向持久性的仓库

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public interface OrderRepository {
    // 根据ID查找订单,返回Optional包装的订单对象
    Optional<Order> findById(OrderId id);
    // 检查存储库是否存在指定ID的订单
    boolean exists(OrderId id);
    // 保存订单,这可能涉及创建或更新操作
    Order save(Order order);
    // 从存储库删除订单
    void delete(Order order);
    // 根据搜索条件分页查找所有订单
    Page<Order> findAll(OrderSpecification specification, int offset, int size);
}

在软件开发中,命令查询责任分离(CQRS)是一种设计模式,用于解决存储库在处理大型数据集时的性能问题。以下是对CQRS概念的详细解释:

存储库的性能问题存储库负责保存和检索聚合数据。当聚合数据量较大时,构建对象图可能会非常耗时,这直接影响到用户体验。

问题场景

  1. 小型列表展示:在需要展示聚合列表时,如果只关心聚合的几个属性,却要加载整个对象图,这不仅浪费资源,也会导致响应速度变慢。
  2. 数据合并展示:当需要将多个聚合的数据合并后展示时,性能问题会变得更加严重。

性能影响虽然对于小数据集

性能损失可能是可接受的,但随着数据量的增长,性能问题可能变得不可接受。

CQRS解决方案命令查询责任分离(CQRS)

提供了一种解决上述问题的方法。CQRS将系统的职责分为两部分:命令端(负责处理业务逻辑和数据更新)和查询端(负责数据的读取和展示)。

命令端- 处理业务逻辑。

  • 更新数据状态。

查询端- 优化数据读取。

  • 提供快速响应的数据查询服务。 通过分离命令和查询的责任,CQRS能够提高系统的性能,尤其是在处理大量数据时。它允许系统根据实际需求,优化数据的写入和读取过程。

结论CQRS模式通过分离数据的写入和读取,有效地解决了存储库在处理大型数据集时的性能瓶颈。这不仅提升了系统的响应速度,也改善了用户体验。

Command Query Responsibility Segregation

CQRS 是一种模式,在该模式中,您将写入(命令)和读取(查询)操作完全分离。深入细节超出了本文的范围,但就 DDD 而言,您可以应用如下模式:

  • 更改系统状态的所有用户操作都以正常方式通过存储库。
  • 所有查询都绕过存储库,直接进入底层数据库,只获取所需的数据,而不获取其他任何数据。
  • 如果需要,您甚至可以为用户界面中的每个视图设计单独的查询对象。
  • 查询对象返回的数据传输对象 (DTO) 必须包含聚合 ID,以便在需要对聚合进行更改时可以从存储库中检索正确的聚合。

在许多项目中,您最终可能会在某些视图中使用 CQRS,而在其他视图中直接使用存储库查询。

域名服务

我们已经提到,值对象和实体都可以(并且应该)包含业务逻辑。但是,在某些情况下,一段逻辑根本不适合一个特定的值对象或一个特定的实体。
将业务逻辑放在错误的位置是一个坏主意,因此我们需要另一种解决方案。输入第二个静态对象: 

域服务

域服务具有以下特征:

  •  他们是无国籍的
  • 他们具有高度的凝聚力(这意味着他们专门做一件事,而且只做一件事)
  • 它们包含的业务逻辑自然不适合其他地方
  • 它们可以与其他域服务交互,并在某种程度上与存储库交互
  • 他们可以发布域事件

在最简单的形式中,域服务可以是一个带有静态方法的实用程序类。更高级的域服务可以作为与其他域服务和存储库的单例实现。

不应将域服务与 应用程序服务混淆。我们将在本系列的下一篇文章中更深入地了解应用程序服务。尽管如此,应用程序服务仍然充当着隔离域模型与世界其他地方之间的中间人。
应用程序服务负责处理事务、确保系统安全、查找正确的聚合、调用方法以及保存对数据库的更改。应用程序服务本身不包含任何业务逻辑。

您可以总结应用程序服务和域服务之间的区别,如下所示:域服务只负责做出业务决策,而应用程序服务只负责编排(查找正确的对象并按正确的顺序调用正确的方法)。
因此,域服务通常不应调用任何改变数据库状态的存储库方法 - 这是应用程序服务的责任。

代码示例

在这个第一个示例中,我们将创建一个域服务,用于检查是否允许进行某种货币交易。实现大大简化,但可以清楚地根据预定义的业务规则做出业务决策。

在这种情况下,由于业务逻辑非常简单,因此您可能能够将其直接 添加到 Account 类中。但是,一旦更高级的业务规则发挥作用,就应该将决策转移到自己的类别中(特别是如果规则随着时间的推移而变化或依赖于某些外部配置)。
此逻辑可能属于域服务的另一个明显迹象是,它涉及多个聚合(两个帐户)。

TransactionValidator.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TransactionValidator {
    // 定义一个阈值,用于检查交易金额是否过大
    private final Money someThreshold;

    // 构造函数,设置交易金额的阈值
    public TransactionValidator(Money someThreshold) {
        this.someThreshold = someThreshold;
    }

    // 验证交易的方法
    public boolean isValid(Money amount, Account from, Account to) {
        // 检查交易金额的货币类型是否与'from'账户的货币类型相匹配
        if (!from.getCurrency().equals(amount.getCurrency())) {
            return false;
        }

        // 检查交易金额的货币类型是否与'to'账户的货币类型相匹配
        if (!to.getCurrency().equals(amount.getCurrency())) {
            return false;
        }

        // 检查'from'账户的余额是否足够
        if (from.getBalance().isLessThan(amount)) {
            return false;
        }

        // 检查交易金额是否大于设置的阈值
        if (amount.isGreaterThan(this.someThreshold)) {
            return false;
        }

        // 如果所有检查都通过,则交易有效
        return true;
    }
}

在第二个示例中,我们探讨了一种具有独特功能的领域服务。这种服务的接口是领域模型的一部分,但其实现却不属于领域模型。这种情况通常发生在需要利用外部信息来做出业务决策时,而我们对这些信息的来源并不关心。例如,CurrencyExchangeService.java 就是这类服务的一个典型例子。

领域服务的特点

  1. 接口集成:领域服务的接口与领域模型紧密集成,确业务逻辑的一致性。2. 实现独立:尽管接口是领域模型的一部分,但实现细节可以独立于领域模型之外。3. 外部信息利用:领域服务可以访问外部数据源,以支持业务决策过程。4. 信息来源无关性:对外部信息的来源不敏感,只关注信息本身对业务决策的影响。

领域服务实现示例以 CurrencyExchangeService.java 为例,该服务可能包含以下关键功能:- 货币兑换:提供不同货币之间的兑换功能。- 汇率更新:定期从外部数据源获取最新的汇率信息。- 业务决策支持:利用汇率信息辅助业务决策,如定价策略等。

结构化实现领域服务的实现应遵循以下结构:- 接口定义:明确服务提供的业务功能。- 数据访问:定义访问外部数据源的方法。- 业务逻辑:实现具体的业务逻辑处理。- 异常处理:妥善处理可能出现的异常情况。

总结领域服务作为一种特殊的组件,它允许领域模型与外部世界进行交互,同时保持领域模型的清晰和专注。通过合理设计领域服务,可以有效地支持复杂的业务需求,提高系统的灵活性和可维护性。

1
2
3
4
public interface CurrencyExchangeService {
    // 将给定的金额转换为目标货币
    Money convertToCurrency(Money currentAmount, Currency desiredCurrency);
}

例如,当连接域模型时,使用依赖项注入框架,然后可以注入此接口的正确实现。
您可以有一个调用本地缓存,另一个调用远程 Web 服务,第三个仅用于测试,依此类推。

工厂

我们将看起来像的最后一个静态对象是 工厂。顾名思义,工厂负责创造新的聚集体。但是,这并不意味着您需要为每个聚合创建一个新工厂。
在大多数情况下,聚合根的构造函数足以设置聚合,使其处于一致状态。在以下情况下,您通常需要一个单独的工厂:

  • 聚合的创建涉及业务逻辑
  • 根据输入数据的不同,聚合的结构和内容可能会有很大不同
  • 输入数据如此之大,以至于需要构建器模式(或类似的东西)
  • 工厂正在从一个边界上下文转换到另一个边界上下文

工厂可以是聚合根类上的静态工厂方法,也可以是单独的工厂类。工厂可以与其他工厂、仓库和域服务交互,但绝不能更改数据库的状态(因此不能保存或删除)。

代码示例

在此示例中,我们将查看在两个边界上下文之间进行转换的工厂。在装运上下文中, 客户 不再被称为客户,而是称为 装运收件人。客户 ID 仍会存储,以便我们以后可以在需要时将这两个概念关联在一起。

ShipmentRecipientFactory.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class ShipmentRecipientFactory {
    private final PostOfficeRepository postOfficeRepository; // 邮政局存储库
    private final StreetAddressRepository streetAddressRepository; // 街道地址存储库

    // 省略了初始化构造函数

    /**
     * 根据客户信息创建发货收件人。
     * 
     * @param customer 客户对象,包含邮寄信息。
     * @return 创建的发货收件人对象。
     */
    ShipmentRecipient createShipmentRecipient(Customer customer) {
        var postOffice = postOfficeRepository.findByPostalCode(customer.postalCode()); // 根据邮编查找邮政局
        var streetAddress = streetAddressRepository.findByPostOfficeAndName(postOffice, customer.streetAddress()); // 根据邮政局和名称查找街道地址
        var recipient = new ShipmentRecipient(customer.fullName(), streetAddress); // 使用客户全名和街道地址创建收件人
        recipient.associateWithCustomer(customer.id()); // 将收件人与客户ID关联
        return recipient; // 返回创建的收件人对象
    }
}

模块化在领域驱动设计中的重要性

在领域驱动设计(DDD)中,模块的概念是至关重要的。在Java中,模块对应于包,在C#中则对应于命名空间。一个有界上下文通常包含多个模块,而正确地组织这些模块对于维护一个清晰和可维护的代码库至关重要。

模块的设计原则

模块内的类应该是相互关联的,这种关联是基于业务逻辑而非技术实现。例如,不应该将所有的存储库类放在一个模块中,所有的实体类放在另一个模块中。相反,所有与特定聚合或业务流程相关的类应该被组织在同一个模块中。这样做的好处是,可以更轻松地导航代码,因为那些在业务逻辑中一起工作并相互依赖的类,在代码中也应该放在一起。

错误的模块化示例

foo.bar.domain.model.services
- AuthenticationService
- PasswordEncoder

foo.bar.domain.model.repositories
- UserRepository
- RoleRepository

foo.bar.domain.model.entities
- User
- Role

foo.bar.domain.model.valueobjects
- UserId
- RoleId
- Username

问题:

  • AuthenticationServicePasswordEncoder 被放在了 services 包中,这是合适的,但 PasswordEncoder 也与用户模块紧密相关,可能更适合放在 user 子包中。
  • UserRole 实体以及它们的存储库被放在了 entities 包中,但没有进一步的子模块化。
  • UserIdRoleIdUsername 被归类为值对象,并放在了 valueobjects 包中,但它们实际上可能与特定的实体(如 UserRole)更相关。

正确的模块化示例

foo.bar.domain.model.services
- AuthenticationService
- PasswordEncoder

foo.bar.domain.model.repositories
- UserRepository
- RoleRepository

foo.bar.domain.model.entities
- User
- Role

foo.bar.domain.model.valueobjects
- UserId
- RoleId
- Username

改进点:

  • AuthenticationService 单独放在 authentication 包中,因为它处理认证逻辑,可能与用户和角色都有交互。
  • User 相关类(包括 User, UserRepository, UserId, Username)和 PasswordEncoder 放在 user 包中,因为密码编码是与用户账户紧密相关的服务。
  • 同样,将 Role 相关类(包括 Role, RoleRepository, RoleId)放在 role 包中,以保持与角色管理相关的所有逻辑和数据模型的接近性。

最佳实践:

  • 逻辑分组:将逻辑上相关的类放在同一个包中。
  • 单一职责:每个包应该有一个单一的职责,并封装该职责所需的所有类。
  • 解耦:尽量避免包之间的循环依赖,保持包的独立性。
  • 可发现性:通过包结构,其他开发者应该能够容易地找到相关类和接口。

正确的模块化有助于项目的结构清晰,使得开发和维护更加高效。

为什么战术领域驱动设计很重要?

战术领域驱动设计不仅帮助解决了我参与的一个项目中的数据不一致问题,也证明了即使项目不是完全基于领域驱动的,也可以从中获益。值对象是DDD中我最喜欢的构建块之一,因为它通过提供上下文使代码更易于阅读和理解,并且其不变性简化了复杂性。 将数据模型组织到聚合和存储库中,即使数据模型本身没有业务逻辑,也有助于保持数据一致性,避免了更新同一实体时出现的副作用和乐观锁定异常。 然而,领域事件的使用需要谨慎,过度依赖事件会使代码难以理解和调试,因为事件触发的操作可能不明显。

领域驱动设计和六边形架构

在本系列的第三部分,我们将探讨六边形架构以及它如何与领域驱动设计相结合,以及如何以可控和可扩展的方式实现外部世界与领域模型的交互。阅读第三部分

战略性领域驱动设计

如果你对战略性领域驱动设计感兴趣,可以查看我们之前的文章

开始使用Vaadin

如果你是Vaadin的新用户,可以在这里配置并下载项目,开始创建你自己的Vaadin应用程序。