TypeScript中的DDD实践(七):聚合

本文探讨了在TypeScript的领域驱动设计(DDD)中,聚合模式的核心概念,包括业务不变量的重要性、如何通过策略模式处理灵活和不变的规则,以及如何定义和维护聚合边界。作者强调了聚合根在领域模型中的角色,以及在处理实体、值对象和边界时的最佳实践。
摘要由CSDN通过智能技术生成

图片

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):为什么重要?

2. TypeScript 中 DDD 的实践:值对象

3. 在TypeScript中实践DDD(领域驱动设计):实体

4. 在TypeScript中实践DDD(领域驱动设计):域服务

5. TypeScript中的DDD实践(五):域事件

欢迎关注公众号:文本魔术,了解更多

  • 16
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值