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

图片

让我们通过介绍最常被误用的模式 — 实体来进一步讨论领域驱动设计主题。

多年来,我经历了一些类似于《土拨鼠日》的经历,我感觉每天我都跳进同样的场景中,这些都是关于DDD中实体模式的目的的持续争论。

许多软件开发人员将其与 PHP 和 Java 框架中的著名示例混合使用,其中 Entity 扮演了许多构建块的角色,从行数据网关到活动记录。

同样的误解也出现在最近流行的编程语言中,如JavaScript、Go和Python。

当人们仍然声称他们使用领域驱动设计,但他们的实体包含一大堆不同的职责时,这种误解就更奇怪了。

但是为什么会这样呢?这种错误使用是关于什么的呢?在我们的代码中使用DDD中的实体模式的正确方法是什么?

让我们来弄清楚。

真正的目的

图片

在许多框架和实践中,或多或少地以有意义的方式设计,我们仍然看到实体模式反映了数据库结构,每个实体都代表一个表,这是一个谬论。

Entity 的目的不是反映表模式,而是保留基本的业务逻辑。

实体不映射数据库。在实现时,它们属于领域层。在那里,它们是业务逻辑的一部分,与其他价值对象和服务一起构建。

我们可以用单元测试有效地隔离和覆盖这些业务逻辑。之后,我们应该提供一个基础设施层,其中包含技术细节,例如与数据库的连接。

领域层

// Entity that contains business logicclass BankAccount {  constructor(    private id: number,    private isLocked: boolean,    private wallets: Wallet[],    private owner: Owner,) {}    public unlock() {    this.isLocked = false;  }    get hasFunds(): boolean {    if (this.isLocked) {      return false;    }
    for (const wallet of this.wallets) {      if (wallet.hasFunds) {        return true;      }    }
    return false;  }
  // the rest of the business logic}
// Repository interface inside domain layerinterface BankAccountRepository {  find(id: number): Promise<BankAccount>;}


基础设施层​​​​​​​

// DAO inside infrastructure layer@Entity()class BankAccountDAO {  @PrimaryGeneratedColumn()  id: number;    @Column({ name: 'is_locked' })  isLocked: boolean;    @OneToMany(() => WalletDAO, (wallet) => wallet.bankAccountId)  wallets: WalletDAO[];    @OneToOne(() => Profile)  @JoinColumn()  owner: OwnerDAO;    get toEntity(): BankAccount {    return new BankAccount(      this.id,      this.isLocked,      this.wallets.map(wallet => wallet.toEntity),      this.owner.toEntity    );  }}
// Actual implementation of Repository inside infrastructure layer@EntityRepository(BankAccountDAO)class BankAccountDBRepository extends Repository<BankAccountDAO> implements BankAccountRepository {
  // Retrival of records from database  public async findOne(id: number): Promise<BankAccount> {    let dao: BankAccountDAO;    //    // some code    //    return dao.toEntity;  }}

在上面的例子中,实体和它在数据库中的表示之间有一个分离。域层中的实体代表了领域驱动设计中的真实实体,业务逻辑的持有者。

在基础设施层中,我们有一个对象,它反映了数据库模式,遵循其中一种模式:数据访问对象或数据传输对象。

我们总是将Repository接口放在领域层上,在这个层中,一些域服务可能依赖于它们,所以它们至少应该知道它们的存在。

Repository 提供了一个契约,保证我们可以处理来自 Domain 层的 Entity 对象。在 Repository 中,我们可以处理任何我们想要处理的对象,只要我们提供准确的结果。

这就是为什么BankAccountRepository接口为我们的代码提供 BankAccount 实体作为输出的实际原因,这样一来,我们的域层就只依赖于包含业务逻辑的实体。

在基础设施层内部,我们有一个具体的存储库实现,BankAccountDBRepository. 在内部,它使用 BankAccountDAO 来处理底层数据库,但它将其转换为 BankAccount

有趣的是,我们赋给 BankAccountDAO 的来自 TypeOrm 本身的装饰器 Entity 进一步支持了 DDD 中 Entity 模式的这种普遍滥用。

在TypeOrm中,Entity被用作数据库表的映射器,但这不是领域驱动设计中的Entity的情况,如果你使用DDD Entity将其映射到数据库,这是一个基本错误!

这个结构是将业务逻辑与下面的技术细节解耦的一种变体。只要对数据库进行一些更改,只有将 DAO/DTO 转换为 Entity 或反之的映射方法必须进行更改。

有时,实体会反映复杂的业务逻辑,数据来自多个地方,如数据库、NoSQL和一些外部API,特别是在这些情况下,将业务层与技术细节分离的想法更有价值。

身份

图片

与值对象的主要区别是身份。实体具有身份。身份是实体的唯一属性,可以定义每个实体的唯一性。

两个实体可以在一个或多个字段中只有一个细微的差别。如果它们具有相同的 Identity,那么我们就讨论相同的实体。为此,当我们检查它们是否相等时,我们只检查它们的 Identity。​​​​​​​

class Currency {  constructor(    private id: number,    private code: string,    private name: string,    private htmlCode: string,) {}    isEqual(other: Currency): boolean {    return other.id == this.id;  }}

有三种类型的标识,它们可以由应用程序生成,这意味着,在将它们发送到存储器中创建之前,我们在应用程序中为它们创建一个新的标识,比如 UUID。

第二种可能性是使用自然身份。当我们想要处理现实世界中具有独特属性的人或物体时,我们可以处理他们的生物标识符。例如,可以是身份证号码。

第三,最常用的方法是数据库生成的标识,即使我能够实现前面两种解决方案,我也会使用这种方法。

应用程序生成的标识​​​​​​​

class Currency {  private id: string;
  constructor(    private code: string,    private name: string,    private htmlCode: string,) {    this.id = uuidv4();  }}


自然的身份​​​​​​​

class Person {  constructor(    private ssn: string, // social security number    private firstName: string,    private lastName: string,    private dateOfBirth: Date) {}}


数据库生成的​​​​​​​

// domain layerclass BankAccount {  constructor(    private id: number,    private isLocked: boolean,    private wallets: Wallet[],    private owner: Owner,) {}}
// infrastructure layer@Entity()class BankAccountDAO {  @PrimaryGeneratedColumn()  id: number;    @Column({ name: 'is_locked' })  isLocked: boolean;    @OneToMany(() => WalletDAO, (wallet) => wallet.bankAccountId)  wallets: WalletDAO[];    @OneToOne(() => Profile)  @JoinColumn()  owner: OwnerDAO;}

在前两种方法中,为了优化表的使用,最好仍然在数据库中使用纯整数作为主键。我们可以在以后将数据库外部使用的标识定义为存储中的索引字段。

对于索引和查询,我们应该只使用数字,在许多情况下,当处理应用程序生成的键或自然键时,我们应该找到一种方法来正确地将这些文本映射到数据库中的数值。

验证

图片

与Value Object相比,Entity可以在其生命周期内改变其状态。这意味着,当我们想要改变Entity时,它需要持续的验证检查。

无论何时我们想在实体内部进行任何类型的更改,我们都应该使用适当的数据封装,并在其内部状态中验证每个更改。​​​​​​​

class BankAccount {  constructor(    private id: number,    private isLocked: boolean,    private wallets: Wallet[],    private owner: Owner,) {}    public add(other: Wallet) {    if (this.isLocked) {      throw new Error("account is locked")    }        // do something  }}
class Wallet {  constructor(    private id: number,    private currency: Currency,    private amount: number,) {}    public deduct(amount: number) {    if (this.amount < amount) {      throw new Error("there is no enough money in wallet")    }
    // do something  }}

我们可以发现,BankAccount 和 Wallet 都允许对其状态进行一些更改,但是它们都不会直接向外部方公开数据。它们有责任接受或拒绝对其状态的任何更改。

推动行为

图片

领域驱动设计的全部目的是尽可能地反映业务流程。

由于实体保持了所有其他方中最复杂的状态,它们可能也拥有表示其丰富行为的最多方法。

有时Entity中的几个字段会不断地相互作用,在这种情况下,最好将这些字段分组到一个单独的Value Object中,并将其给Entity处理。

我们需要小心处理,以避免实体和值对象之间的关系不清晰的情况。为了简单起见,我们将使 Wallet 成为 BankAccount 中的单个值对象:

错误的方法​​​​​​​

class Wallet {  constructor(    public currency: Currency,    public amount: number,) {}}
class BankAccount {  constructor(    private id: number,    private isLocked: boolean,    private wallet: Wallet) {}    public deduct(walletId: number, other: Wallet) {    if (this.isLocked) {      throw new Error("account is locked");    }
    if (!other.currency.isEqual(this.wallet.currency)) {      throw new Error("currencies must be the same");    }    if (other.amount > this.wallet.amount) {      return new Error("insufficient funds");    }        this.wallet = new Wallet(      this.wallet.amount - other.amount,      this.wallet.currency,    );  }}

在上面的例子中,BankAccount 实体从 Wallet 值对象中承担了更多的责任,当检查 BankAccount 是否锁定的部分是明确的,但是,检查 Currency 的相等性和 Wallet 中是否有足够的数量是一个代码异味。

在这些情况下,扣除逻辑应该在Value Object中,当然,除了检查 BankAccount 是否被锁定。像这样,Wallet得到验证和扣除金额的代码部分。

正确的方法​​​​​​​

class Wallet {  constructor(    public currency: Currency,    private amount: number,) {}
  public deduct(other: Wallet): Wallet {    if (!other.currency.IsEqual(this.currency)) {      throw new Error("currencies must be the same");    }    if (other.amount > this.amount) {      throw new Error("insufficient funds");    }        return new Wallet(      this.amount - other.amount,      this.currency,    );  }}
class BankAccount {  constructor(    private id: number,    private isLocked: boolean,    private wallet: Wallet) {}    public deduct(walletId: number, other: Wallet) {    if (this.isLocked) {      throw new Error("account is locked");    }        this.wallet = this.wallet.deduct(other);  }}

像这样,Wallet 值对象可以属于任何其他实体或值对象,并且它仍然可以支持扣除,这取决于它的内部状态。另一方面,BankAccount 可以为锁定的帐户提供额外的金额扣除方法,而无需复制相同的逻辑。

与域服务的关系

图片

一个实体可以将其行为推送到其他构建块,如域服务。

有时我们可能会决定将一些方法从实体转移到域服务。这种决定通常发生在两种情况下:

第一种情况是行为太复杂,可能需要使用规范、策略或其他实体和价值对象,可能依赖于来自存储库或其他服务的结果。

在第二种情况下,没有复杂的行为,但可能发生逻辑不清楚的情况,它可能属于一个实体,也可能属于另一个实体,或者某个值对象。

实体内部的复杂行为​​​​​​​

class ExchangeRates extends Array<ExchangeRate> {}
class Currency {  constructor(    private id: number) {}    exchange(to: Currency, other: Wallet, rates: ExchangeRates): Wallet {    //    // do something    //  }}


复杂行为转移到域服务中​​​​​​​

class Currency {  constructor(    private id: number) {}}
class ExchangeRatesService {  private repository: ExchangeRatesRepository;    exchange(to Currency, other Wallet): Wallet {    //    // do something    //  }}

在上面的例子中,Currency  Entity 有 Exchange 方法,这个方法已经有太多的参数了,问题是这个方法是否应该属于 Wallet  Value Object或 ExchangeRate  Entity。

此外,由于政治争端或经济原因,某些货币的交换可能会被暂时禁止。这样,我们将为 Currency 实体带来更多的业务不变量。

当业务逻辑过大时,我们应该将其移动到一个单独的域服务中,如上面的示例中的  ExchangeRatesService 

贫血域模型

图片

有时候将行为推到其他构建块看起来是一件很自然的事情,但是我们应该非常小心处理它。

将太多的行为从实体转移到域服务会导致另一个代码异味,贫血域模型。​​​​​​​

class TransactionService {  //  // some fields  //    add(account: BankAccount, second: Wallet) {    if (account.IsLocked) {      return new Errors("account is locked");    }
    //    // do something    //  }}


上面的例子显示了TransactionService域服务。这个服务从 BankAccount 实体中承担责任。如果我们不需要检查复杂的业务不变量,那么这个行为就不属于域服务。

结论

实体和值对象一起,提供了一种保存和操纵应用程序内部状态的方法,它们为业务逻辑提供了巨大的支持,并且有许多关于如何正确实现它们的良好实践。



TypeScript领域驱动设计(DDD)系列:
 

1. TypeScript中的实用领域驱动设计(DDD):为什么重要?

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

  • 18
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值