战术领域驱动设计
值对象
实体
骨料
领域事件
可移动物体和静态物体
其他领域对象
存储库
域名服务
工厂
模块
为什么战术领域驱动设计如此重要?
AWS AI 直播!
本文最初以教程的形式发布在 Vaadin 网站上。由于我今后会将 DDD 相关的文章迁移到这个平台,为了保持时间线的连贯性,我也将原有的教程系列复制到这里。
本文将介绍战术领域驱动设计(Tactical DDD)。战术领域驱动设计是一套设计模式和构建模块,可用于设计领域驱动系统。即使对于非领域驱动的项目,您也可以从使用一些战术领域驱动设计模式中获益。
与战略性的领域驱动设计相比,战术设计更注重实践,更贴近实际代码。战略设计关注的是抽象的整体,而战术设计关注的是类和模块。战术设计的目的是将领域模型细化到可以转化为可运行代码的阶段。
设计是一个迭代过程,因此将战略设计与战术设计相结合是合理的。首先进行战略设计,然后进行战术设计。领域模型设计中最重要的发现和突破很可能发生在战术设计阶段,而这反过来又会影响战略设计,因此需要重复这个过程。
再次强调,本文内容主要基于Eric Evans 的《领域驱动设计:软件核心复杂性的应对》(Domain-Driven Design: Tackling Complexity in the Heart of Software )和Vaughn Vernon的《领域驱动设计实现》(Implementing Domain-Driven Design)两本书,我强烈建议您阅读这两本书。与上一篇文章一样,我尽可能用自己的语言进行解释,并在适当的地方融入我自己的想法、思考和经验。
经过这段简短的介绍,现在是时候拿出战术性 DDD 工具箱,看看里面有什么了。
值对象
战术领域驱动设计 (DDD) 中最重要的概念之一是价值对象。这也是我在非 DDD 项目中使用最多的 DDD 构建模块,我希望读完本文后,你也会这样做。
值对象是指其值具有重要意义的对象。这意味着两个值完全相同的值对象可以被视为同一个值对象,因此可以互换。正因如此,值对象应该始终是不可变的。与其改变值对象的状态,不如用一个新的实例替换它。对于复杂的值对象,可以考虑使用构建器模式或本质模式。
值对象不仅是数据的容器,它们还可以包含业务逻辑。值对象的不可变性使得业务操作既线程安全又无副作用。这正是我如此喜欢值对象的原因之一,也是为什么你应该尽可能多地将领域概念建模为值对象的原因。此外,尽量使值对象保持简洁和一致——这有利于维护和重用。
创建值对象的一个好方法是,将所有具有业务意义的单值属性封装成值对象。例如:
- 与其使用
BigDecimal货币值,不如使用Money包装货币值的值对象BigDecimal。如果您要处理多种货币,您可能Currency还需要创建一个值对象,并使该Money对象包装货币BigDecimal对Currency。 - 不要使用字符串表示电话号码和电子邮件地址,而应使用
PhoneNumber包装EmailAddress字符串的值对象。
使用这样的值对象有几个优点。首先,它们为值提供了上下文。您无需知道某个字符串包含的是电话号码、电子邮件地址、名字还是邮政编码,也无需知道它BigDecimal是货币值、百分比还是其他完全不同的类型。类型本身会立即告诉您正在处理什么。
其次,您可以将所有可对特定类型值执行的业务操作添加到值对象本身。例如,一个Money对象可以包含用于加减金额或计算百分比的操作,同时确保底层数据的精度BigDecimal始终正确,并且Money操作中涉及的所有对象都使用相同的货币。
第三,您可以确保值对象始终包含有效值。例如,您可以在值对象的构造函数中验证电子邮件地址输入字符串EmailAddress。
代码示例
Java 中的值Money对象可能如下所示(代码未经测试,为了清晰起见,省略了一些方法实现):
public class Money implements Serializable, Comparable<Money> {
private final BigDecimal amount;
private final Currency currency; // Currency is an enum or another value object
public Money(BigDecimal amount, Currency currency) {
this.currency = Objects.requireNonNull(currency);
this.amount = Objects.requireNonNull(amount).setScale(currency.getScale(), currency.getRoundingMode());
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money subtract(Money other) {
assertSameCurrency(other);
return new Money(amount.subtract(other.amount), currency);
}
private void assertSameCurrency(Money other) {
if (!other.currency.equals(this.currency)) {
throw new IllegalArgumentException("Money objects must have the same currency");
}
}
public boolean equals(Object o) {
// Check that the currency and amount are the same
}
public int hashCode() {
// Calculate hash code based on currency and amount
}
public int compareTo(Money other) {
// Compare based on currency and amount
}
}
Java 中的值StreetAddress对象及其对应的构建器可能如下所示(代码未经测试,为了清晰起见,省略了一些方法实现):
public class StreetAddress implements Serializable, Comparable<StreetAddress> {
private final String streetAddress;
private final PostalCode postalCode; // PostalCode is another value object
private final String city;
private final Country country; // Country is an enum
public StreetAddress(String streetAddress, PostalCode postalCode, String city, Country country) {
// Verify that required parameters are not null
// Assign the parameter values to their corresponding fields
}
// Getters and possible business logic methods omitted
public boolean equals(Object o) {
// Check that the fields are equal
}
public int hashCode() {
// Calculate hash code based on all fields
}
public int compareTo(StreetAddress other) {
// Compare however you want
}
public static class Builder {
private String streetAddress;
private PostalCode postalCode;
private String city;
private Country country;
public Builder() { // For creating new StreetAddresses
}
public Builder(StreetAddress original) { // For "modifying" existing StreetAddresses
streetAddress = original.streetAddress;
postalCode = original.postalCode;
city = original.city;
country = original.country;
}
public Builder withStreetAddress(String streetAddress) {
this.streetAddress = streetAddress;
return this;
}
// The rest of the 'with...' methods omitted
public StreetAddress build() {
return new StreetAddress(streetAddress, postalCode, city, country);
}
}
}
实体
战术领域设计中的第二个重要概念,也是与值对象密切相关的概念,是实体。实体是指其身份至关重要的对象。为了确定实体的身份,每个实体都有一个唯一的ID,该ID在实体创建时分配,并在实体的整个生命周期内保持不变。
即使其他所有属性都不同,相同类型且具有相同 ID 的两个实体也被视为同一实体。同样,相同类型且具有相同属性但 ID 不同的两个实体则被视为不同的实体,就像两个同名的人不被视为同一人一样。
与值对象不同,实体是可变的。但这并不意味着你应该为每个属性都创建 setter 方法。尝试将所有状态更改操作建模为与业务操作相对应的动词。setter 方法只会告诉你正在更改哪个属性,但不会告诉你原因。例如:假设你有一个EmploymentContract实体,它有一个endDate属性。雇佣合同的终止可能是因为合同本身就是临时性的,也可能是因为公司内部从一个分支机构调到另一个分支机构,或者是因为员工辞职,又或者是因为雇主解雇了员工。在所有这些情况下,状态endDate都会被更改,但原因却截然不同。此外,根据合同终止的原因,可能还需要采取其他操作。一个terminateContract(reason, finalDay)方法本身就能提供比方法本身更多的信息setEndDate(finalDay)。
尽管如此,setter 在领域驱动设计 (DDD) 中仍然占有一席之地。在上面的例子中,可以有一个私有setEndDate(..)方法,在设置结束日期之前确保它晚于开始日期。这个 setter 会被其他实体方法使用,但不会暴露给外部。对于主数据和引用数据,以及描述实体但不改变其业务状态的属性,使用 setter 比尝试将操作调整为动词更有意义。一个名为 `setter` 的方法setDescription(..)无疑比 `set` 更易读describe(..)。
我再举一个例子来说明这一点。假设你有一个Person实体,它代表一个人。这个人有姓名firstName和地址lastName属性。如果这只是一个简单的地址簿,你可以允许用户根据需要更改这些信息,并且可以使用相应的设置器setFirstName(..)。setLastName(..)但是,如果你正在构建一个官方的公民登记册,更改姓名就复杂得多。你最终可能会得到类似这样的代码changeName(firstName, lastName, reason, effectiveAsOfDate)。再次强调,上下文至关重要。
关于 Getter 的说明
Getter 方法作为 JavaBean 规范的一部分引入到 Java 中。该规范在 Java 的第一个版本中并不存在,因此您可以在标准 Java API 中找到一些不符合该规范的方法(例如,String.length()与 `get()` 方法相对String.getLength())。
就我个人而言,我希望 Java 能支持真正的属性。即使底层可能使用了 getter 和 setter 方法,我还是希望能够像访问普通字段一样访问属性值。mycontact.phoneNumber目前 Java 还无法做到这一点,但我们可以通过get省略 getter 方法中的后缀来达到类似的效果。我认为,这样做可以使代码更加流畅,尤其是在需要深入对象层次结构来获取某些信息时mycontact.address().streetNumber()。
然而,移除 getter 方法也有其弊端,那就是工具支持。所有 Java IDE 和许多库都依赖于 JavaBean 标准,这意味着你最终可能需要手动编写原本可以自动生成的代码,并添加一些原本可以通过遵循约定来避免的注解。
实体还是值对象?
判断某个事物应该建模为值对象还是实体并非易事。同一个现实世界的概念,在一种情况下可以建模为实体,而在另一种情况下则可以建模为值对象。我们以街道地址为例。
如果你正在构建一个发票系统,街道地址只是发票上需要打印的内容。只要发票上的文字正确,使用哪个对象实例都无关紧要。在这种情况下,街道地址是一个值对象。
如果你正在为公共事业公司构建系统,你需要确切地知道哪条燃气管道或电力管道接入了哪个公寓。在这种情况下,街道地址是一个实体,它甚至可以被细分为更小的实体,例如建筑物或公寓。
值对象更易于使用,因为它们不可变且体积小。因此,您应该尽量设计一个实体数量少、值对象数量多的架构。
代码示例
Java 中的实体Person可能如下所示(代码未经测试,为了清晰起见,省略了一些方法实现):
public class Person {
private final PersonId personId;
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);
}
// Other getters omitted
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (o == null || o.getClass() != getClass()) {
return false;
}
return personId.equals(((Person) o).personId);
}
public int hashCode() {
return personId.hashCode();
}
}
这个例子中有几点需要注意:
- 值对象
PersonId用于实体 ID。我们也可以使用 UUID、字符串或长整型,但值对象可以立即告诉我们这是一个用于标识特定实体的 IDPerson。 - 除了实体 ID 之外,该实体还使用了许多其他值对象:
PersonName,LocalDate(是的,即使它是标准 Java API 的一部分,它也是一个值对象)StreetAddress,EmailAddress和PhoneNumber。 - 我们没有使用 setter 来更改名称,而是使用业务方法,该方法还会将更改内容以及更改名称的原因存储在事件日志中。
- 有一个 getter 函数可以检索名称更改历史记录。
equals仅hashCode检查实体 ID。
领域驱动设计和增删改查
现在是时候讨论领域驱动设计(DDD)和增删改查(CRUD)的问题了。CRUD 代表创建(Create ) 、检索(Retrieve)、更新(Update)和删除(Delete),也是企业应用程序中常见的用户界面模式:
- 主视图由一个网格组成,可能带有筛选和排序功能,您可以在其中查找实体(检索)。
- 在主视图中,有一个用于创建新实体的按钮。点击该按钮会弹出一个空白表单,提交表单后,新实体就会出现在网格中(创建)。
- 在主视图中,有一个用于编辑所选实体的按钮。点击该按钮会弹出一个包含实体数据的表单。提交表单后,实体将使用新信息进行更新(更新)。
- 在主视图中,有一个用于删除选定实体的按钮。单击该按钮会将实体从网格中删除(删除)。
这种模式固然有其用武之地,但在领域驱动型应用中,它应该是例外而非常态。原因如下:CRUD 应用仅涉及数据的结构化、显示和编辑,通常并不支持底层业务流程。当用户在系统中输入、更改或删除数据时,其背后必然存在业务原因。或许,这种更改是更大业务流程的一部分?在 CRUD 系统中,更改背后的原因被忽略了,业务流程完全依赖于用户的理解。
真正的领域驱动用户界面将基于通用语言(以及领域模型)本身所包含的操作,业务流程则内置于系统中,而非用户脑海中。这反过来又会带来更健壮但灵活性可能不如纯粹的CRUD应用程序的系统。我将用一个略显夸张的例子来说明这种差异:
A公司采用领域驱动的员工管理系统,而B公司采用CRUD驱动的方法。两家公司都有一名员工离职。接下来会发生什么:
- A公司:
- 经理在系统中查找该员工的记录。
- 经理选择“终止雇佣合同”操作。
- 系统会询问终止日期和原因。
- 经理输入所需信息并点击“终止合同”。
- 系统会自动更新员工记录,撤销员工的用户凭证和电子办公钥匙,并向工资系统发送通知。
- B公司:
- 经理在系统中查找该员工的记录。
- 经理勾选“合同终止”复选框,输入终止日期,然后点击“保存”。
- 管理员登录用户管理系统,查找用户帐户,勾选“禁用”复选框,然后单击“保存”。
- 经理登录办公室钥匙管理系统,查找用户的钥匙,勾选“禁用”复选框,然后点击“保存”。
- 经理向薪资部门发送电子邮件,通知他们该员工已离职。
关键要点如下:并非所有应用程序都适合领域驱动设计,领域驱动应用程序不仅具有领域驱动的后端,还具有领域驱动的用户界面。
骨料
现在我们了解了实体和值对象是什么,接下来我们将学习下一个重要概念:聚合。聚合是具有某些特定特征的实体和值对象的集合:
- 聚合体作为一个整体被创建、检索和存储。
- 总体始终处于一致状态。
- 该聚合体由称为聚合根的实体拥有,其 ID 用于标识聚合体本身。
此外,关于集合体还有两个重要的限制:
- 聚合体只能通过其根对象从外部引用。聚合体外部的对象不能引用聚合体内部的任何其他实体。
- 聚合根负责在聚合内部强制执行业务不变性,确保聚合始终处于一致状态。
这意味着,每当您设计一个实体时,都必须决定要创建哪种类型的实体:该实体是作为聚合根,还是我所说的存在于聚合内部并受聚合根监管的本地实体?由于本地实体不能从聚合外部引用,因此只需确保它们的 ID 在聚合内部唯一(它们具有本地标识),而聚合根必须具有全局唯一的 ID(它们具有全局标识)。然而,这种语义差异的重要性取决于您选择如何存储聚合。在关系数据库中,对所有实体使用相同的主键生成机制是最合理的。另一方面,如果将整个聚合保存为文档数据库中的单个文档,则为本地实体使用真正的本地 ID 更为合理。
那么,如何判断一个实体是否是聚合根呢?首先,两个实体之间存在父子(或主从)关系并不意味着父实体就自动成为聚合根,子实体就自动成为本地实体。在做出决定之前,还需要更多信息。以下是我的方法:
- 在应用程序中如何访问该实体?
- 如果实体可以通过 ID 或某种搜索方式查找,则它很可能是一个聚合根。
- 其他聚合体是否需要引用它?
- 如果该实体将被其他聚合引用,那么它肯定是聚合根。
- 应用程序中将如何修改该实体?
- 如果它能够独立修改,那么它很可能是一个聚合根。
- 如果不能在不修改其他实体的情况下对其进行修改,那么它很可能是一个本地实体。
一旦确定要创建聚合根,如何确保它强制执行业务不变式?业务不变式又意味着什么?业务不变式是指无论聚合根发生什么变化都必须始终成立的规则。一个简单的业务不变式可以是:在发票中,总金额必须始终等于各行项目金额之和,无论项目是被添加、编辑还是删除。这些不变式应该成为通用语言和领域模型的一部分。
从技术上讲,聚合根可以通过不同的方式强制执行业务不变性:
- 所有状态改变操作均通过聚合根执行。
- 允许对本地实体执行状态更改操作,但每当状态发生更改时,都会通知聚合根。
在某些情况下,例如发票总额的例子中,可以通过让聚合根在每次请求时动态计算总额来强制执行不变性。
我个人设计聚合函数时,会确保所有不变式都能立即且始终得到强制执行。当然,你也可以通过在保存聚合函数之前执行严格的数据验证(Java EE 的方式)来达到同样的效果。归根结底,这只是个人偏好问题。
骨料设计指南
设计骨料时,有一些指导原则需要遵循。我之所以称之为指导原则而非规则,是因为在某些情况下,打破这些原则也是合理的。
准则一:保持骨料颗粒细小
聚合数据总是作为一个整体进行检索和存储。需要读写的数据越少,系统性能就越好。出于同样的原因,您应该避免使用无界的一对多关联(集合),因为它们会随着时间的推移而变得非常庞大。
较小的聚合也使得聚合根更容易强制执行业务不变性,如果您更喜欢在聚合中使用值对象(不可变)而不是本地实体(可变),则更是如此。
准则 2:按 ID 引用其他聚合数据
与其直接引用另一个聚合,不如创建一个值对象来封装聚合根的 ID,并使用该值对象作为引用。这样更容易维护聚合的一致性边界,因为您甚至不会意外地从一个聚合内部更改另一个聚合的状态。此外,它还能防止在检索聚合时从数据存储中检索到过深的对象树。
如果您确实需要访问其他聚合的数据,并且没有更好的解决方案,则可能需要打破此准则。您可以依赖持久化框架的延迟加载功能,但根据我的经验,它们往往弊大于利。一种需要更多编码但更明确的方法,是将存储库(稍后会详细介绍)作为方法参数传递:
public class Invoice extends AggregateRoot<InvoiceId> {
private CustomerId customerId;
// All the other methods and fields omitted
public void copyCustomerInformationToInvoice(CustomerRepository repository) {
Customer customer = repository.findById(customerId);
setCustomerName(customer.getName());
setCustomerAddress(customer.getAddress());
// etc.
}
}
无论如何,都应该避免聚合体之间的双向关系。
准则 3:每次交易更改一个汇总值
尽量设计操作,使单个事务中只修改一个聚合。对于跨越多个聚合的操作,请使用领域事件和最终一致性(稍后会详细介绍)。这可以防止意外的副作用,并便于将来在必要时部署系统。此外,它还能简化不支持事务的文档数据库的使用。
然而,这样做会增加复杂性。你需要搭建一个能够可靠处理领域事件的基础架构。尤其是在单体应用中,你可以在同一线程和事务内同步分发领域事件,因此我认为增加这种复杂性的必要性并不高。我认为一个好的折衷方案是,仍然依赖领域事件来修改其他聚合,但要在同一事务内完成:
总之,你应该尽量避免直接从一个聚合内部更改另一个聚合的状态。
我们将在稍后介绍领域事件时详细讨论这一点。
准则 4:使用乐观锁定
聚合的关键特性在于强制执行业务不变性并确保数据始终保持一致。然而,如果由于数据存储更新冲突导致聚合数据损坏,那么这一切努力都将付诸东流。因此,在保存聚合数据时,应使用乐观锁来防止数据丢失。
乐观锁优于悲观锁的原因是,如果持久化框架本身不支持乐观锁,则乐观锁很容易自己实现,而且乐观锁易于分发和扩展。
遵守第一条准则也有助于解决这个问题,因为小规模的交易(以及由此产生的小额交易)也能降低冲突的风险。
聚合、不变量、UI绑定和验证
你们中的一些人可能想知道聚合和强制执行业务不变式是如何与用户界面(尤其是表单绑定)协同工作的。如果始终要强制执行不变式,并且聚合必须始终保持一致状态,那么在用户填写表单时该怎么做?此外,如果没有设置器,又该如何将表单字段绑定到聚合呢?
解决这个问题有多种方法。最简单的方案是将不变式强制执行延迟到聚合保存之后,为所有属性添加 setter 方法,并将实体直接绑定到表单。我个人不喜欢这种方法,因为我认为它更偏向数据驱动而非领域驱动。这样做的风险很高,实体可能会沦为贫乏的数据容器,而业务逻辑最终却被放到服务层(或者更糟,放到用户界面层)。
相反,我更倾向于另外两种方法。第一种方法是将表单及其内容建模为独立的领域模型概念。在现实世界中,如果您申请某项服务,通常需要填写申请表并提交。申请随后会被处理,一旦所有必要信息都已提供且您符合相关规定,申请就会获得批准,您就能获得所申请的产品或服务。您可以在领域模型中模拟这一过程。例如,如果您有一个Membership聚合根,您还可以创建一个MembershipApplication聚合根来收集创建成员对象所需的所有信息Membership。然后,该申请对象可以作为创建成员对象的输入。
第二种方法是第一种方法的变体,即本质模式。对于每个需要编辑的实体或值对象,创建一个包含相同信息的可变本质对象。然后,将此本质对象绑定到表单。一旦本质对象包含所有必要信息,就可以使用它来创建真正的实体或值对象。与第一种方法的区别在于,本质对象并非领域模型的一部分,它们只是技术构造,旨在简化与实际领域对象的交互。在实践中,本质模式可能如下所示:
public class Person extends AggregateRoot<PersonId> {
private final DateOfBirth dateOfBirth;
// Rest of the fields omitted
public Person(String firstName, String lastName, LocalDate dateOfBirth) {
setDateOfBirth(dateOfBirth);
// Populate the rest of the fields
}
public Person(Person.Essence essence) {
setDateOfBirth(essence.getDateOfBirth());
// Populate the rest of the fields
}
private void setDateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = Objects.requireNonNull(dateOfBirth, "dateOfBirth must not be null");
}
@Data // Lombok annotation to automatically generate getters and setters
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;
public Person createPerson() {
validate();
return new Person(this);
}
private void validate() {
// Make sure all necessary information has been entered, throw an exception if not
}
}
}
如果你更熟悉某种模式,可以用构建器替换精华部分。最终结果是一样的。
代码示例
Order以下是一个聚合根( )和具有本地标识的本地实体( )的示例OrderItem(代码未经测试,为了清晰起见,省略了一些方法实现):
public class Order extends AggregateRoot<OrderId> { // ID type passed in as generic parameter
private CustomerId customer;
private String shippingName;
private PostalAddress shippingAddress;
private String billingName;
private PostalAddress billingAddress;
private Money total;
private Long nextFreeItemId;
private List<OrderItem> items = new ArrayList<>();
public Order(Customer customer) {
super(OrderId.createRandomUnique());
Objects.requireNonNull(customer);
// These setters are private and make sure the passed in parameters are valid:
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);
}
private Long getNextFreeItemId() {
return nextFreeItemId++;
}
void recalculateTotals() { // Package visibility to make the method accessible from 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;
}
// Getters, private setters and other methods omitted
}
public class OrderItem extends LocalEntity<Long> { // ID type passed in as generic parameter
private Order order;
private ProductId product;
private String description;
private int quantity;
private Money price;
private Money subTotal;
OrderItem(Long id, Order order) {
super(id);
this.order = Objects.requireNonNull(order);
this.quantity = 0;
this.price = Money.ZERO;
recalculateSubTotal();
}
private void recalculateSubTotal() {
Money oldSubTotal = this.subTotal;
this.subTotal = price.multiply(quantity);
if (oldSubTotal != null && !oldSubTotal.equals(this.subTotal)) {
this.order.recalculateTotals(); // Invoke aggregate root to enforce invariants
}
}
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");
this.price = price;
recalculateSubTotal();
}
// Getters and other setters omitted
}
领域事件
到目前为止,我们只关注了领域模型中的“事物”。然而,这些事物只能用来描述模型在任何给定时刻的静态状态。在许多业务模型中,您还需要能够描述那些会改变模型状态的事件。为此,您可以使用领域事件。
埃文斯在其关于领域驱动设计的书中并未包含领域事件。领域事件后来被添加到工具箱中,并收录在弗农的书中。
领域事件是指领域模型中发生的、可能对系统其他部分有意义的任何事件。领域事件可以是粗粒度的(例如,创建特定的聚合根或启动进程),也可以是细粒度的(例如,更改特定聚合根的特定属性)。
领域事件通常具有以下特征:
- 它们是不可改变的(毕竟,你无法改变过去)。
- 它们都带有事件发生的时间戳。
- 它们可能具有唯一的ID,用于区分不同的事件。这取决于事件的类型以及事件的分布方式。
- 它们由聚合根或域名服务发布(稍后会详细介绍)。
域事件发布后,可以被一个或多个域事件监听器接收,这些监听器反过来又可能触发额外的处理和新的域事件,以此类推。发布者并不知道事件的后续处理过程,监听器也不应影响发布者(换句话说,从发布者的角度来看,发布域事件应该是无副作用的)。因此,建议域事件监听器不要在发布事件的同一事务中运行。
从设计角度来看,领域事件最大的优势在于其可扩展性。您可以根据需要添加任意数量的领域事件监听器来触发新的业务逻辑,而无需更改现有代码。当然,这首先假设已发布了正确的事件。有些事件您可能事先就知道,但其他事件则会在后续开发过程中逐渐显现。当然,您可以尝试猜测需要哪些类型的事件并将其添加到模型中,但这也可能导致系统被大量未使用的领域事件所阻塞。更好的方法是尽可能简化领域事件的发布流程,然后在需要时再添加缺失的事件。
关于事件溯源的说明
事件溯源是一种设计模式,它将系统状态持久化为有序的事件日志。每个事件都会改变系统状态,而当前状态可以通过从头到尾重放事件日志随时计算得出。这种模式在财务账簿或医疗记录等应用中尤为有用,因为在这些应用中,历史记录与当前状态同等重要(甚至更重要)。
根据我的经验,典型的业务系统的大部分部分不需要事件溯源,但有些部分确实需要。强制整个系统使用事件溯源作为持久化模型,在我看来是过度设计。不过,我发现可以在需要的地方使用领域事件来实现事件溯源。实际上,这意味着每个改变模型状态的操作都会发布一个领域事件,并将其存储在某个事件日志中。具体的实现方法不在本文的讨论范围之内。
分发领域事件
只有当你有可靠的方法将领域事件分发给监听器时,它们才能发挥作用。在单体架构中,你可以使用标准的观察者模式在内存中处理分发。然而,即使在这种情况下,如果你遵循将事件发布者放在独立事务中的最佳实践,你可能也需要更复杂的方法。如果某个事件监听器发生故障,需要重新发送事件,该怎么办?
弗农提出了两种不同的活动分发方式,这两种方式既适用于远程活动也适用于本地活动。我建议您阅读他的著作以了解详情,但在这里我将简要概述一下这些选项。
通过消息队列进行分发
此方案需要外部消息传递系统(MQ),例如 AMQP 或 JMS。该方案需要支持发布/订阅模型并保证消息送达。当发布领域事件时,生产者将其发送到消息传递系统。领域事件监听器订阅该消息传递系统,并会立即收到通知。
这种模型的优点是速度快、易于实现,并且依赖于现有的成熟可靠的消息传递解决方案。缺点是需要设置和维护消息队列解决方案,而且如果新用户订阅,则无法接收过去的事件。
通过事件日志进行分发
此方案无需额外组件,但需要编写一些代码。当发布领域事件时,该事件会被追加到事件日志中。领域事件监听器会定期轮询此日志以检查新事件。它们还会跟踪已处理的事件,以避免每次都遍历整个事件日志。
该模型的优点在于无需任何额外组件,并且包含完整的事件历史记录,可供新的事件监听器重放。缺点是实现起来比较复杂,且事件发布到被监听器接收之间的延迟最多为轮询间隔。
关于最终一致性的说明
在分布式系统或多个数据存储参与同一逻辑事务的情况下,数据一致性始终是一个挑战。高级应用服务器支持分布式事务,可以用来解决这个问题,但它们需要专门的软件,并且配置和维护起来可能很复杂。如果强一致性是绝对必要的,那么您别无选择,只能使用分布式事务,但在许多情况下,从业务角度来看,强一致性实际上可能并没有那么重要。我们之所以习惯于从单个应用程序在单个 ACID 事务中与单个数据库通信的时代来思考强一致性,是因为我们过去一直这样认为。
强一致性的替代方案是最终一致性。这意味着应用程序中的数据最终会趋于一致,但有时系统的各个部分可能并非完全同步,这完全没问题。设计一个支持最终一致性的应用程序需要不同的思维方式,但反过来,这将使系统比仅支持强一致性的系统更具弹性和可扩展性。
在领域驱动系统中,领域事件是实现最终一致性的绝佳方式。任何需要在其他模块或系统中发生某些事件时更新自身的系统或模块都可以订阅来自该系统的领域事件:
在上面的示例中,对系统 A 所做的任何更改最终都会通过域事件传播到系统 B、C 和 D。每个系统都会使用自己的本地事务来实际更新数据存储。根据事件分发机制和系统负载的不同,传播时间可能从不到一秒(所有系统都在同一网络中运行,事件会立即推送给订阅者)到几小时甚至几天(某些系统处于离线状态,仅偶尔连接到网络以下载自上次签入以来发生的所有域事件)。
为了成功实现最终一致性,您必须拥有一个可靠的系统来分发领域事件,即使部分订阅者在事件首次发布时不在线,该系统也能正常工作。您还需要围绕“任何数据都可能随时过时”这一假设来设计业务逻辑和用户界面。此外,您还需要设定数据不一致的持续时间限制。您可能会惊讶地发现,某些数据可以保持不一致数天之久,而其他数据则必须在几秒钟甚至更短的时间内更新。
代码示例
以下是一个聚合根()的示例,它会在订单发货时Order发布一个域事件( )。域监听器( )将接收该事件,并在单独的事务中创建新发票。假设存在一种机制,可以在保存聚合根时发布所有已注册的事件(代码未经测试,并且为了清晰起见,省略了一些方法实现):OrderShippedInvoiceCreator
public class OrderShipped implements DomainEvent {
private final OrderId order;
private final Instant occurredOn;
public OrderShipped(OrderId order, Instant occurredOn) {
this.order = order;
this.occurredOn = occurredOn;
}
// Getters omitted
}
public class Order extends AggregateRoot<OrderId> {
// Other methods omitted
public void ship() {
// Do some business logic
registerEvent(new OrderShipped(this.getId(), Instant.now()));
}
}
public class InvoiceCreator {
final OrderRepository orderRepository;
final InvoiceRepository invoiceRepository;
// Constructor omitted
@DomainEventListener
@Transactional
public void onOrderShipped(OrderShipped event) {
var order = orderRepository.find(event.getOrderId());
var invoice = invoiceFactory.createInvoiceFor(order);
invoiceRepository.save(invoice);
}
}
可移动物体和静态物体
在继续之前,我想先介绍一下可移动对象和静态对象。这些并非领域驱动设计(DDD)的正式术语,而是我在思考领域模型不同组成部分时常用的概念。在我看来,可移动对象是指可以存在多个实例,并且可以在应用程序的不同部分之间传递的对象。值对象、实体和领域事件都是可移动对象。
另一方面,静态对象是单例(或池化资源),它始终存在于一个位置,并由应用程序的其他部分调用,但很少被传递(除非被注入到其他静态对象中)。存储库、领域服务和工厂都是静态对象。
这种区别至关重要,因为它决定了对象之间可以建立哪些关系。静态对象可以持有对其他静态对象和可移动对象的引用。
可移动对象可以持有对其他可移动对象的引用。但是,可移动对象永远不能持有对静态对象的引用。如果可移动对象需要与静态对象交互,则必须将静态对象作为方法参数传递给与之交互的方法。这使得可移动对象更具可移植性和自包含性,因为每次反序列化可移动对象时,无需查找并注入对静态对象的引用。
其他领域对象
在编写领域驱动代码时,有时会遇到一些类并不符合值对象、实体或领域事件的定义的情况。根据我的经验,这种情况通常发生在以下几种情况下:
- 来自外部系统(即另一个限界上下文)的任何信息。从你的角度来看,这些信息是不可变的,但它有一个用于唯一标识它的全局 ID。
- 用于描述其他实体的类型数据(Vaughn Vernon 称这些对象为标准类型)。这些对象具有全局 ID,并且在某种程度上可能是可变的,但就应用程序本身的实际用途而言,它们都是不可变的。
- 框架/基础设施级别的实体,例如用于在数据库中存储审计条目或领域事件。它们可能具有全局 ID,也可能不具有全局 ID,并且可能可变,也可能不可变,具体取决于使用场景。
我处理这类情况的方法是使用一个由基类和接口构成的层次结构,这个层次结构以一个称为“领域对象”的东西开始DomainObject。领域对象是指任何与领域模型相关的可移动对象。如果一个对象纯粹是一个值对象或并非纯粹是一个实体,我可以将其声明为领域对象,并在 Java 文档中解释它的作用和原因,然后继续进行后续操作。
我喜欢在层次结构的顶端使用接口,因为你可以随意组合它们,甚至可以让其他类enums实现它们。有些接口是标记接口,不包含任何方法,它们仅用于指示实现类在领域模型中扮演的角色。在上图中,类和接口如下:
DomainObject- 所有领域对象的顶级标记接口。DomainEvent- 所有领域事件的接口。它通常包含一些关于事件的元数据,例如事件的日期和时间,但也可能是一个标记接口。ValueObject- 所有值对象的标记接口。此接口的实现必须是不可变的,并且需要实现 `Input` 和 `Input` 接口equals()。hashCode()遗憾的是,尽管从接口层面强制执行此要求会很理想,但目前尚无办法。IdentifiableDomainObject- 用于所有可在特定上下文中唯一标识的领域对象的接口。我通常将其设计为一个通用接口,并将 ID 类型作为通用参数。StandardType- 标准类型的标记接口。Entity- 实体的抽象基类。我通常会包含一个 ID 字段,并相应地实现equals()相关hashCode()功能。根据持久化框架的不同,我可能还会向此类添加乐观锁定信息。LocalEntity- 本地实体的抽象基类。如果我使用本地标识来标识本地实体,则此类将包含管理该标识的代码。否则,它可能只是一个空的标记类。AggregateRoot- 聚合根的抽象基类。如果我使用本地标识来标识本地实体,则此类将包含用于生成新本地 ID 的代码。此类还将包含用于分发域事件的代码。如果乐观锁定信息未包含在类中Entity,则肯定会包含在此处。根据应用程序的需求,还可以将审计信息(创建时间、上次更新时间等)添加到此类中。
代码示例
在这个代码示例中,我们有两个限界上下文:身份管理和员工管理:
员工管理上下文需要身份管理上下文中的部分用户信息,但并非全部。为此,系统提供了一个 REST 端点,数据以 JSON 格式序列化。
在身份管理上下文中,a 的User表示方式如下:
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;
// Getters, setters and business logic omitted
}
在员工管理场景中,我们只需要用户 ID 和姓名。用户 ID 用于唯一标识,而姓名则显示在用户界面上。显然,我们无法更改任何用户信息,因此用户信息是不可变的。代码如下:
public class User implements IdentifiableDomainObject<UserId> {
private final UserId userId;
private final String firstName;
private final String lastName;
@JsonCreator // We can deserialize the incoming JSON directly into an instance of this class.
public User(String userId, String firstName, String lastName) {
// Populate fields, convert incoming userId string parameter into a UserId value object instance.
}
public String getFullName() {
return String.format("%s %s", firstName, lastName);
}
// Other getters omitted.
public boolean equals(Object o) {
// Check userId only
}
public int hashCode() {
// Calculate based on userId only
}
}
存储库
现在我们已经介绍了领域模型中的所有可移动对象,接下来我们将介绍静态对象。第一个静态对象是存储库。存储库是聚合的持久化容器。任何保存到存储库中的聚合都可以在以后从中检索,即使系统重启后也是如此。
一个代码仓库至少应该具备以下功能:
- 能够将整个聚合数据保存到某种数据存储中
- 能够根据其 ID 检索整个聚合数据。
- 能够根据其 ID 完全删除聚合数据。
大多数情况下,一个真正可用的存储库还需要更高级的查询方法。
实际上,存储库是指向外部数据存储(例如关系数据库、NoSQL 数据库、目录服务甚至文件系统)的领域感知接口。尽管实际的存储隐藏在存储库之后,但其存储语义通常会泄露出来,并对存储库的实现方式施加限制。因此,存储库通常分为面向集合的存储库和面向持久化的存储库。
面向集合的存储库旨在模拟内存中的对象集合。一旦聚合被添加到集合中,对其所做的任何更改都会自动持久化,直到该聚合从存储库中移除。换句话说,面向集合的存储库将具有诸如 `add`add()和 `resolve`之类的方法remove(),但没有保存方法。
另一方面,面向持久化的存储库并不试图模仿集合。相反,它充当外部持久化解决方案的门面,并包含诸如 `setChanges` 和 ` insert()setChanged`update()之类的方法delete()。对聚合所做的任何更改都必须通过调用 `saveChanges` 方法显式地保存到存储库中update()。
在项目初期确定正确的存储库类型至关重要,因为它们在语义上差异很大。通常,面向持久化的存储库更容易实现,并且与大多数现有的持久化框架兼容。而面向集合的存储库则更难实现,除非底层持久化框架本身就支持它。
代码示例
本示例演示了面向集合的存储库和面向持久化的存储库之间的区别。首先,我们来看面向集合的存储库:
public interface OrderRepository {
Optional<Order> get(OrderId id);
boolean contains(OrderID id);
void add(Order order);
void remove(Order order);
Page<Order> search(OrderSpecification specification, int offset, int size);
}
// Would be used like this:
public void doSomethingWithOrder(OrderId id) {
orderRepository.get(id).ifPresent(order -> order.doSomething());
// Changes will be automatically persisted.
}
然后是面向持久化的存储库:
public interface OrderRepository {
Optional<Order> findById(OrderId id);
boolean exists(OrderId id);
Order save(Order order);
void delete(Order order);
Page<Order> findAll(OrderSpecification specification, int offset, int size);
}
// Would be used like this:
public void doSomethingWithOrder(OrderId id) {
orderRepository.findById(id).ifPresent(order -> {
order.doSomething();
orderRepository.save(order);
});
}
关于 CQRS 的说明
存储库始终保存和检索完整的聚合数据。这意味着它们的运行速度可能相当慢,具体取决于其实现方式以及每个聚合数据需要构建的对象图的大小。从用户体验的角度来看,这可能会造成问题,尤其体现在以下两种使用场景中。第一种是小型列表,您可能只想显示一个聚合数据列表,但仅使用其中一两个属性。当您只需要几个属性值时,却要加载完整的对象图,这会浪费时间和计算资源,并且通常会导致用户体验缓慢。另一种情况是,您需要合并来自多个聚合数据的数据才能在列表中显示单个项目。这可能会导致更糟糕的性能。
只要数据集和聚合数据较小,性能损失可能还可以接受,但如果性能变得无法接受,则有一个解决方案:命令查询职责分离 (CQRS)。
CQRS 是一种将写入(命令)和读取(查询)操作完全解耦的模式。本文篇幅有限,无法深入探讨其细节,但就领域驱动设计 (DDD) 而言,您可以这样应用该模式:
- 所有改变系统状态的用户操作都会按正常方式通过存储库。
- 所有查询都绕过存储库,直接访问底层数据库,只获取所需的数据,不获取其他任何数据。
- 如有需要,您甚至可以为用户界面中的每个视图设计单独的查询对象。
- 查询对象返回的数据传输对象 (DTO) 必须包含聚合 ID,以便在需要对其进行更改时可以从存储库中检索正确的聚合。
在许多项目中,您可能会在某些视图中使用 CQRS,而在其他视图中使用直接存储库查询。
域名服务
我们之前已经提到过,值对象和实体都可以(也应该)包含业务逻辑。然而,在某些情况下,一段逻辑并不适合放在某个特定的值对象或实体中。将业务逻辑放在错误的位置是一个糟糕的想法,因此我们需要另一种解决方案。这就引出了我们的第二个静态对象:领域服务。
域服务具有以下特点:
- 他们是无国籍人士。
- 它们具有高度凝聚力(意味着它们专注于做一件事,而且只做一件事)。
- 它们包含一些不适合放在其他地方的业务逻辑。
- 它们可以与其他领域服务进行交互,并在一定程度上与存储库进行交互。
- 他们可以发布领域事件
最简单的领域服务可以是一个包含静态方法的实用程序类。更高级的领域服务可以实现为单例模式,并将其他领域服务和存储库注入其中。
领域服务不应与应用服务混淆。我们将在本系列的下一篇文章中更详细地探讨应用服务,但简而言之,应用服务充当隔离的领域模型与外部世界之间的中间人。应用服务负责处理事务、确保系统安全、查找正确的聚合、调用其方法并将更改保存回数据库。应用服务本身不包含任何业务逻辑。
你可以将应用服务和领域服务之间的区别概括如下:领域服务只负责制定业务决策,而应用服务只负责流程编排(找到正确的对象并按正确的顺序调用正确的方法)。因此,领域服务通常不应该调用任何会改变数据库状态的存储库方法——那是应用服务的职责。
代码示例
在第一个示例中,我们将创建一个领域服务,用于检查某笔货币交易是否可以进行。虽然实现部分已大大简化,但显然是基于一些预定义的业务规则做出业务决策。
在这种情况下,由于业务逻辑非常简单,您或许可以直接将其添加到Account类中。但是,一旦涉及到更高级的业务规则,将决策过程移至单独的类中就显得尤为重要(尤其是在规则随时间变化或依赖于某些外部配置的情况下)。另一个表明此逻辑可能更适合放在域服务中的明显迹象是,它涉及多个聚合(两个帐户)。
public class TransactionValidator {
public boolean isValid(Money amount, Account from, Account to) {
if (!from.getCurrency().equals(amount.getCurrency())) {
return false;
}
if (!to.getCurrency().equals(amount.getCurrency())) {
return false;
}
if (from.getBalance().isLessThan(amount)) {
return false;
}
if (amount.isGreaterThan(someThreshold)) {
return false;
}
return true;
}
}
在第二个例子中,我们将探讨一个具有特殊特性的领域服务:它的接口是领域模型的一部分,但它的实现却不是。这种情况可能出现在你需要从外部获取信息以在领域模型中做出业务决策,但你并不关心这些信息的来源时。
public interface CurrencyExchangeService {
Money convertToCurrency(Money currentAmount, Currency desiredCurrency);
}
例如,当领域模型配置完成后(例如使用依赖注入框架),就可以注入该接口的正确实现。例如,可以有一个实现调用本地缓存,另一个实现调用远程 Web 服务,第三个实现仅用于测试,等等。
工厂
我们最终要介绍的静态对象是工厂(factory)。顾名思义,工厂负责创建新的聚合(aggregate)。但这并不意味着每个聚合都需要创建一个新的工厂。在大多数情况下,聚合根的构造函数就足以设置聚合,使其处于一致状态。通常,在以下情况下才需要单独的工厂:
- 创建聚合体时涉及到业务逻辑。
- 根据输入数据的不同,骨料的结构和成分可能差异很大。
- 输入数据量非常庞大,因此需要使用构建器模式(或类似模式)。
- 工厂正在从一个有限的语境过渡到另一个有限的语境。
工厂可以是聚合根类上的静态工厂方法,也可以是单独的工厂类。工厂可以与其他工厂、存储库和领域服务交互,但绝不能更改数据库的状态(即不能进行保存或删除操作)。
代码示例
在这个例子中,我们将考察一个在两个限界上下文之间进行转换的工厂。在发货上下文中,客户不再被称为“客户”,而是被称为“发货收件人”。客户 ID 仍然会被存储,以便我们稍后在需要时将这两个概念关联起来。
public class ShipmentRecipientFactory {
private final PostOfficeRepository postOfficeRepository;
private final StreetAddressRepository streetAddressRepository;
// Initializing constructor omitted
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());
return recipient;
}
}
模块
差不多该进入下一篇文章了,但在我们离开战术领域驱动设计之前,还有一个概念我们需要了解,那就是模块。
在领域驱动设计(DDD)中,模块对应于Java中的包和C#中的命名空间。一个模块可以对应于一个限界上下文,但通常情况下,一个限界上下文会包含多个模块。
属于同一范畴的类应该放在同一个模块中。但是,创建模块不应该基于类的类型,而应该基于类在业务角度如何融入领域模型。也就是说,不应该把所有存储库放在一个模块里,把所有实体放在另一个模块里,等等。相反,应该把所有与特定聚合或特定业务流程相关的类放在同一个模块中。这样可以简化代码的阅读,因为属于同一范畴且协同工作的类也应该放在一起。
模块示例
这是一个按类型对类进行分组的模块结构示例。请勿这样做:
- foo.bar.domain.model.服务
AuthenticationServicePasswordEncoder
- foo.bar.domain.model.仓库
UserRepositoryRoleRepository
- foo.bar.domain.model.实体
UserRole
- foo.bar.domain.model.valueobjects
UserIdRoleIdUserName
更好的方法是按流程和聚合对类进行分组。请按以下步骤操作:
- foo.bar.domain.model.身份验证
AuthenticationService
- foo.bar.domain.model.用户
UserUserRepositoryUserIdUserNamePasswordEncoder
- foo.bar.domain.model.角色
RoleRoleRepositoryRoleId
为什么战术领域驱动设计如此重要?
正如我在本系列第一篇文章的引言中提到的,我第一次接触领域驱动设计是在挽救一个数据严重不一致的项目时。由于没有任何领域模型或通用语言,我们开始将现有的数据模型转换为聚合,并将数据访问对象转换为存储库。正是由于这些转换引入了约束,我们才得以消除数据不一致问题,最终软件得以部署到生产环境。
这次与战术领域驱动设计(DDD)的初次接触让我意识到,即使项目的其他方面并非领域驱动,你也能从中受益。我最喜欢的 DDD 构建模块是值对象,我几乎在所有参与的项目中都会用到它。它易于引入,并且由于为属性赋予了上下文,因此能立即提升代码的可读性和可理解性。其不可变性也往往能简化复杂的事情。
即使数据模型本身非常贫乏(只有getter和setter方法,没有任何业务逻辑),我也经常尝试将其分组为聚合和存储库。这有助于保持数据一致性,并避免在通过不同机制更新同一实体时出现奇怪的副作用和乐观锁定异常。
领域事件有助于解耦代码,但这把双刃剑也会带来负面影响。如果过度依赖事件,代码将变得更难理解和调试,因为无法立即清楚地了解特定事件会触发哪些其他操作,或者最初是什么事件导致了特定操作的触发。
与其他软件设计模式一样,战术领域驱动设计为一系列您通常会遇到的问题提供了解决方案,尤其是在构建企业级软件时。您掌握的工具越多,就越容易应对在软件开发职业生涯中不可避免会遇到的问题。
文章来源:https://dev.to/peholmst/tropical-domain-driven-design-17dp










