让我们通过介绍最常被误用的模式 — 实体来进一步讨论领域驱动设计主题。
多年来,我经历了一些类似于《土拨鼠日》的经历,我感觉每天我都跳进同样的场景中,这些都是关于DDD中实体模式的目的的持续争论。
许多软件开发人员将其与 PHP 和 Java 框架中的著名示例混合使用,其中 Entity 扮演了许多构建块的角色,从行数据网关到活动记录。
同样的误解也出现在最近流行的编程语言中,如JavaScript、Go和Python。
当人们仍然声称他们使用领域驱动设计,但他们的实体包含一大堆不同的职责时,这种误解就更奇怪了。
但是为什么会这样呢?这种错误使用是关于什么的呢?在我们的代码中使用DDD中的实体模式的正确方法是什么?
让我们来弄清楚。
真正的目的
在许多框架和实践中,或多或少地以有意义的方式设计,我们仍然看到实体模式反映了数据库结构,每个实体都代表一个表,这是一个谬论。
Entity 的目的不是反映表模式,而是保留基本的业务逻辑。
实体不映射数据库。在实现时,它们属于领域层。在那里,它们是业务逻辑的一部分,与其他价值对象和服务一起构建。
我们可以用单元测试有效地隔离和覆盖这些业务逻辑。之后,我们应该提供一个基础设施层,其中包含技术细节,例如与数据库的连接。
领域层
// Entity that contains business logic
class 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 layer
interface 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 layer
class 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):为什么重要?
欢迎关注公众号:文本魔术,了解更多