DDD 第 2 部分:战术领域驱动设计 瓦丁 -- 知识铺
这是我们关于领域驱动设计 (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
来表示货币值,而应使用 包装BigDecimal
的Money
值对象。如果要处理多种货币,则可能还需要创建一个Currency
值对象,并使Money
对象包装为BigDecimal-Currency
对。 -
不要对电话号码和电子邮件地址使用字符串,而应使用 包装字符串的
PhoneNumber
和EmailAddress
值对象。
使用这样的值对象有几个优点。首先,它们为价值带来了背景信息。您不需要知道特定字符串是否包含电话号码、电子邮件地址、名字或邮政编码,也不需要知道 BigDecimal
是 货币值、百分比还是完全不同的东西。类型本身会立即告诉您正在处理什么。
其次,您可以将可以对特定类型的值执行的所有业务操作添加到值对象本身。例如,Money 对象可以包含用于添加和减去货币总和或计算百分比的操作,同时确保基础 BigDecimal
的精度始终正确,并且操作中涉及的所有 Money
对象都具有相同的货币。
第三,您可以确保值对象始终包含有效值。例如,可以在 EmailAddress
值对象的 构造函数中验证电子邮件地址输入字符串。
代码示例
Java 中的 Money
值对象可能看起来像这样(代码未经测试,为了清楚起见,省略了一些方法实现):
Money.java
|
|
Java 中的 StreetAddress
值对象和相应的构建器可能看起来像这样(代码未经测试,为了清楚起见,省略了一些方法实现):
StreetAddress.java
|
|
在领域驱动设计(Domain-Driven Design, DDD)中,值对象(Value Object)是一种没有独立存在意义的类,它仅通过它的属性来表达其概念,并通常用于描述领域模型中的特定方面或属性集合。
值对象的特点:
-
不可变性:值对象一旦被创建,其状态(属性)就不能被改变。如果需要修改,会创建一个新的实例。
-
相等性:值对象的相等性是基于它的属性来定义的,而不是它的身份。如果两个值对象的属性完全相同,那么它们就被认为是相等的。
-
无标识性:值对象没有唯一标识符,它们不是通过ID来区分的。
-
可替换性:值对象是可替换的,这意味着在任何需要使用该值对象的地方,都可以使用它的一个副本而不会影响业务逻辑。
-
语义化:值对象通常具有丰富的语义和行为,这些行为定义了它在领域中的作用。
-
轻量级:值对象通常是轻量级的,并且可以被轻松地在领域中传递。
值对象的用途:
- 表示不能独立存在的概念,比如尺寸、颜色、货币金额等。
- 作为领域实体的属性,比如一个人的姓名、地址等。
- 作为参数传递给领域服务或实体的方法,以表达某个特定概念。
值对象的实现示例:
|
|
在这个例子中,Money
类是一个值对象,它有两个属性:金额和货币类型。它重写了 equals
和 hashCode
方法,以确保基于属性的相等性。此外,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
|
|
领域驱动设计(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时,应该考虑业务规则和实体行为,以确保系统的健壮性和可维护性。
领域驱动设计中的CRUD与业务流程
在传统的CRUD(创建、读取、更新、删除)应用程序中,用户界面通常由以下几个部分组成:
- 检索:主视图通常是一个网格,用户可以在其中通过过滤和排序功能查找实体。2. 创建:在主视图中,有一个按钮用于创建新实体。点击此按钮会弹出一个空表单,提交后新实体将显示在网格中。3. 更新:有一个按钮用于编辑所选实体。点击后会弹出一个包含实体数据的表单,提交表单后实体将更新为新信息。4. 删除:还有一个按钮用于删除所选实体,点击后实体将从网格中删除。 然而,在领域驱动的应用程序中,CRUD模式应该是例外,而不是常态。这是因为CRUD应用仅关注数据的构建、显示和编辑,而不支持底层的业务流程。在CRUD系统中,用户输入、更改或删除内容背后的业务原因往往丢失,导致业务流程仅存在于用户的脑海中。
领域驱动的用户界面
一个真正的领域驱动用户界面将基于通用语言的操作,并将业务流程内置在系统中。这使得系统比纯CRUD应用更健壮,但可能在灵活性上有所欠缺。以下是领域驱动与CRUD方法的对比示例:
A公司:领域驱动的员工管理系统
- 经理在系统中查找员工记录。2. 选择“终止雇佣合同”操作。3. 系统询问终止日期和原因。4. 经理输入所需信息并点击“终止合同”。5. 系统自动更新员工记录,撤销凭证和密钥,并向工资系统发送通知。
B公司:CRUD驱动的方法
- 经理查找员工记录。2. 在“合同终止”复选框中打勾,输入终止日期,点击“保存”。3. 管理员禁用用户账户。4. 经理禁用办公室密钥。5. 发送电子邮件通知工资部门。
聚合的概念
在领域驱动设计中,除了实体和值对象,聚合也是一个重要概念。它由以下特点组成:
- 聚合作为一个整体被创建、检索和存储。- 聚合始终处于一致状态。- 聚合由一个称为聚合根的实体拥有,该实体的ID用于标识聚合本身。 聚合是领域模型的基础,确保数据的完整性和一致性。
关键要点
- 不是所有应用程序都适合领域驱动设计。- 领域驱动的应用程序应具有领域驱动的后端和用户界面。 通过上述内容,我们可以看到领域驱动设计如何帮助我们更好地理解和实现业务流程,以及如何通过聚合来维护数据的一致性和完整性。
此外,关于聚合还有两个重要的限制:
- 聚合只能通过其根从外部引用。聚合外部的对象不得 引用聚合内部的任何其他实体。
- 聚合根负责在聚合内部强制执行业务不变 性,确保聚合始终处于一致状态。
在领域驱动设计(DDD)中,实体的设计至关重要,它们可以是聚合根或本地实体。聚合根负责管理和协调其内部的本地实体,确保业务规则的一致性。以下是对聚合设计的一些关键点的梳理:
实体类型- 聚合根:具有全局唯一标识,独立存在,可被直接访问和修改。- 本地实体:存在于聚合内部,拥有局部唯一标识,不能被外部直接访问。
存储方式的影响- 在关系数据库中,统一使用主键生成机制。- 在文档数据库中,本地实体可使用本地ID。
判断聚合根的标准- 访问方式:是否通过ID或搜索直接访问。- 引用情况:是否被其他聚合引用。- 修改独立性:是否可以独立于其他实体进行修改。
业务不变性的强制- 聚合根通过以下方式强制业务不变性: - 所有状态更改通过聚合根执行。 - 本地实体状态变化时通知聚合根。
聚合设计准则1. 保持聚合小:减少数据读取写入量,提高系统性能,便于业务规则的强制。2. 通过ID引用其他聚合:避免直接引用,使用包装ID的值对象,维护聚合的一致性边界。
个人实践- 我倾向于设计聚合以立即且始终强制执行业务不变性。- 可以通过严格的数据验证实现相同的效果,但这更多是个人偏好。
结论聚合设计是DDD中的一个基础而关键的环节,合理的设计可以提高系统的可维护性和性能。
在某些情况下,如果确实需要访问其他聚合的数据,可能需要打破常规的准则。尽管有其他方法,但它们可能并不总是最佳解决方案。一种替代方法是使用持久性框架的延迟加载功能,然而,根据我的个人经验,这种方法可能会带来更多问题而不是解决方案。 为了更明确地控制数据访问,可以考虑采用一种编码工作量较大但更直接的方法。具体来说,可以将存储库作为方法参数传入。这将在稍后的部分详细介绍,但基本思想是利用存储库的直接访问能力,以确保数据的一致性和准确性。
|
|
准则 3:更改每个事务的一个聚合
在设计领域驱动设计(DDD)中的操作时,应遵循以下原则:确保每个事务仅对一个聚合体进行更改。这一做法有助于维护数据的一致性,并减少在事务处理过程中出现意外副作用的风险。此外,它还为将来可能的系统分布式部署提供了便利。
聚合与事务的关系
在DDD中,聚合是指一组紧密相关的对象集合,它们作为一个整体被管理以保证数据的一致性。事务则是数据库操作中的一个单元,确保了操作的原子性。将两者结合使用时,应保证在单个事务中只对一个聚合体进行修改,以避免跨聚合体的复杂依赖和潜在的数据问题。
跨聚合体的操作
如果业务需求需要跨多个聚合体进行操作,可以利用领域事件和最终一致性模式来实现。领域事件允许系统的不同部分在发生特定业务活动时进行通信,而最终一致性则允许系统在一定时间后达到一致的状态,而不是立即。这些机制将在后续内容中详细介绍。
避免副作用和简化系统使用
遵循准则3,可以减少因事务操作不当而产生的副作用,例如数据不一致或更新冲突。此外,当系统需要使用不支持事务的文档数据库时,这种设计原则也使得数据操作更加容易实现。
结论
通过将每个事务限制在一个聚合体上,我们不仅提高了系统的健壮性和可维护性,也为未来的系统扩展和分布式部署打下了基础。 在软件开发过程中,引入域事件可以增加系统复杂性。然而,通过合理设置基础架构,可以确保域事件的可靠处理。特别是在单体应用程序中,域事件的同步调度可以在同一个线程和事务中完成,从而减少这种复杂性。 我认为,一个有效的方法是继续使用域事件来对其他聚合进行更改,但这些更改应该在同一事务中完成。这样,我们可以在保持系统灵活性的同时,减少因域事件处理不当而带来的风险。 在领域驱动设计(DDD)中,聚合是一个核心概念,它代表了一个数据修改的一致性单元。以下是对聚合、乐观锁定以及与用户界面交互的一些关键点的重新整理和阐述:
聚合与业务不变性聚合是DDD中的一个关键概念,它封装了一系列相关对象,确保它们作为一个单元进行修改,从而维护数据的一致性和业务规则的不变性。聚合根是聚合中的核心对象,负责管理聚合的生命周期。
乐观锁定为了防止在并发环境下的数据冲突,乐观锁定是一种常用的技术。它假设多个事务在同一数据上的操作不会发生冲突,只有在提交更改时才会进行检查。如果检测到冲突,事务可以被拒绝并重试。
避免直接修改其他聚合在设计系统时,应避免从一个聚合中直接修改另一个聚合的状态。这样做有助于保持聚合的自治性和封装性。
与用户界面的交互在用户界面上,如何处理聚合的不变性和表单绑定是一个挑战。以下是一些策略:
- 推迟不变性执行:直到聚合保存前才执行不变性检查,但这可能导致业务逻辑泄露到UI层。2. 领域模型映射:将表单及其内容映射到领域模型中,例如,使用
MembershipApplication
来收集创建Membership
所需的信息。3. 本质模式:创建一个可变的、包含实体或值对象相同信息的本质对象,用于表单绑定。本质对象在收集完所有必要信息后,可以用来创建实际的领域对象。
实践中的注意事项- 保持聚合的小巧,以降低并发冲突的风险。
- 谨慎使用乐观锁定,确保它适合你的业务场景。
- 在设计UI时,考虑如何将表单与领域模型自然地结合起来,而不是简单地将数据从UI传递到领域模型。
通过上述方法,可以在保持DDD原则的同时,有效地与用户界面进行交互。
|
|
如果你愿意,如果你更熟悉这种模式,你可以用构建器替换本质。最终结果将是相同的。
代码示例
下面是一个聚合根 (Order
) 和具有本地标识的本地实体 (OrderItem
) 的示例(代码未经测试,为了清楚起见,省略了一些方法实现):
Order.java
|
|
OrderItem.java
|
|
域事件概述
在领域驱动设计(Domain-Driven Design, DDD)中,除了研究领域模型中的“事物”外,描述模型状态变化的过程同样重要。这就需要用到域事件。
域事件定义域事件
是领域模型中发生的状态改变,它可能引起系统其他部分的关注。事件可以是粗粒度的,比如创建一个聚合根;也可以是细粒度的,比如修改聚合根的某个属性。
域事件特性
- 不变性:事件一旦发生,就无法更改,代表已经发生的过去。
- 时间戳:每个事件都有发生的时间标记。
- 唯一标识:可能拥有唯一ID,以区分不同的事件。
- 发布者:通常由聚合根或领域服务发布。
域事件的作用
一旦发布,域事件可以被一个或多个域事件侦听器接收。这些侦听器可能会触发额外的处理,甚至产生新的域事件。发布者和侦听器之间是解耦的,发布者发布事件后不知晓后续会发生什么,侦听器也不应影响发布者。
事件溯源(Event Sourcing)
事件溯源是一种设计模式,系统中的状态以一系列事件日志的形式存在。每个事件都改变系统状态,当前状态可以通过重播事件日志来计算。
事件分发事件的有效性取决于能否可靠地分发给侦听器。
在单体架构中,可以使用观察者模式;但在分布式系统中,则需要更复杂的机制。
通过消息队列分发
使用外部消息传递系统(如AMQP或JMS)来保证事件的发布-订阅和可靠交付。事件发布者将事件发送到消息队列,而侦听器订阅并接收这些事件。
结论域事件使系统
具备扩展性,可以在不修改现有代码的基础上,通过添加侦听器来引入新的业务逻辑。事件溯源为需要时提供了一种持久化模型,但并不适用于所有系统。简化事件发布过程,并在需要时添加事件是推荐的做法。 在分布式系统中,事件日志分发模型以其快速性和易于实现的特点而受到青睐。它依赖于成熟的信息传递解决方案,但同时也带来了一些挑战。以下是该模型的优缺点概述:
优点
- 速度:事件日志分发模型能够快速响应事件,因为它直接将事件记录到日志中。
- 易实现:该模型不需要复杂的设置,可以快速部署到现有的系统中。
- 依赖成熟技术:它利用了久经考验的消息传递技术,减少了技术风险。
缺点
- 维护成本:需要设置和维护消息队列(MQ)解决方案,这可能涉及到额外的工作量和成本。
- 历史事件问题:当新的消费者订阅时,它们无法接收到之前发生的事件,这可能限制了系统的灵活性。
实现方式
- 事件日志:当发布域事件时,事件将被追加记录到日志中。
- 轮询机制:域事件侦听器会定期检查日志,寻找新事件。
- 事件跟踪:侦听器还会跟踪已经处理的事件,避免重复处理,提高效率。 这种模型适用于需要快速响应事件且希望简化实现过程的场景。然而,开发者需要权衡其维护成本和对历史事件处理的需求。
模型优势与挑战
优势
- 无需额外组件:模型本身自足,无需依赖外部组件。
- 完整的事件历史记录:能够为新事件侦听器提供事件历史重播,增强了系统的可追溯性。
挑战
- 实现复杂性:需要一定的工作量来实现模型。
- 延迟问题:事件发布与侦听器接收之间存在最大轮询间隔的延迟。
最终一致性的概念
在分布式系统中,数据一致性是一个常见问题,特别是在多个数据存储参与同一逻辑事务时。
强一致性
- 高级应用服务器:支持分布式事务,但配置维护复杂。
- 适用场景:当业务需求绝对要求数据强一致性时,分布式事务是必要的。
最终一致性
- 定义:系统数据最终达到一致状态,但过程中可能存在非同步状态。
- 业务意义:从业务角度出发,强一致性并非总是必需的。
- 设计思维:需要采用与强一致性不同的设计和思维方式。
弹性与可扩展性
- 优势:系统设计为最终一致性可以提高系统的弹性和可扩展性。
域驱动设计中的事件
在域驱动设计系统中,使用域事件来实现最终一致性是一种有效的方法。
- 事件订阅:系统或模块可以订阅其他系统或模块的域事件,以便在事件发生时进行自我更新。
在上面的示例中,对系统 A 所做的任何更改_最终_ 都将 通过域事件传播到系统 B、C 和 D。每个系统都将使用自己的本地事务来实际更新数据存储。
根据事件分发机制和系统的负载,传播时间可以从不到一秒(所有系统都运行在同一网络中,事件立即推送给订阅者)到几个小时甚至几天(某些系统处于离线状态,只是偶尔连接到网络以下载自上次签入以来发生的所有域事件)。
为了成功实现最终一致性,您必须有一个可靠的系统来分发域事件,即使某些订阅者在首次发布事件时当前不在线,这些事件也能正常工作。
您还需要围绕以下假设来设计业务逻辑和用户界面:任何数据片段都可能随时过时一段时间。您还需要制定关于数据不一致的时间长度的约束。
您可能会惊讶地发现,某些数据可能会在几天内保持一致,而其他数据必须在几秒钟甚至更短的时间内更新。
代码示例
下面是一个聚合根 (Order
) 的示例,该聚合根在订单发货时发布域事件 (OrderShipped
)。域侦听器 (InvoiceCreator
) 将接收事件并在单独的事务中创建新发票。
假设存在一种机制,该机制在保存聚合根时发布所有已注册的事件(代码未经测试,并且为了清楚起见,省略了一些方法实现):
OrderShipped.java
|
|
Order.java
|
|
InvoiceCreator.java
|
|
领域驱动设计中的可移动与静态对象
可移动对象在领域驱动设计(DDD)中,可移动对象指的是那些可以在应用程序的不同部分之间传递的对象。这些对象可以有多个实例,并且它们是领域模型中的关键部分。可移动对象的类型包括:
- 值对象:表示没有独立存在意义的属性集合。- 实体:具有唯一标识和生命周期的对象。- 域事件:表示领域中重要事件的对象。
静态对象与可移动对象相对的是静态对象,它们通常是单例或池化资源,位于固定位置,由应用程序的其他部分调用。静态对象的类型包括:
- 存储库:提供对领域对象集合的访问和管理。- 领域服务:执行不自然属于任何实体或值对象的领域逻辑。- 工厂:负责创建复杂的聚合或对象。
对象之间的关系可移动对象可以引用其他可移动对象,但它们永远不能引用静态对象。如果需要与静态对象交互,应将静态对象作为参数传递给方法。这种设计使得可移动对象更加自包含和可移植。
其他域对象在DDD实践中,我们可能会遇到一些不符合值对象、实体或域事件模式的类。这些情况通常包括:
- 来自外部系统的信息,具有全局ID且不可变。- 用于描述其他实体数据的标准类型,具有全局ID,但对应用程序而言是不可变的。- 框架或基础设施级别的实体,如审计条目或域事件记录。
处理方法为了处理这些特殊的域对象,可以采用基类和接口的层次结构,从DomainObject
开始。DomainObject
是与领域模型相关的任何可移动对象。如果对象不符合纯粹的值对象或实体的定义,可以将其声明为DomainObject
,并在JavaDocs中说明其作用和存在的理由。
结构化内容以下是对上述内容的结构化梳理:
- 可移动对象:领域模型中可以传递的对象,包括值对象、实体和域事件。- 静态对象:应用程序中的单例或池化资源,如存储库、领域服务和工厂。- 对象关系:可移动对象可以相互引用,但不能引用静态对象。- 其他域对象:不符合标准模式的类,如外部系统信息、标准类型和框架实体。- 处理策略:使用
DomainObject
基类和接口层次结构来组织这些对象。 通过这种方式,我们可以清晰地理解和应用领域驱动设计中的不同对象类型及其关系。 在领域驱动设计(Domain-Driven Design, DDD)中,接口和类的结构是至关重要的,它们定义了领域模型的骨架。以下是对领域模型中接口和类的一些基本介绍和结构化描述:
领域对象接口- DomainObject
: 所有领域对象的顶级标记接口,用于标识领域对象。- DomainEvent
: 所有领域事件的接口,通常包含事件的元数据,如时间戳,也可以是标记接口。- ValueObject
: 所有值对象的标记接口,要求实现不可变性以及equals()
和hashCode()
方法。
特定领域对象接口- IdentifiableDomainObject
: 可在某些上下文中唯一标识的领域对象接口,通常设计为泛型接口,以容纳不同的ID类型。- StandardType
: 标准类型的标记接口,用于标识领域中的通用数据类型。
实体和聚合根- Entity
: 实体的抽象基类,通常包含ID字段,并实现equals()
和hashCode()
方法。根据使用的持久性框架,可能包含乐观锁定信息。- LocalEntity
: 本地实体的抽象基类,如果使用本地标识,则包含管理该标识的代码。- AggregateRoot
: 聚合根的抽象基类,包含生成新本地ID的代码,调度领域事件,以及可能包含乐观锁定和审计信息。
边界上下文示例在提供的代码示例中,我们有两个边界上下文:Identity Management和Employee Management。每个边界上下文都有其特定的模型和语言,但它们之间可能存在交互和集成需求。
边界上下文关系- 边界上下文之间的关系可以通过多种方式定义,包括合作伙伴关系、共享内核、客户-供应商关系等。
集成模式- 确定边界上下文如何集成是设计过程中的关键部分,包括定义上下文边界、技术通信方式、领域模型之间的映射,以及如何防止不希望的变更。
结构化思考通过使用接口和抽象类,我们可以构建一个灵活且可扩展的领域模型。同时,识别和定义边界上下文及其关系,有助于我们更好地理解系统的复杂性,并为未来的扩展和维护打下基础。
注意事项- 接口应设计为尽可能通用,以支持不同上下文的需求。- 值对象的不可变性和正确的equals()
及hashCode()
实现对于领域模型的一致性和可靠性至关重要。- 聚合根作为交易的边界,包含了业务逻辑和领域事件的调度,是领域模型中的关键角色。
通过上述结构化的内容,我们可以更清晰地理解领域模型的构建和边界上下文的管理。
员工管理上下文需要来自身份管理上下文的部分(但不是全部)有关用户的信息。有一个 REST 端点用于此,数据被序列化为 JSON。
在身份管理上下文中, 用户
表示如下:
User.java(身份管理)
|
|
我们只需要员工管理上下文中的用户 ID 和名称。ID 将唯一标识用户,但名称会显示在 UI 中。我们显然不能更改任何用户信息,因此用户信息是不可变的。代码如下所示:
User.java
(员工管理)
|
|
存储 库
现在,我们已经涵盖了域模型的所有可移动对象,现在是时候转向静态对象了。第一个静态对象是 存储库。存储库是聚合的持久性容器。保存到存储库中的任何聚合都可以在以后从那里检索,即使在系统重新启动后也是如此。
至少,存储库应具有以下功能:
- 能够在某种数据存储中将聚合完整保存
- 能够根据聚合的 ID 检索整个聚合
- 能够根据聚合的 ID 完全删除聚合
在大多数情况下,存储库还需要更高级的查询方法才能真正可用。
在实践中,存储库是连接到外部数据存储(如关系数据库、NoSQL 数据库、目录服务甚至文件系统)的域感知接口。
即使实际存储隐藏在存储库后面,其存储语义通常会泄露并对存储库的外观施加限制。因此,存储库通常是 面向 集合的,或者 是面向持久性的。
面向集合的存储库旨在模拟内存中的对象集合。将聚合添加到集合中后,对其所做的任何更改都将自动保留,直到从存储库中删除聚合。
换句话说,面向集合的存储库将具有 add()
和 remove()
等 方法,但没有用于保存的方法。
另一方面,面向持久性的存储库不会尝试模仿集合。相反,它充当外部持久性解决方案的外观,并包含 insert()
、 update()
和 delete()
等 方法。对聚合所做的任何更改都必须通过调用 update()
方法显式保存到存储库中。
在项目开始时正确获取存储库类型非常重要,因为它们在语义上完全不同。通常,面向持久性的存储库更易于实现,并且与大多数现有的持久性框架配合使用。
面向集合的存储库更难实现,除非底层持久性框架开箱即用地支持它。
代码示例
此示例演示了面向集合的存储库和面向持久性的存储库之间的差异。
面向集合的存储库
|
|
面向持久性的仓库
|
|
在软件开发中,命令查询责任分离(CQRS)是一种设计模式,用于解决存储库在处理大型数据集时的性能问题。以下是对CQRS概念的详细解释:
存储库的性能问题存储库负责保存和检索聚合数据。当聚合数据量较大时,构建对象图可能会非常耗时,这直接影响到用户体验。
问题场景
- 小型列表展示:在需要展示聚合列表时,如果只关心聚合的几个属性,却要加载整个对象图,这不仅浪费资源,也会导致响应速度变慢。
- 数据合并展示:当需要将多个聚合的数据合并后展示时,性能问题会变得更加严重。
性能影响虽然对于小数据集
性能损失可能是可接受的,但随着数据量的增长,性能问题可能变得不可接受。
CQRS解决方案命令查询责任分离(CQRS)
提供了一种解决上述问题的方法。CQRS将系统的职责分为两部分:命令端(负责处理业务逻辑和数据更新)和查询端(负责数据的读取和展示)。
命令端- 处理业务逻辑。
- 更新数据状态。
查询端- 优化数据读取。
- 提供快速响应的数据查询服务。 通过分离命令和查询的责任,CQRS能够提高系统的性能,尤其是在处理大量数据时。它允许系统根据实际需求,优化数据的写入和读取过程。
结论CQRS模式通过分离数据的写入和读取,有效地解决了存储库在处理大型数据集时的性能瓶颈。这不仅提升了系统的响应速度,也改善了用户体验。
CQRS 是一种模式,在该模式中,您将写入(命令)和读取(查询)操作完全分离。深入细节超出了本文的范围,但就 DDD 而言,您可以应用如下模式:
- 更改系统状态的所有用户操作都以正常方式通过存储库。
- 所有查询都绕过存储库,直接进入底层数据库,只获取所需的数据,而不获取其他任何数据。
- 如果需要,您甚至可以为用户界面中的每个视图设计单独的查询对象。
- 查询对象返回的数据传输对象 (DTO) 必须包含聚合 ID,以便在需要对聚合进行更改时可以从存储库中检索正确的聚合。
在许多项目中,您最终可能会在某些视图中使用 CQRS,而在其他视图中直接使用存储库查询。
域名服务
我们已经提到,值对象和实体都可以(并且应该)包含业务逻辑。但是,在某些情况下,一段逻辑根本不适合一个特定的值对象或一个特定的实体。
将业务逻辑放在错误的位置是一个坏主意,因此我们需要另一种解决方案。输入第二个静态对象:
域服务。
域服务具有以下特征:
- 他们是无国籍的
- 他们具有高度的凝聚力(这意味着他们专门做一件事,而且只做一件事)
- 它们包含的业务逻辑自然不适合其他地方
- 它们可以与其他域服务交互,并在某种程度上与存储库交互
- 他们可以发布域事件
在最简单的形式中,域服务可以是一个带有静态方法的实用程序类。更高级的域服务可以作为与其他域服务和存储库的单例实现。
不应将域服务与 应用程序服务混淆。我们将在本系列的下一篇文章中更深入地了解应用程序服务。尽管如此,应用程序服务仍然充当着隔离域模型与世界其他地方之间的中间人。
应用程序服务负责处理事务、确保系统安全、查找正确的聚合、调用方法以及保存对数据库的更改。应用程序服务本身不包含任何业务逻辑。
您可以总结应用程序服务和域服务之间的区别,如下所示:域服务只负责做出业务决策,而应用程序服务只负责编排(查找正确的对象并按正确的顺序调用正确的方法)。
因此,域服务通常不应调用任何改变数据库状态的存储库方法 - 这是应用程序服务的责任。
代码示例
在这个第一个示例中,我们将创建一个域服务,用于检查是否允许进行某种货币交易。实现大大简化,但可以清楚地根据预定义的业务规则做出业务决策。
在这种情况下,由于业务逻辑非常简单,因此您可能能够将其直接 添加到 Account
类中。但是,一旦更高级的业务规则发挥作用,就应该将决策转移到自己的类别中(特别是如果规则随着时间的推移而变化或依赖于某些外部配置)。
此逻辑可能属于域服务的另一个明显迹象是,它涉及多个聚合(两个帐户)。
TransactionValidator.java
|
|
在第二个示例中,我们探讨了一种具有独特功能的领域服务。这种服务的接口是领域模型的一部分,但其实现却不属于领域模型。这种情况通常发生在需要利用外部信息来做出业务决策时,而我们对这些信息的来源并不关心。例如,CurrencyExchangeService.java
就是这类服务的一个典型例子。
领域服务的特点
- 接口集成:领域服务的接口与领域模型紧密集成,确业务逻辑的一致性。2. 实现独立:尽管接口是领域模型的一部分,但实现细节可以独立于领域模型之外。3. 外部信息利用:领域服务可以访问外部数据源,以支持业务决策过程。4. 信息来源无关性:对外部信息的来源不敏感,只关注信息本身对业务决策的影响。
领域服务实现示例以 CurrencyExchangeService.java
为例,该服务可能包含以下关键功能:- 货币兑换:提供不同货币之间的兑换功能。- 汇率更新:定期从外部数据源获取最新的汇率信息。- 业务决策支持:利用汇率信息辅助业务决策,如定价策略等。
结构化实现领域服务的实现应遵循以下结构:- 接口定义:明确服务提供的业务功能。- 数据访问:定义访问外部数据源的方法。- 业务逻辑:实现具体的业务逻辑处理。- 异常处理:妥善处理可能出现的异常情况。
总结领域服务作为一种特殊的组件,它允许领域模型与外部世界进行交互,同时保持领域模型的清晰和专注。通过合理设计领域服务,可以有效地支持复杂的业务需求,提高系统的灵活性和可维护性。
|
|
例如,当连接域模型时,使用依赖项注入框架,然后可以注入此接口的正确实现。
您可以有一个调用本地缓存,另一个调用远程 Web 服务,第三个仅用于测试,依此类推。
工厂
我们将看起来像的最后一个静态对象是 工厂。顾名思义,工厂负责创造新的聚集体。但是,这并不意味着您需要为每个聚合创建一个新工厂。
在大多数情况下,聚合根的构造函数足以设置聚合,使其处于一致状态。在以下情况下,您通常需要一个单独的工厂:
- 聚合的创建涉及业务逻辑
- 根据输入数据的不同,聚合的结构和内容可能会有很大不同
- 输入数据如此之大,以至于需要构建器模式(或类似的东西)
- 工厂正在从一个边界上下文转换到另一个边界上下文
工厂可以是聚合根类上的静态工厂方法,也可以是单独的工厂类。工厂可以与其他工厂、仓库和域服务交互,但绝不能更改数据库的状态(因此不能保存或删除)。
代码示例
在此示例中,我们将查看在两个边界上下文之间进行转换的工厂。在装运上下文中, 客户 不再被称为客户,而是称为 装运收件人。客户 ID 仍会存储,以便我们以后可以在需要时将这两个概念关联在一起。
ShipmentRecipientFactory.java
|
|
模块化在领域驱动设计中的重要性
在领域驱动设计(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
问题:
AuthenticationService
和PasswordEncoder
被放在了services
包中,这是合适的,但PasswordEncoder
也与用户模块紧密相关,可能更适合放在user
子包中。User
和Role
实体以及它们的存储库被放在了entities
包中,但没有进一步的子模块化。UserId
、RoleId
和Username
被归类为值对象,并放在了valueobjects
包中,但它们实际上可能与特定的实体(如User
和Role
)更相关。
正确的模块化示例
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应用程序。
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/geek001/post/20240730/DDD-%E7%AC%AC-2-%E9%83%A8%E5%88%86%E6%88%98%E6%9C%AF%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1-%E7%93%A6%E4%B8%81--%E7%9F%A5%E8%AF%86%E9%93%BA/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com