One pattern to rule them all.
我喜欢一遍又一遍地观看大片和电视节目。更甚的是,我喜欢在日常对话中引用它们的名言。比如,not great, not terrible。或者,one Ring to rule them all。
我经常在工作或与朋友一起使用它们,特别是在我想强调某个特定事物的重要性时,我喜欢用《指环王》中的那个图案。
聚合是DDD中最关键的模式,如果没有它,整个战术领域驱动设计可能就没有意义了。它将业务逻辑绑定在一起。
聚合看起来像一组模式,但这是一个误解。聚合是域层的中心点。没有它,就没有使用DDD的理由。
对于聚合以及它为什么重要,有很多误解。文献也帮不上忙。这就是为什么我想更详细地指出这部分。
让我们看看这到底是怎么回事。
业务不变量
在现实的商业世界中,有些规则是灵活的:当客户从银行贷款时,需要随着时间的推移支付一定的利息。利息的总金额是可调整的,取决于投入的资本和客户将花费的时间来偿还债务。
在某些情况下,银行可能会给予宽限期,或者由于客户过去的忠诚度而提供更好的整体信贷报价,或者提供一次性报价,或者强制对房屋进行抵押。
在DDD中,我们把所有来自业务世界的灵活规则都实现为策略模式(或者更广为人知的策略模式),它们依赖于许多特定的案例,因此需要更复杂的代码结构。
在现实的商业世界中,有一些不可变的规则。无论我们如何尝试,我们都不能改变它们或它们在业务上的应用。无论对象从一种状态转换到另一种状态,这些规则都必须适用。它们被称为业务不变量。
例如,如果与客户关联的任何银行账户有钱或负债,任何人都不能删除银行的客户账户。
在许多银行,一个客户可以拥有多个同一种货币的银行账户,但是,在其中一些银行,客户不允许拥有任何外币或多个同一种货币的账户。
当这样的业务规则发生时,它们就成为业务不变量,从创建对象到删除对象,它们一直存在。
破坏它们意味着破坏整个应用程序的目的。
让我们来把握一下下面这个例子:
货币单位
class Currency {
constructor(
private id: string,
//
// some fields
//
) {}
equal(other: Currency): boolean {
return this.id == other.id;
}
}
银行账户
class BankAccount {
constructor(
private id: string,
private iban: string,
private amount: number,
private currency: Currency
) {}
get hasMoney(): boolean {
return this.amount > 0;
}
get inDebt(): boolean {
return this.amount > 0;
}
isForCurrency(currency: Currency): boolean {
return this.currency.equal(currency);
}
}
银行账户收款
class BankAccounts extends Array<BankAccount> {
get hasMoney(): boolean {
for (const account of this) {
if (account.hasMoney) {
return true;
}
}
return false;
}
get inDebt(): boolean {
for (const account of this) {
if (account.inDebt) {
return true;
}
}
return false;
}
hasCurrency(currency: Currency): boolean {
for (const account of this) {
if (account.isForCurrency(currency)) {
return true;
}
}
return false;
}
}
客户帐户实体和聚合
class CustomerAccount {
constructor(
private id: string,
private isDeleted: boolean,
private accounts: BankAccounts
//
// some fields
//
) {}
markAsDeleted() {
if (this.accounts.hasMoney) {
throw new Error("there are still money on bank account");
}
if (this.accounts.inDebt) {
throw new Error("bank account is in debt");
}
this.isDeleted = true;
}
createAccountForCurrency(currency: Currency) {
if (this.accounts.hasCurrency(currency)) {
throw new Error("there is already bank account for that currency");
}
this.accounts.push(new BankAccount(currency));
}
}
在上面的例子中,有一个 CustomerAccount
作为一个实体和聚合,除此之外,还有 BankAccount
和 Currency
作为实体。
这三个实体各自有自己的业务规则,有些是灵活的,有些是不变的,但当它们相互作用时,一些不变的规则会影响到所有实体,这就是聚合所要处理的领域。
我们有一个BankAccount
创建逻辑,它依赖于特定 CustomerAccount
的所有 BankAccounts
。在这种情况下,一个CustomerAccount
不能有多个具有相同 Currency
的 BankAccounts
。
同样,如果所有连接到 BankAccounts
的 CustomerAccount
都不在干净状态,我们也不能删除它。它们不应该构成或拥有任何资金。
业务不变量影响范围
上图显示了我们已经讨论过的三个实体的集群,它们都与业务不变量连接在一起,以确保聚合始终处于可靠的状态。
如果任何其他实体或值对象属于相同的业务不变量,那么这些新对象就成为相同聚合的一部分。
如果在同一个聚合中,我们没有一个单独的不变式将一个对象与其他对象绑定,那么该对象不属于该聚合。
边界
DDD中最大的问题之一是如何定义聚合边界,随着游戏中不断增加新的实体或价值对象,这个问题也不断出现。
聚合不仅仅是对象的集合,它是一个领域概念,它的成员定义了一个逻辑集群,如果不将它们分组,我们就无法保证它们处于有效状态。
让我们检查下一个例子:
人实体
class Person {
constructor(
private id: string,
private birthday: Date,
//
// some fields
//
) {}
get isAdult(): boolean {
const past = new Date();
past.setFullYear(past.getFullYear() - 18)
return this.getTime() <= past.getTime();
}
}
公司实体
class Company {
constructor(
private id: string,
private isLiquid: boolean,
//
// some fields
//
) {}
}
客户实体
class Customer {
constructore() {
private id: string,
private person: Person,
private company: Company,
//
// some fields
//
}
get isLegal(): boolean {
if (!!this.person) {
return this.person.isAdult;
} else {
return this.company.isLiquid;
}
}
}
在上面的代码片段中,有一个 Customer
聚合,不仅仅在这里,在很多应用中,你都会有一个名为 Customer
的实体,而且几乎总是会是聚合。
这里我们有一些业务不变式,定义了特定 Customer
的合法性,这取决于我们讨论的是 Person
还是 Company
。
当我们处理一个银行应用程序时,困境是如果 CustomerAccount
和 Customer
属于同一个聚合,它们之间有一个连接,一些业务规则绑定它们,但它们是不变量吗?
域层中的新实体
一个 Customer
可以有许多 CustomerAccounts
(或者没有),并且我们可以看到,对于 Customer
和 CustomerAccount
附近的对象,有一些业务不变量。
从不变量的精确定义出发,如果我们找不到任何一个不变量将 Customer
和 CustomerAccount
绑定在一起,那么我们应该将它们拆分成聚合。
我们引入的任何其他聚类都需要同样对待 — 它们是否与已经存在的聚合共享一些不变量?
多个聚合连接
一个好的实践是使聚合尽可能小,聚合成员被持久化在存储中(如数据库),在单个事务中添加太多的表并不是一个好的做法。
在这里我们已经看到,我们应该在聚合级别定义一个存储库,并仅通过该存储库持久化其所有成员,如下面的示例所示。
客户库
interface CustomerRepository {
search(specification: CustomerSpecification): Promise<Customer[]>;
create(customer: Customer): Promise<Customer>;
updatePerson(customer: Customer): Promise<Customer>;
updateCompany(customer: Customer): Promise<Customer>;
//
// and many other methods
//
}
我们可以定义 Person
和 Company
为实体(或值对象),但即使它们有自己的标识,我们也应该使用 CustomerRepository
从 Customer
更新它们。
直接使用 Person
或 Company
,或者在没有 Customer
和其他对象的情况下持久化它们,可能会破坏业务不变性。我们希望确保所有对象的事务都一起传递,或者如果有必要,回滚所有更改。
除了持久化,聚合的删除必须同时发生。这意味着当删除 Customer
实体时,我们必须同时删除 Person
和 Company
实体。它们没有理由单独存在。
聚合不应该太大或太小,它必须精确地与业务不变量相界,界限内的所有内容必须一起使用,而界限外的所有内容都属于其他聚合。
关系
聚合之间存在关系,这些关系应该始终在代码中,但它们必须尽可能简单。
为了避免复杂的连接,我们首先应该避免引用聚合,而是使用身份来表示关系 — 下面的代码片段中有一个示例。
让我们检查下一个例子:
错误的实现
class CustomerAccount {
constructor() {
private id: string,
//
// some fields
//
private customer: Customer, // the wrong way with referencing
//
// some fields
//
}
}
正确的实现
class CustomerAccount {
constructor() {
private id: string,
//
// some fields
//
private customerId: string, // the right way with identity
//
// some fields
//
}
}
另一个问题可能是关系的方向。最好的情况是我们在他们之间有一个单向的连接,我们避免任何双向的。
这不是一个简单的决定过程,它取决于我们在有限上下文中的用例。
如果我们为自动取款机编写软件,其中用户使用借记卡与CustomerAccount
交互,那么我们有时会通过 CustomerAccount
中的标识访问 Customer
。
在另一种情况下,我们的Bounded Context可能是管理一个 Customer
中所有 CustomerAccounts
的应用程序。
用户可以授权和操作所有的 BankAccounts
。在这种情况下,Customer
应该包含与 CustomerAccounts
关联的 Identities 列表。
聚合根
文档中所有的聚合都与一些实体有相同的名字,比如 Customer
实体和聚合,这些唯一的实体是聚合的根和聚合中的主体对象。
聚合根是访问所有其他实体、值对象和集合内部的网关。我们不应该直接更改聚合的成员,而应该通过聚合根。
Aggregate Root 公开了表示其丰富的行为的方法。它应该定义访问内部属性或对象以及操作数据的方法。
即使聚合根返回一个对象,它也应该返回它的一个副本(尽管这在JavaScript中相当困难)。
更多关于客户账户的信息
class CustomerAccount {
constructor() {
private accounts: BankAccounts[],
}
getIBANForCurrency(currency: Currency): string {
for (const account of this.accounts) {
if (account.isForCurrency(currency)) {
return account.iban;
}
}
throw new Error("this account does not support this currency");
}
markAsDeleted() {
if (this.accounts.hasMoney) {
throw new Error("there are still money on bank account");
}
if (this.accounts.inDebt) {
throw new Error("bank account is in debt");
}
this.isDeleted = true;
}
createAccountForCurrency(currency: Currency) {
if (this.accounts.hasCurrency(currency)) {
throw new Error("there is already bank account for that currency");
}
this.accounts = append(ca.accounts, NewBankAccount(currency))
}
addMoney(amount: number, currency: Currency) {
if (this.isDeleted) {
throw new Error("account is deleted");
}
if (this.isLocked) {
throw new Error("account is locked");
}
return this.accounts.AddMoney(amount, currency);
}
}
由于聚合包含多个实体和值对象,因此它内部会出现许多不同的标识。在这些情况下,有两种类型的标识。
聚合根有一个全局身份。该身份是全局唯一的,并且在应用程序中没有任何地方可以找到具有相同身份的实体。我们可以从聚合的外部引用聚合根的身份。
人实体
class Person {
constructor(
private id: string, // local identity
//
// some fields
//
) {}
}
公司实体
class Company {
constructor(
private id: string, // local identity
//
// some fields
//
) {}
}
客户实体
class Customer {
constructore() {
private id: string, // global identity
//
// some fields
//
}
}
聚集内的所有其他实体都有本地标识。这些标识仅在聚集内是唯一的,但在聚集外可能重复。只有聚集保存有关本地标识的信息,我们不应该在聚集外引用它们。
结论
聚合是业务不变式定义的一个领域概念。业务不变式定义了在应用程序的任何状态下都必须有效的规则。它们代表了聚合的边界。
TypeScript领域驱动设计(DDD)系列:
1.
TypeScript中的实用领域驱动设计(DDD):为什么重要?
3.
在TypeScript中实践DDD(领域驱动设计):实体
4.
在TypeScript中实践DDD(领域驱动设计):域服务
欢迎关注公众号:文本魔术,了解更多