带有数据库ID的值对象和实体在DDD类设计中的困境

13

这是一个很长的问题,我将直接进入主题。下面是伪代码,更好地说明了问题。

数据库结构

用户(UserID,Name,LastName)

地址(AddressID,UserID,Street,City,State,ZipCode)=>与用户之间存在多对一关系

电话(PhoneID,UserID,Number,IsPrimary)=>与用户之间存在多对一关系

领域类

class User:IEntity
{
public string Name {get;set;}
public string LastName {get;set;}
public ContactInfo{get;set;}
}

class Phone: IValueObject or IEntity? will see later.
{
public int id; // persistence ID, not domain ID
public string Number {get;set;}
}

class Address: IValueObject or IEntity? will see later.
{
public string Line1 {get;set;}
public string City {get;set;}
public string State {get;set;}
public string ZipCode {get;set;}
}

class ContactInfo: IValueObject or IEntity? will see later.
{
List<Address> Addresses {get;set;}
List<Phone> PhoneNumbers {get;set;}
}

到目前为止,我们对于这个领域及其模型有一个非常基本的表示。

我的问题是:假设我想更新其中一个地址或者修正一个号码的区号,因为在最初输入时出现了错误拼写。

如果我遵循Evan的《领域驱动设计》中的价值对象概念,那么价值对象应该是不可变的,也就是说,在创建之后不能更改其属性或字段。 如果是这种情况,那么我想,我的所有类都不是价值对象,因为我不能仅仅因为电话号码中的某一部分字符串错误就重新创建整个ContactInfo类。那么,我想这样就把所有的类都变成了实体类?

请记住,每个类都有一个“持久性ID”,因为它们存储在数据库中。

假设我决定将Phone作为一个值对象,因为它可以在构造函数中轻松重建。

public Phone(string newNumber)

所以,这就像是将一个方法添加到用户(聚合根)和联系信息中一样?(迪米特法则)

就像...

User....
public void UpdatePrimaryPhoneNumber(string number)
{
this.ContactInfo.UpdatePrimaryPhoneNumber(number);
}

ContactInfo....
public void UpdatePrimaryPhoneNumber(string number)
{
var oldPhone = Phones.Where(p=>p.IsPrimary).Single();
var newPhone = new Phone(number, oldPhone.persistenceid???-> this is not part of the domain)
oldPhone = newPhone;
}

但我仍然要处理持久化id... 嗷!真是个头疼的问题。

有时候,当我阅读那些博客时,我觉得大多数“ddd专家”并没有正确使用价值对象,或者说过度使用了。

针对这种情况,什么是最好的解决方案呢?谢谢。


8
四年后,当我再次阅读我的问题时……哦天啊。这是一条漫长的路。我给那些寻找关于DDD答案的人的唯一建议是……要开放心态。不要感到沮丧。未来会更好。随着你编写DDD相关代码,事情会变得更加顺利。我记得那些想要烧掉与DDD相关的每一本书的日子。现在我把它们放在床头柜旁边。 - Pepito Fernandez
你推荐的领域驱动设计(DDD)方面的书籍有哪些? - Markus Pscheidt
每个博客都会提到的“老生常谈”。有一些免费的在线PDF确实不错。我能给出的最好建议不仅是阅读书籍,还要参与论坛,查看代码,最重要的是...无论项目大小,始终编写DDD代码。这将使您在该主题上拥有深度、经验等。编码是我能推荐的最好的书籍。注意!大量的重构工作即将展开。时不时地查看自己的代码,以便自我认识自己已经走了多远。我4年前的一些代码让我哭笑不得。享受旅程吧! - Pepito Fernandez
@PepitoFernandez 这又是四年了,想知道你一路上还有什么其他的想法?我快没有DDD书可烧了。 - Stark
1
@Craig 再次强调,不断编写代码直到疲惫。这将变成几乎是肌肉记忆的东西。DDD并不是一个困难的概念,但需要时间才能理解。有一些技巧可以帮助您纠正路径。例如,如果您发现自己修改业务对象,甚至在AR之外检查值if... then... else,那已经是一个坏迹象了。但这种事情只能通过不断重复来发展。微服务使它更容易,因为您的AR和Bounded Context的逻辑分离现在是物理的。没有跨引用AR的风险等。 - Pepito Fernandez
3个回答

11

如果我按照Evan的DDD圣经,价值对象应该是不可变的。这意味着,在创建后不能更改其属性或字段。如果是这样的话,那么我想,我的任何一个类都不是一个价值对象,因为我不能仅仅因为电话号码中的一部分字符串错误就重新创建整个ContactInfo类。所以,我想这使得我的所有类都成为了实体类?

虽然VO本身可能是不可变的,但VO并不存在于独立空间——它总是作为聚合的一部分存在。因此,VO可以是不可变的,但引用该VO的对象不必是不可变的。帮助我理解VO的方式是将其与原始Int32值之类的东西进行比较。每个整数的值都是不可变的——5始终是5。但在任何有Int32的地方,您都可以设置另一个值。

对于您的领域,这意味着您可以拥有一个不可变的地址VO,但给定的使用实体可以引用任何地址VO的实例。这就允许进行更正和任何其他更改。您不会更改地址VO上的个别字段——您需要用一个全新的VO实例替换它。

其次,“持久化ID”不应在领域代码中表达。它们仅存在于满足关系数据库需求的情况下,而NoSQL数据库根本不需要它们。

主要电话场景应该更像这样:

public void UpdatePrimaryPhoneNumber(string number)
{
  var existingPrimaryNumber = this.Phones.FirstOrDefault(x => x.IsPrimary == true);
  if (existingPrimaryNumber != null)
      this.Phones.Remove(existingPrimaryNumber);
  this.Phones.Add(new Phone(phoneNumber: number, isPrimary = true));
}

这种方法包装了更新现有主要电话号码的想法。电话号码VO是不可变的,这意味着您必须删除现有值并用新值替换它。通常在数据库端发生的情况,特别是使用ORM(如NHibernate),它将发出SQL删除和随后的插入以有效替换所有电话号码。这没问题,因为VO的ID并不重要。


谢谢您的回答。有一个问题,使用您的代码示例,如果我保留我的ContactInfo类呢?它不能独立存在(虽然我仍然不明白这意味着什么),所以它是一个值对象...因此是不可变的。问题是,在我的领域中,如果要更新电话号码,应该如何处理?当我只想更改电话号码时,是否需要一个全新的ContactInfo类?我认为总的来说,DDD不适用于Web应用程序,因为大多数对象都是在每个Web请求上下文中创建和销毁的。除非您将所有内容都放入会话中(非常糟糕)。现在比24小时前更加困惑了 :) - Pepito Fernandez
VO并不一定是不可改变的,只是简化了各种场景,比如对象关系映射。此外,你不必过分担心创建一些额外的类(因此产生的对象实例) - 这不会影响Web应用程序。 - eulerfx
你是指ValueObject在数据库中的ID吗?当然很重要。如果Phone的PK在其他表中被用作FK,那怎么办?你会失去那个引用吗?如果我们有一个带有PhoneFK的CallHistory表,删除电话不会删除历史记录,它会将其留空。明白我的意思了吗? - Pepito Fernandez
理想情况下,电话号码的主键不应该作为外键出现在其他地方,因为这违反了电话号码所属聚合物的边界。如果您有一个通话历史记录表,它应该存储电话号码的一个非规范化副本。毕竟,如果电话号码更改,它并不会改变通话历史记录。 - eulerfx
什么?违反聚合...什么?数据库对聚合一无所知,就像DDD对持久性一无所知一样。我给你举个更好的例子。邮寄地址。假设它是不正确的,错误的邮政编码,并在表MailingHistoryEvent中创建了一个“退回邮件”事件。我仍然希望能够提取与地址PK相关的所有事件,而不管最初是否有不同的邮政编码。您建议的做法将使我执行复杂的搜索以查找“相同”地址的匹配记录。你不这么认为吗? - Pepito Fernandez
1
如果您真的想保留ID,则应实现一个NHibernate组件映射,该映射在内部管理ID。关系型数据库难以表示聚合的事实是NoSQL(其中聚合与文档一对一)成为DDD应用程序的流行替代方案的原因之一。关系模型使得创建事物之间的关系非常容易,并且可能会让人们认为所有事物都应该相关联。但问题在于这种方法不可扩展,最终会导致摩擦。 - eulerfx

10

一个实体具有独特而个别的生命周期。只有它单独存在时才有意义。

经典示例Order/OrderItem,这可能有所帮助。 如果一个OrderItem成为实体,那么它将拥有自己的生命周期。然而,这并没有太多意义,因为它是订单的一部分。当查看订单时,这总是显而易见的,但当查看自己的类时可能就不太明显,因为类之间可能存在一些引用关系。例如,OrderItem代表我们销售的某个Product。一个Product有自己的生命周期。我们可以拥有一个独立的Product列表。如何建模OrderItemProduct之间的链接可能是另一个讨论,但我会将所需的Product数据去规范化到OrderItem中,并存储原始的Product.Id

那么,Address类是实体还是值对象?这通常是一个有趣的问题,我们有那些最喜欢的答案:它取决于情况。

这将是与上下文相关的。但是,请问自己是否有(或需要)一份独立的Address列表,然后只需要在您的User中链接到该Address。如果是这种情况,则它是一个实体。如果Address仅在作为用户的一部分时才有意义,则它是一个值对象。

一个值对象是不可变的,并不意味着你需要替换更多的东西,只需要替换特定的值对象。我不确定你的当前设计中是否需要一个ContactInfo类,因为它只包装了两个集合(Address/PhoneNumber),但如果有更多内容可以保留它(可能会有)。简单地用相关的PhoneNumber替换即可。如果你有类似于主/副的情况,那么做法非常简单: AR.ReplacePrimaryPhoneNumber(new PhoneNumber('...')) 如果是一系列任意的号码,那么Remove/Add 就是正确的做法。
现在来考虑持久化的Id。其实不需要这个属性。当你有主/副的情况时,你知道你的用例是什么,可以在数据库中执行相应的查询(更新主要的PhoneNumber, 例如)。如果你有一个任意列表,你可以选择添加我的所有新号码从数据库中删除不在我的列表中的号码; 否则就删除所有号码,并将列表中的所有号码都添加进去。如果这看起来像是大量的繁琐操作: 是的,确实如此。事件溯源会把很多东西移到内存处理中,这是我未来会认真推动的事情。
希望这一切都说得清楚。摆脱数据方面的关注是相当困难,但却是必须的。把注意力集中在领域上,就好像你没有数据库一样。当你遇到阻力时,请尽力不要把数据库的思维方式引入到你的领域中,而是尝试想出保持你的领域干净并仍然使用你选择的数据库的方法。

好的回答。我加入了我的观点以澄清一些事情。 - eulerfx
你好,Eben,感谢您的回复。我很感激您的时间。我之所以有一个ContactInfo是为了将相关信息分组,这样我的User类就不会显得太拥挤了。我想我可以避免使用这个类。我理解整个VO vs Entity的“问卷调查”。我想我的困惑在于对象的“不可变性”。我觉得如果我替换List<Phone>中的一个元素,我最终会在数据库中得到两个副本。新的和旧的。这就是为什么我们在nhibernate中有级联=delete-orphan的原因吗?如果我们有其他带有Phone FK的表怎么办?失去引用了吗? - Pepito Fernandez
我不使用ORM。其中一个主要原因是它们似乎总是需要在域中进行考虑。我不想使我的所有方法在域中都是虚拟的,我当然也不想为了持久性而使用ID。这就是为什么我自己进行映射的原因。我不进行更改跟踪(ORM的主要好处之一),因为我的应用程序始终知道正在执行哪个用例。所以,不幸的是,我不能帮助NHibernate部分 :) - Eben Roux
你似乎混淆了聚合根和实体。一个孤立的实体只有在它是聚合根时才有意义。例如,一辆车的轮子如果没有车就毫无意义。 - Markus Malkusch
1
@Markus:这个答案是2012年的,所以我现在更加了解其中的细微差别,不像看起来那么困惑。然而,如果你打算让Wheel表示一个位置以及轮胎,那么Wheel肯定是一个VO。一些车队公司会跟踪轮胎,因为它们有很长的生命周期并且可以翻新。所以,如果你的意思是Wheel是轮胎,那么它可以是任何一个,具体取决于领域 :) - Eben Roux
我假设任何回答DDD主题的人都已经阅读了蓝皮书,其中特别提到了汽车和轮子的本地标识示例。 - Markus Malkusch

0
我会创建一个名为 "PhoneNumber" 的类,并包含当前 Phone 类的字符串号码,然后在你的 Phone 类中将其用作值对象。
class Phone implements IEntity
{
public int id; // persistence ID, not domain ID
public PhoneNumber number {get;set;}
}

class PhoneNumber implements IValueObject
{
public String number {get;set;};
}

当您的代码发展到一定程度时,您可能需要电话号码验证,您可以将其放在PhoneNumber类中。这个类可以在整个应用程序中的不同位置重复使用。

地址在我看来是一个值对象,您可以像对待整体一样对待它。虽然您可以建模街道、城市等通常是实体的内容,但这可能是过度建模了。地址的任何部分都不能更改,在初始创建后更改时始终替换整个对象。

在这个例子中,用户类在这些边界内是一个聚合根(因此也是一个实体)。ContactInfo类不是值对象(不是不可变的),也不是实体(没有真正的身份),而是一个聚合。它包含多个类,应该被视为一个整体。

有关更多信息,请参见http://martinfowler.com/bliki/DDD_Aggregate.html

通常,只要有持久性ID,您就应该考虑实体。但是,如果您想要添加持久性ID,则可以像Phone和PhoneNumber类一样开始拆分。例如,Address(包含ID的实体)和AddressValue包含所有其他字段(以及关于地址值的逻辑)。

这也应该解决了关于管理持久性标识的头痛问题,因为在updatePrimaryPhoneNumber的情况下,您替换整个值对象并且持久性标识保持不变。


感谢您的回复。幸运的是,自从我第一次发布那个问题到现在,我已经对DDD有了很多的理解和学习。一开始真的很棘手,因为我们太习惯CRUD系统了。希望所有开发人员都能学习这些知识,这将使他们的生活变得更加轻松。 - Pepito Fernandez
不客气。是的,这确实很棘手,如果所有开发人员都学会了这些东西,那就太好了。 - Maarten

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接