这就是为什么我们可以认为值对象模式是领域驱动设计中最大的代码质量改变者。
我喜欢恐龙。除非有人拿恐龙来嘲笑我的年龄。不幸的是,这变得不可避免,特别是在IT行业。我们的尖端技能总有一天会变成恐龙技能。
我第一次测试 TypeScript 是在 2013 年的早春,从那以后,我一直在我所有的项目中使用它,甚至包括我的学士学位项目。
2017年,我告诉自己,与其他编程语言约会会很好,所以我转向了Golang。
去年我重返 TypeScript 和一些框架,比如 NestJS 和 NextJS,我第一次接触到新版本的 TypeScript 时,第一句话是:“这太奇怪了,以前我们有......”,停,我意识到我是个老古董。
幸运的是,这五年错过的 TypeScript,我用来塑造我的其他技能,如域驱动设计。这是一个好的选择。当我使用一些支持 TypeScript 的后端相关框架时,我注意到了对代码质量的直接影响。
我们的代码库变得更易维护、更易单元测试、更稳定、更少bug.当然,我们是逐渐过渡的,我们引入的第一个代码块就是我现在要向你介绍的——Value Object模式。
简约的艺术
值对象可能是领域驱动设计中最有影响力的模式,Martin Fowler的《红皮书》对它进行了很好的介绍,Eric Evans的《蓝皮书》对领域驱动设计的用法进行了更好的介绍。
Value Object 可以将几个属性组合成单个单元,该单元可以传递特定的行为,该单元代表我们在现实世界中可以找到的一些质量或数量,并绑定到一些更复杂的对象。
它传递一些特定的值或特征,可以是颜色或货币(Value Object 的一个子类型)、电话号码或任何其他提供一些值的小对象,如下面的代码块所示。
数量
class Money {
constructor(
private amount: number,
private currency: Currency
) {}
add(amount: number): Money {
return new Money(this.amount + amount, this.currency);
}
}
质量
class Color {
constructor(
private red: number,
private green: number,
private blue: number
) {}
get css(): string {
return `rgb(${this.red}, ${this.green}, ${this.blue})`;
}
}
逻辑组
class Phone {
constructor(
private countryPrefix: string,
private areaCode: string,
private number: string
) {}
get fullNumber(): string {
return `${this.countryPrefix} ${this.areaCode} ${this.number}`;
}
}
类型扩展
class Salutation extends String {
get isPerson(): boolean {
return String(this) !== 'company'
}
}
在 TypeScript 中,我们可以将值对象表示为独立的类或通过扩展一些 JavaScript 原生类型,这两种方法都旨在为单个值或一组值提供独特的附加行为。
在许多情况下,Value Object 可以提供特定的字符串格式化方法,以定义值在 JSON 编码或解码中的行为,但这些方法的主要目的应该是支持与实际生活中的特征或质量绑定的业务不变量。
直言不讳
值对象最有价值的特性是它的显性,当原始类型不支持特定行为或行为不直观时,它为外部世界提供了清晰性。
例如,我们可以在许多项目中处理客户,这些客户必须满足一些业务不变量,比如作为成年人或代表某个法律实体。
延长日期
class Birthday extends Date {
public isYoungerThen(other: Date): boolean {
return this.getTime() > other.getTime();
}
get isAdult(): boolean {
const past = new Date();
past.setFullYear(past.getFullYear() - 18)
return this.getTime() <= past.getTime();
}
}
与枚举结合
enum LegalFormEnum {
Freelancer = 1,
Partnership,
LLC,
Corporation,
}
class LegalForm extends Number {
get isIndividual(): boolean {
return Number(this) === LegalFormEnum.Freelancer
}
get hasLimitedResponsability(): boolean {
const number = Number(this);
return number === LegalFormEnum.LLC ||
number === LegalFormEnum.Corporation;
}
}
有时值对象不需要明确定义为任何其他实体或值对象的一部分,但我们可以将值对象定义为帮助对象,以便在代码中以后使用。
这就是处理 Customer
的情况,它可以是 Person
或 Company
。根据 Customer
的类型,我们在应用程序中有不同的流程。更好的方法之一是客户的转换,以更明确地处理它。
客户类型
interface Person {
fullName: string;
birthday: Birthday;
}
interface Company {
name: string;
creationDate: Date;
}
基础客户
class Customer {
constructor(
private id: string,
private name: string,
private legalForm: LegalForm,
private date: Date
) {}
get asPerson(): Person {
return {
fullName: this.name,
birthday: new Birthday(this.date),
};
}
get asCompany(): Company {
return {
name: this.name,
creationDate: this.date,
};
}
}
虽然在一些项目中可能会出现转换的情况,但在大多数情况下,它们告诉我们应该将那些值对象作为域模型的实际部分添加进来。在这种情况下,这意味着我们应该将 Person
和 Company
作为完整的实体引入进来。
当我们注意到一些特定的小字段组不断地相互交互,但它们位于一些更大的组中时,这已经是一个迹象,表明我们应该将它们分组到 Value Object 中,并在我们的更大的组(现在变小了)中使用它。
身份与平等
Value Object 没有标识,这是它与 Entity 模式之间的关键区别。
实体模式有一个身份作为其唯一性的描述。如果两个实体具有相同的身份,那么它暗示我们正在讨论相同的对象。
实体平等
class Currency {
constructor(
private id: number
) {}
public equalTo(other: Currency): boolean {
return this.id === other.id;
}
}
值对象没有这样的身份。值对象只有一些字段,这些字段更好地描述了它的值。为了测试两个值对象之间的相等性,我们必须检查所有字段的等价性,就像下面的代码块一样。
值对象的相等性
class Color {
constructor(
private red: number,
private green: number,
private blue: number
) {}
public equalTo(other: Color): boolean {
return this.red === other.red &&
this.green === other.Green &&
this.blue === other.blue;
}
}
class Money {
constructor(
private amount: number,
private currency: Currency
) {}
public equalTo(other: Money): boolean {
return this.amount === other.amount &&
this.currency.equalTo(other.currency);
}
}
在上面的例子中,Money
和 Color
类都有 EqualTo
方法来检查它们所有的字段,而 Currency
类中的方法则检查相同的身份。
值对象也可以引用一些实体对象,比如在本例中是
Money
和Currency
,它还可以包含一些其他的更小的值对象。
不变性
值对象是不可变的。在值对象的生命周期中,没有任何单一的原因、理由或其他参数可以改变其状态。
有时多个对象可以包含一个相同的Value Object(尽管这不是一个完美的解决方案),在这些情况下,我们不想在一些意想不到的地方改变Value Object,比如直接改变它的内部状态。
错误的方法
class Money {
constructor(
private amount: number,
private currency: Currency
) {}
// wrong way to change the state inside value object
public add(amount: number) {
this.amount = this.amount + amount;
}
// wrong way to change the state inside value object
public deduct(amount: number) {
this.amount = this.amount - amount;
}
}
class Color {
constructor(
private red: number,
private green: number,
private blue: number
) {}
// wrong way to change the state inside value object
public keepOnlyGreen() {
this.red = 0
this.blue = 0
}
}
因此,无论何时我们想要改变Value Object的内部状态或组合多个内部状态,我们都必须创建一个新的实例,并将新状态作为返回值,如下面的代码块所示。
正确的方法
class Money {
constructor(
private amount: number,
private currency: Currency
) {}
// right way to create a new value object
public withAmount(amount: number): Money {
return new Money(
this.amount + amount,
this.currency
);
}
// right way to create a new value object
public deductedWith(amount: number): Money {
return new Money(
this.amount - amount,
this.currency,
);
}
}
class Color {
constructor(
private red: number,
private green: number,
private blue: number
) {}
// right way to create a new value object
public withOnlyGreen(): Color {
return new Color (
0,
this.green,
0,
);
}
}
这种不变性意味着我们不应该在 Value Object 的整个生命周期中验证它,而应该只在创建时验证它,就像上面的例子中所示的那样。
当我们想要创建一个新的Value Object时,我们必须始终执行验证,如果业务不变量没有满足,则返回错误,并且只有在Value Object是有效的时才创建Value Object。从那一刻起,就不再需要验证Value Object了。
丰富的行为
值对象传递了许多不同的行为。它的主要目的是提供一个可及的接口。如果它是贫血的,我们应该考虑一下它没有任何方法存在的原因。
如果Value Object在代码的某些特定位置有意义,那么它为业务不变量提供了很大的支持,可以更好地描述我们想要解决的问题。
扩展颜色的功能
class Color {
constructor(
private red: number,
private green: number,
private blue: number
) {}
get brighter(): Color {
return new Color(
Math.min(255, this.red + 10),
Math.min(255, this.green + 10),
Math.min(255, this.blue + 10),
);
}
get darker(): Color {
return new Color(
Math.max(0, this.red - 10),
Math.max(0, this.green - 10),
Math.max(0, this.blue - 10),
);
}
public combine(other: Color): Color {
return new Color(
Math.min(255, this.red + other.red),
Math.min(255, this.green + other.green),
Math.min(255, this.blue + other.blue),
);
}
get isRed(): boolean {
return this.red === 255 && this.green === 0 && this.blue === 0;
};
get isYellow(): boolean {
return this.red === 255 && this.green === 255 && this.blue === 0;
}
get isMagenta(): boolean {
return this.red === 255 && this.green === 0 && this.blue === 255;
}
get css(): string {
return `rgb(${this.red}, ${this.green}, ${this.blue})`;
}
}
将整个域模型分解成像值对象(和实体)这样的小块,使得代码清晰,并接近现实世界中的业务逻辑。
每个Value Object可以描述一些小的组件,并支持许多类似于日常业务流程的行为。最后,这使得整个单元测试更加舒适,并有助于覆盖所有边缘情况。
什么时候是实体,什么时候是值对象?
在一个有界上下文中,我们可以有几十个值对象,其中一些甚至可以是其他有界上下文中的实体。
这就是 Currency
的情况。在一个简单的 Web 服务中,我们希望呈现一些关于货币的信息,我们可以将 Currency
视为一个值对象,我们不打算更改它。
货币作为价值对象
class Currency {
constructor(
private code: string,
private html: string
) {}
}
另一方面,在支付服务上,我们希望使用一些交换服务API进行实时更新,在这种情况下,我们需要使用域模型中的身份。在这种情况下,我们将有不同的 Currency
实现,作为一个实体。
作为实体的货币
class Currency {
constructor(
private id: number,
private code: string,
private html: string
) {}
}
我们想要使用的模式,无论是Value Object还是Entity,只取决于对象在Bounded Context中表示什么。
如果它是一个可重用的对象,独立存储在数据库中,可以改变和应用于许多其他对象,或与一些外部实体耦合,当外部实体改变时,我们就谈论实体。
但是,如果一个对象描述了一些值,属于一个特定的实体,是一个来自外部服务的简单拷贝,或者它不应该独立存在于数据库中,那么它就是一个值对象。
结论
现实世界充满了不同的特征、质量和数量。当软件应用程序试图解决现实世界的问题时,使用这些量词是不可避免的。值对象作为一种解决方案被呈现给我们,以解决业务逻辑中的这种明确性。
TypeScript领域驱动设计(DDD)系列:
1. TypeScript中的实用领域驱动设计(DDD):为什么重要?
欢迎关注公众号:文本魔术,了解更多