甄别实体与值对象非常重要,正确与否会直接影响聚合的设计。
聚合是边界
在DDD中,聚合是实体与值对象的边界。一个聚合对外代表了一个完整的领域概念,遵循面向对象设计的基本原则,聚合内部往往由多个细小的高内聚领域概念组成。聚合内部的领域模型形成了一棵树,树的根必须是实体,可以称之为是聚合根(Aggregate Root),当然,也可以称之为根实体(Root Entity),它是聚合的唯一入口或出口。例如订单聚合定义了Order根实体,它就是订单聚合的唯一代言人。
在一个限界上下文的所有领域模型(实体和值对象)中,按照关系的强弱与概念的完整性,将其划分为多个聚合,就好像草原部落由一个个蒙古包构成了松散的聚居社群一般。
考虑到值对象与实体的差异,倘若需要管理它们的生命周期,则值对象不可能脱离聚合的边界单独存在。这就意味着,当我们要识别领域模型的聚合时,实体与值对象之间的强弱关系并不会影响到对聚合边界的界定。只要实体与值对象之间存在关系,无论关系强弱,该值对象都必须与存在关系的实体放在同一个聚合。如果一个值对象与多个实体之间存在关系,要么说明多个实体都属于一个聚合;要么意味着该值对象需要复制为多份,放到不同的聚合中,如下图所示:
如此一来,对于聚合边界的识别,就变成了对实体关系强弱的判断。只要我们正确地甄别了实体与值对象,在识别聚合时,就可以不再考虑值对象,如此就能降低识别的难度。
上下文的影响
虽然我们知道实体与值对象之间的本质差异在于是否具备唯一的身份标识(identity),然而许多时候,这一差异仍然显得似是而非。更何况,实体与值对象的定义并非绝对,在不同的上下文,同一个领域概念也可能定义为不同的设计类型。例如下图所示的钞票一枚:
在购买上下文,买卖双方只关注钞票的面值与货币类型,只要值相等,即可认为是同一个对象,因而需定义为值对象;在印钞上下文,每张钞票都具有一个唯一的标识,即使同为100元的人民币,只要ID不同,也会认为是不同的对象,故而定义为实体。因此,要正确地甄别实体与值对象,需要结合具体的上下文。
识别的特征
即便如此,仍然缺乏相对客观的判断标准。为此,我总结了如下几个特征。
相等性
甄别实体与值对象,可以首先从相等性进行判断。只要一个领域模型对象的属性值相等,就认为是同一个对象,应优先考虑建模为值对象;否则,需要为领域模型对象定义唯一标识,并建模为实体。
注意:在进行相等性判断时,不能将作为唯一标识的ID视为领域模型的属性。
例如地址领域概念,只要其属性值国家、省份、城市、街道与邮政编码相等,就可以认为是同一个地址,应将Address类定义为值对象。对于大家耳熟能详的订单领域概念,显然需要为其分配一个唯一的订单编号,因为理论上可能存在除订单编号外其他属性都相同的两个不同订单,应将Order定义为实体。
然而,在对相等性进行判断时,可能出现ID与属性存在一种隐含的对应关系。例如,出版行业中作为正规出版物的图书,具有唯一的ISBN号,它相当于是图书领域概念的ID,所以Book应定义为实体。可在对Book相等性进行判断时,也可以不通过ISBN进行相等性判断,基本上,只要书名、作者(译者)、出版社、价格、出版日期、版次、页数、字数等属性值相同,也可以认为是同一本书,那是否意味着可以将Book定义为值对象呢?
显然,在进行相等性判断时,考虑的属性越多,就会出现多个组合的属性形成一种“隐藏”的唯一标识特征,有一些体现业务规则的ID,自身就是根据属性值来定义的。例如,航班的唯一标识就可以根据承运公司二字码、航班号、起降机场三字码与执飞日期来决定。通过唯一标识固然可以决定是否同一个航班,根据映射的多个属性值,也可以判断相等性。这会让人在甄别实体与值对象时,显得摇摆不定。例如,腾讯会议的会议号是Meeting的身份标识,在比较会议的相等性时,倘若我们考虑了除会议号之外的其他属性,如会议名称、会议类型、开始时间、结束时间、创建人、创建时间等属性,不一样可以确定会议的相等性吗?
因此,除了判断相等性,还需考虑不变性。
不变性
Eric Evans建议将值对象定义为不变的类,实则是因为根据值判等的值对象就应该具有不变性。仍以购买上下文的钞票为例,50元+50元=100元,这100元与原来的50元是另一张不同的钞票:
反之,一个对象除了ID,其余属性值都可以修改,不需要创建一个新的对象,就可以认为该领域对象是可变的,应考虑定义为实体。如前所述的Meeting对象,只要meetingId值不变,如会议名称、会议类型、开始时间、结束时间这样的属性值即使发生了天翻地覆的变化,我们也认为它是同一个会议。显然,应将Meeting定义为实体。
再考虑一个典型的订单聚合:
为什么我们要将订单聚合中的OrderItem定义为实体?如果不考虑ID属性,只要orderId、product与quantity值相同,完全可以认为是同一个订单项。然则,订单项的quantity值是可以更改的,更改了数量的订单项也不会认为是不同的订单项。订单项的可变性决定了它应该定义为实体。
为何要将OrderItem的Product属性定义为值对象呢?要知道,该Product类型还定义了productId属性,既然具有身份标识,不应该定义为实体吗?因为在订单上下文中,商品的productId来自于商品上下文的商品ID,在订单聚合中,可以将productId视为Product类的属性值。只要productId、name和price值相同,就可以认为是同一个商品,且它们的值是不变的。这正是将Product定义为值对象的原因所在。
独立性
即使考虑了相等性和不变性,仍有一种例外情形,那就是考虑独立性特征。值对象作为实体的属性必定附属于实体,不能单独存在;如果一个领域对象既满足了相等性,又满足了不变性,可定义为值对象;可是,如果它单独存在,且需要管理其生命周期,就需要将这样的类“升级”为实体。
考虑考勤上下文的假期领域概念。由于中国农历假期的缘故,每年都需要配置新的假期。假期概念对应的Holiday类定义为:
显然,该类的所有属性值相等,即可认为是同一个假期,一旦修改了假期的值,也可以认为是不同的假期,即Holiday类同时满足相等性和不变性,应定义为值对象。可是,在考勤上下文的领域模型中,Holiday类是完全独立的,不依附于其他任何实体,而它也需要管理生命周期。这时,就应遵循独立性特征,将其“升级”为实体。
优先级
以上三个特征并无重要性排列,需综合考虑。如果仍然无法判断,就遵循优先级原则:优先将领域概念建模为值对象。