战术领域驱动设计
在本文中,我们将学习战术领域驱动的设计。战术DDD是一组设计模式和构建块,可用于设计域驱动的系统。即使对于非域驱动的项目,您也可以从使用一些战术性DDD模式中受益。
与战略领域驱动的设计相比,战术设计更加动手,并且更接近于实际代码。战略设计涉及抽象整体,而战术设计涉及课程和模块。战术设计的目的是将域模型精炼到可以将其转换为工作代码的阶段。
设计是一个反复的过程,因此将战略和战术设计结合起来是有意义的。您从战略设计开始,然后是战术设计。战术设计期间可能会出现最大的领域模型设计启示和突破,而这反过来又会影响战略设计,因此您需要重复此过程。
同样,内容很大程度上基于埃里克·埃文斯(Eric Evans)的《域驱动设计:解决软件核心中的复杂性》和沃恩·弗农(Vaughn Vernon)的《实现域驱动设计》一书,我强烈建议您阅读它们。就像在上一篇文章中一样,我选择了用自己的语言尽可能多地解释,并在适当的时候注入自己的想法,思想和经验。
通过简短的介绍,是时候展示战术DDD工具箱并了解其中的内容了。
值对象
战术DDD中最重要的概念之一是值对象。这也是我在非DDD项目中使用最多的DDD构建基块,我希望阅读完这篇文章后,您也能这样做。
值对象是其价值很重要的对象。这意味着具有完全相同值的两个值对象可以被视为同一值对象,因此可以互换。因此,应该始终将值对象设为不可变的。无需更改值对象的状态,而是将其替换为新实例。对于复杂的值对象,可以考虑使用制造商或本质的模式。
值对象不仅是数据容器,还可以包含业务逻辑。值对象也是不可变的事实使业务操作既线程安全又无副作用。这是我非常喜欢值对象的原因之一,也是为什么您应该尝试为值对象尽可能多地建模领域概念的原因之一。另外,请尝试使值对象尽可能小且尽可能一致-这使它们更易于维护和重用。
制作值对象的一个很好的起点是采用具有商业意义的所有单值属性并将其包装为值对象。例如:
与其使用aBigDecimal作为货币值,Money不如使用包装了的value对象BigDecimal。如果您使用的是一种以上的货币,则可能还需要创建一个Currency值对象,并使该Money对象包装为BigDecimal-Currency对。
不要使用字符串作为电话号码和电子邮件地址,而应使用PhoneNumber和EmailAddress赋值包装字符串的对象。
像这样使用值对象具有多个优点。首先,它们为价值带来了环境。您不需要知道特定的字符串是否包含电话号码,电子邮件地址,名字或邮政编码,也不需要知道aBigDecimal是货币值,百分比还是完全不同的东西。类型本身会立即告诉您要处理的内容。
其次,您可以将可以对特定类型的值执行的所有业务操作添加到值对象本身。例如,一个Money对象可以包含用于加和减总金额或计算百分比的操作,同时确保基础货币的精度BigDecimal始终正确并且Money该操作中涉及的所有对象都具有相同的币种。
第三,可以确定值对象始终包含有效值。例如,您可以在EmailAddress值对象的构造函数中验证电子邮件地址输入字符串。
程式码范例
Money中的值对象可能看起来像这样(代码未经测试,为清晰起见,一些方法实现已被省略):
Money.java
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
}
}
一个StreetAddress值对象和相应的设计器的Java可能看起来像这样(代码是未经测试,为清楚起见一些方法实现省略):
StreetAddress.java
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);
}
}
}
实体
战术DDD中的第二个重要概念和重视对象的同胞是实体。实体是其身份很重要的对象。为了能够确定实体的身份,每个实体都有一个唯一的ID,该ID在创建实体时分配,并且在实体的整个生命周期内保持不变。
即使所有其他属性不同,具有相同类型和相同ID的两个实体也被视为同一实体。同样,具有相同类型和相同属性但具有不同ID的两个实体被认为是不同的实体,就像两个具有相同名称的个体被认为是不同的一样。
与值对象相反,实体是可变的。但是,这并不意味着您应该为每个属性创建setter方法。尝试将所有状态更改操作建模为与业务操作相对应的动词。设置员只会告诉您要更改的属性,而不是原因。例如:假设您有一个EmploymentContract实体,并且它具有一个endDate属性。雇佣合同之所以终止,可能是因为它们只是暂时的,是由于从一个公司分支机构内部转移到另一分支机构,因为雇员辞职或因为雇主解雇了雇员。在所有这些情况下,endDate都更改了,但是原因有所不同。此外,根据终止合同的原因,可能还需要采取其他措施。一种terminateContract(reason, finalDay)方法已经说明了很多,而不仅仅是一个setEndDate(finalDay)方法。
话虽如此,二传手仍然在DDD中占有一席之地。在上面的示例中,可能有一个私有setEndDate(..)方法来确保结束日期在开始日期之后再进行设置。该设置方法将由其他实体方法使用,但不会暴露给外界。对于在不更改其业务状态的情况下描述实体的主数据和参考数据,使用设置器比尝试将操作调整为动词更有意义。setDescription(..)可以说一种叫做的方法比更具可读性describe(..)。
我将用另一个例子来说明这一点。假设您有一个Person代表一个人的实体。该人拥有firstName和的lastName资产。现在,如果这只是一个简单的通讯录,您将让用户根据需要更改此信息,并且可以使用设置器setFirstName(..)和setLastName(..)。但是,如果您要建立政府的正式公民登记册,则更易涉及更改姓名。您可能会得到类似的结果changeName(firstName, lastName, reason, effectiveAsOfDate)。同样,上下文就是一切。
关于吸气剂的注意事项 作为JavaBean规范的一部分引入Java的Getter方法。该规范在Java的第一个版本中不存在,这就是为什么您可以在标准Java API中找到一些不符合该规范的方法的原因(例如String.length()与相对String.getLength())。
就我个人而言,我希望看到对Java中的不动产的支持。尽管他们可能是使用getter和幕后制定者,我想以同样的方式来访问属性值,如果它只是一个普通的领域:mycontact.phoneNumber。我们尚不能在Java中做到这一点,但可以通过省略get吸气剂的后缀来达到相当接近的效果。在我看来,这使代码更流畅,尤其是在需要深入了解对象层次结构以获取某些东西的情况下,尤其如此mycontact.address().streetNumber()。
但是,摆脱吸气剂还有一个弊端,那就是工具支持。所有Java IDE和许多库都依赖于JavaBean标准,这意味着您可能最终需要手动编写可能已经为您自动生成的代码,并添加通过遵守约定可以避免的注释。
实体还是值对象?
并不总是很容易知道是将某些东西建模为值对象还是实体。可以在一个上下文中将完全相同的真实世界概念建模为实体,而在另一个上下文中,可以将其建模为值对象。让我们以街道地址为例。
如果要构建发票系统,则街道地址就是您在发票上打印的内容。只要发票上的文本正确,使用什么对象实例都没有关系。在这种情况下,街道地址是一个值对象。
如果您要为公用事业建立系统,则需要确切地知道给定公寓使用的是哪种燃气管线或哪种电力管线。在这种情况下,街道地址是一个实体,甚至可以分成较小的实体,例如建筑物或公寓。
值对象是不可变的且较小,因此更易于使用。因此,你的目标应该是很少的实体和许多值对象的设计。
程式码范例
Person中的实体可能看起来像这样(未经测试的代码,为清楚起见,省略了一些方法的实现):
Person.java
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,字符串或long,但是值对象会立即告诉我们这是一个标识特定Person的ID。
-
除了实体ID外,该实体还使用许多其他值对象:PersonName,LocalDate(是的,即使它是标准Java API的一部分,这也是一个值对象),StreetAddress,EmailAddress和PhoneNumber。
-
我们没有使用更改方法来更改名称,而是使用了将更改内容以及更改原因的原因也存储在事件日志中的业务方法。
-
有一个用于获取名称更改历史的吸气剂。
-
equals和hashCode仅检查实体ID。
领域驱动设计和CRUD
我们现在已经到了解决有关DDD和CRUD的问题的时候了。CRUD代表Create,Retrieve,Update和Delete,它也是企业应用程序中常见的UI模式:
-
主视图由一个网格组成,可能带有过滤和排序功能,您可以在其中查找实体(retrieve)。
-
在主视图中,有一个用于创建新实体的按钮。单击该按钮将弹出一个空表单,并且在提交表单后,新实体将显示在网格中(create)。
-
在主视图中,有一个用于编辑所选实体的按钮。单击该按钮将弹出一个包含实体数据的表单。提交表单后,将使用新信息更新实体(update)。
-
在主视图中,有一个用于删除所选实体的按钮。单击该按钮将从网格中删除实体(delete)。
这种模式当然有它的位置,但是在域驱动的应用程序中应该是例外,而不是规范。原因如下:CRUD应用程序仅用于结构化,显示和编辑数据。它通常不支持基础业务流程。当用户向系统中输入某些内容,更改或删除某些内容时,该决定背后存在商业原因。也许更改是作为较大业务流程的一部分进行的?在CRUD系统中,更改的原因丢失了,业务流程在用户的头上。
真正的领域驱动用户界面将基于本身是普适语言(进而是域模型)的一部分的操作,并且业务流程是内置在系统中的,而不是用户头中的。反过来,这又导致了比纯CRUD应用程序更健壮,但可以说更不灵活的系统。我将通过一个讽刺的例子来说明这种差异:
公司A具有领域驱动的员工管理系统,而公司B具有CRUD驱动的方法。一名员工在两家公司辞职。发生以下情况:
-
公司A:
-
经理在系统中查找员工的记录。
-
经理选择“终止雇佣合同”操作。
-
系统要求终止日期和原因。
-
经理输入所需的信息,然后单击“终止合同”。
-
该系统会自动更新员工记录,撤销员工的用户凭证和电子办公室密钥,并将通知发送到薪资系统。
-
公司B:
-
经理在系统中查找员工的记录。
-
经理在“合同终止”复选框中添加了一个检查并输入终止日期,然后单击“保存”。
-
管理员登录到用户管理系统,查找用户帐户,在“已禁用”复选框中打勾,然后单击“保存”。
-
经理登录到办公室钥匙管理系统,查找用户的钥匙,在“已禁用”复选框中打勾,然后单击“保存”。
-
经理向工资部门发送电子邮件,通知他们该雇员已辞职。
关键要点如下:并非所有应用程序都适合域驱动的设计,并且领域驱动的应用程序不仅具有领域驱动的后端,而且具有领域驱动的用户界面。
聚合
现在,当我们知道什么是实体和值对象时,我们将研究下一个重要概念:聚合。集合是一组具有某些特征的实体和值对象:
-
聚合是作为整体创建,检索和存储的。
-
聚合始终处于一致状态。
-
聚合由称为聚合根的实体所有,该实体的ID用于标识聚合本身。
此外,关于聚合有两个重要限制:
-
聚合只能从外部通过其根引用。聚合外部的对象可能无法引用聚合内部的任何其他实体。
-
聚合根负责在聚合内部强制执行业务不变量,以确保聚合始终处于一致状态。
这意味着,每当设计一个实体时,都必须决定要创建哪种类型的实体:该实体将充当聚合根,还是我所说的位于聚合内部且位于聚合下方的本地实体?监督总根?由于不能从聚合外部引用本地实体,因此它们的ID在聚合内唯一(它们具有本地标识)就足够了,而聚合根必须具有全局唯一ID(它们具有全局标识)就足够了)。但是,这种语义差异的重要性因您选择存储聚合的方式而异。在关系数据库中,对所有实体使用相同的主键生成机制是最有意义的。另一方面,如果将整个聚合保存为文档数据库中的单个文档,则对本地实体使用真正的本地ID更有意义。
那么,您怎么知道一个实体是否是一个聚合根?首先,两个实体之间存在父子(或主从细节)关系这一事实并不会自动将父代变成聚合根,而将子代变成本地实体。在做出该决定之前,需要更多信息。这是我的方法:
-
如何在应用程序中访问实体?
-
如果将通过ID或通过某种搜索来查找实体,则该实体可能是聚合根。
-
其他汇总是否需要引用它?
-
如果将从其他聚合中引用该实体,则该实体肯定是聚合根。
-
如何在应用程序中修改实体?
-
如果可以独立修改,则可能是聚合根。
-
如果在不更改另一个实体的情况下不能对其进行修改,则可能是本地实体。
知道要创建聚合根后,如何使它强制执行业务不变式,这甚至意味着什么?业务不变式是一个必须始终成立的规则,无论聚合发生了什么。一个简单的业务不变性可能是,发票中的总金额必须始终是订单项金额的总和,而不管项目是添加,编辑还是删除。不变式应该成为普遍存在的语言和领域模型的一部分。
从技术上讲,聚合根可以以不同方式强制执行业务不变式:
-
所有状态更改操作都是通过聚合根执行的。
-
允许对本地实体进行状态更改操作,但是每当更改时,它们都会通知聚合根。
在某些情况下,例如在带有发票总额的示例中,可以通过让汇总根每次请求总计动态计算总数来强制执行不变式。
我亲自设计了聚合,这样就可以随时随地强制执行不变式。可以说,通过引入在保存聚合之前执行的严格数据验证(Java EE方法),可以达到相同的最终结果。归根结底,这是个人喜好问题。
总体设计准则
在设计聚合时,要遵循某些准则。我选择称其为指南而不是规则,因为在某些情况下有必要打破它们。
准则1:将聚合保持较小
聚合始终始终作为整体进行检索和存储。您必须读写的数据越少,系统的性能就越好。出于相同的原因,您应避免无限制的一对多关联(集合),因为它们会随着时间的推移而增大。
较小的聚合也使聚合根更容易实施业务不变量,甚至在您更喜欢在聚合中使用值对象(不可变)而不是本地实体(可变)时,更是如此。
准则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绑定和验证
你们中的有些人现在可能想知道聚合和强制业务不变量如何与用户界面一起工作,更具体地说是与表单绑定一起工作。如果始终要强制执行不变式,并且聚合必须始终处于一致状态,那么在用户填写表单时您会怎么做?此外,如果没有设置器,如何将表单字段绑定到聚合?
处理这一问题有多种方式。最简单的解决方案是将不变式实施推迟到保存聚合之前,为所有属性添加设置器,然后将实体直接绑定到表单。我个人不喜欢这种方法,因为我认为它是数据驱动而不是领域驱动。实体降级为数据的贫血持有者的风险很高,而业务逻辑最终会到达服务层(或更糟的是,在UI中)。
相反,我更喜欢另外两种方法。第一个是将表单及其内容建模为自己的领域模型概念。在现实世界中,如果您要申请某些东西,则通常必须填写一份申请表并提交。然后处理该应用程序,一旦提供了所有必要的信息并且您符合规则,就将授予该应用程序,您可以获得所申请的任何内容。您可以在领域模型中模拟此过程。例如,如果您有一个Membership聚合根,则也可以有一个MembershipApplication聚合根,该根用于收集创建所需的所有信息Membership。然后,在创建成员资格对象时,可以将应用程序对象用作输入。
第二种方法是第一种方法的一种变体,那就是本质模式。对于需要编辑的每个实体或值对象,创建一个包含相同信息的可变本质对象。然后,此基本对象绑定到表单。一旦基本对象包含所有必要的信息,就可以将其用于创建真实实体或值对象。与第一种方法的不同之处在于,本质对象不是领域模型的一部分,它们只是存在的技术构造,其存在使其能够更轻松地与实际领域对象进行交互。在实践中,基本模式可能看起来像这样:
Person.java
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)的示例(该代码未经测试,为清晰起见,一些方法实现已被省略):
Order.java
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
}
OrderItem.java
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
}
领域事件
到目前为止,我们只研究了域模型中的“事物”。但是,这些只能用于描述模型在任何给定时刻所处的静态。在许多业务模型中,您还需要能够描述发生的事情并更改模型的状态。为此,您可以使用领域事件。
领域事件未包含在Evans的有关领域驱动设计的书中。它们已在以后添加到工具箱中,并包含在Vernon的书中。
领域事件是领域模型中发生的,系统其他部分可能感兴趣的任何事情。领域事件可以是粗粒度的(例如,创建特定的聚合根或启动进程),也可以细粒度的(例如,更改特定的聚合根的特定属性)。
领域事件通常具有以下特征:
-
它们是不可变的(毕竟,您无法更改过去)。
-
他们有事件发生时的时间戳。
-
它们可能具有唯一的ID,有助于将一个事件与另一个事件区分开。这取决于事件的类型以及事件的分布方式。
-
它们是通过聚合根或域服务发布的(稍后将详细介绍)。
发布领域事件后,一个或多个领域事件侦听器可以接收到该事件,进而可能触发其他处理和新的领域事件等。发布者不知道事件会发生什么,侦听器也不应该知道能够影响发布者的能力(换句话说,从发布者的角度来看,发布领域事件应无副作用)。因此,建议领域事件侦听器不要在发布该事件的同一事务中运行。
从设计的角度来看,领域事件的最大优点是它们使系统可扩展。您可以根据需要添加任意数量的领域事件侦听器,以触发新的业务逻辑,而不必更改现有代码。自然地,假设正确的事件首先被发布。您可能会事先意识到一些事件,但其他事件可能会在以后进一步显示。当然,您可以尝试猜测将需要哪种类型的事件并将其添加到模型中,但是随后您还可能面临因未在任何地方使用的领域事件而阻塞系统的风险。更好的方法是尽可能轻松地发布领域事件,然后在意识到需要时添加缺失的事件。
关于事件采购的注意事项 事件源是一种设计模式,其中系统状态作为事件的有序日志持久存在。每个事件甚至都会改变系统状态,并且可以通过从头到尾重播事件日志来随时计算当前状态。这种模式在诸如财务分类账或病历之类的应用中特别有用,在这些应用中,历史与当前状态一样重要(甚至更重要)。
以我的经验,典型业务系统的大多数部分都不需要事件源,但是某些部分却需要。在我看来,强迫整个系统使用事件源作为持久性模型将是过大的。但是,我发现可以在需要的地方使用领域事件来实现事件源。实际上,这意味着更改模型状态的每个操作还将发布存储在某些事件日志中的领域事件。从技术上讲,这不在本文讨论范围之内。
分发领域事件
仅当您有可靠的方式将领域事件分发给侦听器时,才可以使用领域事件。在整体内部,您可以使用标准观察者模式来处理内存中的分布。但是,即使在这种情况下,如果您遵循在单独的事务中运行事件发布者的良好实践,则可能还需要一些更复杂的东西。如果事件监听器之一失败并且必须重新发送事件该怎么办?
弗农提出了两种不同的方式来分发可在远程和本地运行的事件。我鼓励您阅读他的书以获取详细信息,但在这里我将简要介绍这些选项。
通过消息队列分发
此解决方案需要一个外部消息传递解决方案(MQ),例如AMQP或JMS。该解决方案需要支持发布-订阅模型和有保证的交付。发布领域事件时,生产者将其发送到MQ。领域事件侦听器订阅了MQ,并将立即得到通知。
该模型的优点是它快速,易于实施,并且依赖于现有的可靠消息传递解决方案。缺点是您必须设置和维护MQ解决方案,并且如果有新使用者订阅,则无法接收过去的事件。
通过事件日志进行分发
该解决方案不需要其他组件,但需要一些编码。发布领域事件后,会将其附加到事件日志中。领域事件侦听器会定期轮询此日志以检查是否有新事件。他们还跟踪已处理的事件,以避免每次都要遍历整个事件日志。
该模型的优点是它不需要任何其他组件,并且包含完整的事件历史记录,可以针对新的事件侦听器进行重放。缺点是需要执行一些工作,并且侦听器发布和接收的事件之间的延迟最多为轮询间隔。
关于最终一致性的注意事项 在分布式系统或多个数据存储参与同一逻辑事务的情况下,数据一致性始终是一个挑战。先进的应用程序服务器支持可用于解决此问题的分布式事务,但它们需要专用的软件,并且配置和维护起来可能很复杂。如果绝对强一致性是一个绝对的要求,那么您别无选择,只能使用分布式事务,但是在许多情况下,可能会发现从业务角度来看,高度强一致性实际上并不那么重要。从我们在单个ACID事务中使用单个应用程序与单个数据库进行通信的时间开始,我们就习惯于考虑强一致性。
最终一致性是替代强一致性的方法。这意味着应用程序中的数据最终将变得一致,但是有时系统中的所有部分并非彼此同步,这是完全可以的。为最终的一致性设计应用程序需要不同的思维方式,但与仅要求强一致性的系统相比,这反过来将导致该系统具有更大的弹性和可扩展性。
在领域驱动的系统中,领域事件是实现最终一致性的绝佳方法。在另一个模块或系统中发生某些事情时,需要更新自身的任何系统或模块都可以订阅来自该系统的领域事件:
在上面的示例中,对系统A所做的任何更改最终都将通过域事件传播到系统B,C和D。每个系统将使用其自己的本地事务来实际更新数据存储。取决于事件分发机制和系统的负载,传播时间的范围可以从不到一秒(所有系统都在同一网络中运行,事件会立即推送给订户)到几小时甚至几天(某些情况下,系统处于脱机状态,仅偶尔连接到网络以下载自上次签入以来发生的所有域事件)。
为了成功实现最终的一致性,即使事件首次发布时某些订阅者当前未联机,您也必须具有可靠的系统来分发有效的域事件。您还需要围绕这样的假设设计业务逻辑和用户界面,即任何数据都可以在一段时间内过时。您还需要制定关于数据不一致时间的限制。您可能会惊讶地发现有些数据可能在几天之内保持不一致,而另一些数据必须在几秒钟或更短的时间内进行更新。
程式码范例
以下是汇总根(Order)的示例,该根在发布OrderShipped订单时发布域事件()。域侦听器(InvoiceCreator)将接收事件并在单独的交易中创建新的发票。假定存在一种机制,可以在保存聚合根时发布所有注册的事件(未经测试的代码,为清晰起见,省略了一些方法的实现):
OrderShipped.java
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
}
Order.java
public class Order extends AggregateRoot<OrderId> {
// Other methods omitted
public void ship() {
// Do some business logic
registerEvent(new OrderShipped(this.getId(), Instant.now()));
}
}
InvoiceCreator.java
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,并且可能具有可变性,也可能不具有可变性。
我处理这些情况的方法是使用以a开头的基类和接口的层次结构DomainObject。领域对象是与领域模型相关的任何可移动对象。如果一个对象纯粹是一个值对象,或者不是一个纯粹的实体,我可以将其声明为域对象,在JavaDocs中解释其作用和原因,然后继续进行。
我喜欢在层次结构的顶部使用接口,因为您可以按照自己喜欢的任何方式组合它们,甚至可以enums实现它们。一些接口是标记接口,没有任何仅用于指示实现类在域模型中扮演什么角色的方法。在上图中,类和接口如下:
-
DomainObject -所有域对象的顶级标记接口。
-
DomainEvent-所有域事件的接口。它通常包含一些有关事件的元数据,例如事件的日期和时间,但它也可能是标记界面。
-
ValueObject-所有值对象的标记接口。该接口的实现必须是不变的,并且必须实现equals()和hashCode()。不幸的是,即使那样很好,也无法从接口级别强制执行。
-
IdentifiableDomainObject-在某些情况下可以唯一标识的所有域对象的接口。我经常将此设计为ID类型为通用参数的通用接口。
-
StandardType -标准类型的标记界面。
-
Entity-实体的抽象基类。我经常在字段中输入ID,equals()并hashCode()相应地实施。根据持久性框架,我可能还会向此类添加乐观的锁定信息。
-
LocalEntity-本地实体的抽象基类。如果我对本地实体使用本地身份,则该类将包含用于管理本地身份的代码。否则,它可能只是一个空的标记类。
-
AggregateRoot-聚合根的抽象基类。如果我对本地实体使用本地身份,则该类将包含用于生成新本地ID的代码。该类还将包含用于调度域事件的代码。如果乐观锁信息未包含在Entity该类中,则肯定包含在此处。根据应用程序的要求,审核信息(创建,上次更新等)也可以添加到此类中。
程式码范例
在此代码示例中,我们有两个有界上下文,身份管理和员工管理:
员工管理上下文需要身份管理上下文中的一些(但不是全部)有关用户的信息。为此有一个REST端点,并且数据被序列化为JSON。
在身份管理上下文中,aUser表示如下:
User.java(身份管理)
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进行唯一标识,但名称会显示在UI中。我们显然不能更改任何用户信息,因此用户信息是不可变的。代码如下:
User.java(员工管理)
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()和的remove()方法,但是没有用于保存的方法。
另一方面,面向持久性的存储库不会尝试模仿集合。取而代之的是,它作为一个外部持久性溶液中的门面和包含方法,如insert(),update()和delete()。对聚合所做的任何更改都必须通过调用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的注释 存储库始终保存和检索完整的聚合。这意味着它们可能非常慢,这取决于它们的实现方式以及必须为每个聚合构造的对象图的大小。从UX的角度来看,这可能是有问题的,尤其是想到了两个用例。第一个是一个小清单,您想在其中显示聚合列表,但仅使用一个或两个属性。仅需要几个属性值时显示完整的对象图会浪费时间和计算资源,并且通常会导致用户体验缓慢。另一种情况是,您需要合并来自多个聚合的数据以在列表中显示单个项目。这可能会导致更差的性能。
只要数据集和聚合很小,性能损失可能是可以接受的,但是如果到了性能根本不能接受的时候,则有一个解决方案:命令查询责任分离(CQRS)。
CQRS是一种模式,您可以完全将写入(命令)和读取(查询)操作彼此分离。详细内容不在本文讨论范围之内,但是就DDD而言,您应采用以下模式:
-
更改系统状态的所有用户操作均以正常方式通过存储库。
-
所有查询都绕过存储库,直接进入基础数据库,仅获取所需的数据,而没有其他数据。
-
如果需要,您甚至可以为用户界面中的每个视图设计单独的查询对象
-
查询对象返回的数据传输对象(DTO)必须包含聚合ID,以便可以在需要更改存储库时从存储库中检索正确的聚合。
在许多项目中,您最终可能会在某些视图中使用CQRS,而在其他视图中直接使用存储库查询。
领域服务
我们已经提到过,价值对象和实体都可以(并且应该)包含业务逻辑。但是,在某些情况下,一种逻辑根本不适合一个特定的值对象或一个特定的实体。将业务逻辑放在错误的位置是一个坏主意,因此我们需要另一个解决方案。输入我们的第二个静态对象:领域服务。
领域服务具有以下特征:
-
他们是无状态的
-
他们具有高度的凝聚力(意味着他们只专注于做一件事和一件事)
-
它们包含的业务逻辑无法自然地融入其他地方
-
它们可以与其他域服务进行交互,并在某种程度上与存储库进行交互
-
他们可以发布领域事件
以最简单的形式,领域服务可以是其中包含静态方法的实用程序类。可以将更高级的领域服务实现为具有其他领域服务和存储库的单例
领域服务不应与应用程序服务混淆。在本系列的下一篇文章中,我们将仔细研究应用程序服务,但是简而言之,应用程序服务充当隔离域模型和世界其他地区之间的中间人。应用程序服务负责处理事务,确保系统安全性,查找适当的聚合,在其上调用方法并将更改保存回数据库。应用程序服务本身不包含任何业务逻辑。
您可以总结出应用程序服务和领域服务之间的区别,如下所示:领域服务仅负责制定业务决策,而应用程序服务仅负责编排(查找正确的对象并以正确的顺序调用正确的方法)。因此,领域服务通常不应调用任何更改数据库状态的存储库方法-这是应用程序服务的责任。
程式码范例
在第一个示例中,我们将创建一个领域服务,以检查是否允许进行某些货币交易。该实现已大大简化,但显然可以基于一些预定义的业务规则来做出业务决策。
在这种情况下,由于业务逻辑是如此简单,因此您可能已经能够将其直接添加到Account类中。但是,一旦更高级的业务规则生效,就可以将决策制定到自己的类中(特别是如果规则随时间变化或依赖于某些外部配置)。该逻辑可能属于领域服务的另一个明显标志是,它涉及多个聚合(两个帐户)。
TransactionValidator.java
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;
}
}
在第二个示例中,我们将研究具有特殊功能的域服务:其接口是领域模型的一部分,而其实现则不是。当您需要外界的信息以在领域模型内做出业务决策时,可能会出现这种情况,但是您对这些信息的来源不感兴趣。
CurrencyExchangeService.java
public interface CurrencyExchangeService {
Money convertToCurrency(Money currentAmount, Currency desiredCurrency);
}
当领域模型被连接起来时,例如使用依赖项注入框架,您就可以注入该接口的正确实现。您可能有一个调用本地缓存,另一个调用远程Web服务,第三个仅用于测试,依此类推。
工厂
我们将要看到的最后一个静态对象是factory。顾名思义,工厂负责创建新的聚合。但是,这并不意味着您需要为每个聚合创建一个新工厂。在大多数情况下,聚合根的构造函数足以设置聚合,使其处于一致状态。在以下情况下,您通常需要一个单独的工厂:
业务逻辑参与集合的创建
聚合的结构和内容可能会因输入数据而有很大不同
输入数据如此之大,以至于需要构建器模式(或类似的东西)
工厂正在从一种有限的上下文转换为另一种
工厂可以是聚合根类上的静态工厂方法,也可以是单独的工厂类。工厂可以与其他工厂,存储库和领域服务进行交互,但绝不能更改数据库的状态(因此不能保存或删除)。
程式码范例
在此示例中,我们将看一个在两个有界上下文之间转换的工厂。在装运上下文中,客户不再称为客户,而称为装运收件人。客户ID仍然存储着,以便我们以后可以根据需要将这两个概念联系在一起。
ShipmentRecipientFactory.java
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.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
更好的方法是按过程和汇总对类进行分组。改为执行以下操作:
- foo.bar.domain.model.authentication
- AuthenticationService
- foo.bar.domain.model.user
- User
- UserRepository
- UserId
- UserName
- PasswordEncoder
- foo.bar.domain.model.role
- Role
- RoleRepository
- RoleId
战术域驱动设计为何重要?
就像我在本系列第一篇文章的引言中提到的那样,我首先遇到了领域驱动设计,同时挽救了一个遭受严重数据不一致问题困扰的项目。在没有任何领域模型或无处不在的语言的情况下,我们开始将现有数据模型转换为聚合,并将数据访问对象转换为存储库。由于这些引入到软件中的约束,我们设法摆脱了不一致的问题,最终可以将软件部署到生产中。
战术领域驱动设计的第一次接触向我证明,即使在所有其他方面的项目都不是领域驱动的情况下,您也可以从中受益。我倾向于在参与的所有项目中使用的最喜欢的DDD构建块是值对象。它很容易引入,并立即使代码更易于阅读和理解,因为它为您的属性带来了环境。不变性也倾向于使复杂的事情变得更简单。
我经常还会尝试将数据模型分为汇总和存储库,即使该数据模型完全是贫乏的(只有没有任何业务逻辑的getter和setter)。当通过不同机制更新同一实体时,这有助于保持数据的一致性,并避免奇怪的副作用和乐观的锁定异常。
领域事件对于解耦代码很有用,但这是一把双刃剑。如果您过多地依赖事件,则代码将变得更加难以理解和调试,因为尚无法立即清楚地了解特定事件将触发哪些其他操作,或者首先会导致哪些事件导致特定操作被触发。
与其他软件设计模式一样,战术领域驱动设计为通常遇到的一系列问题提供了解决方案,尤其是在构建企业软件时。您拥有的工具越多,就越容易解决您在软件开发人员的职业生涯中不可避免地遇到的问题。