DDD-实体不能直接访问存储库的规则

|| 在域驱动设计中,似乎有很多共识,即实体不应直接访问存储库。 这是来自Eric Evans域驱动设计书,还是来自其他地方? 背后的原因在哪里有很好的解释? 编辑:澄清:我不是在谈论将数据访问从业务逻辑分离到一个单独的层的经典OO做法-我是在谈论DDD中的具体安排,实体不应与完全是数据访问层(即,它们不应保存对存储库对象的引用) 更新:我给了BacceSR赏金,因为他的回答似乎最接近,但是我对此仍然很不了解。如果它是如此重要的原理,那么肯定在某处在线有关于它的好文章吗? 更新:2013年3月,对该问题的投票暗示对此有很多兴趣,尽管有很多答案,但我仍然认为,如果人们对此有想法,还有更多的余地。     
已邀请:
这里有些混乱。存储库访问聚合根。聚合根是实体。这样做的原因是关注点分离和良好的分层。这在小型项目中没有意义,但是如果您在大型团队中,您想说的是:“您可以通过产品存储库访问产品。产品是实体集合(包括ProductCatalog对象。如果要更新ProductCatalog,则必须通过ProductRepository。\“ 这样,您就可以非常清楚地区分业务逻辑和更新内容。您没有一个孩子一个人呆着,自己编写了整个程序,该程序将所有这些复杂的事情处理到产品目录中,当涉及到将其集成到上游项目时,您坐在那里看着它,意识到这一切都必须放弃。这也意味着当人们加入团队,添加新功能时,他们知道去哪里以及如何构建程序。 可是等等!像“存储库模式”一样,存储库也指持久层。在一个更好的世界中,埃里克·埃文斯(Eric Evans)的存储库和存储库模式将具有单独的名称,因为它们往往会重叠很多。为了获得存储库模式,您需要使用服务总线或事件模型系统来与其他访问数据的方式进行对比。通常,当您达到这一级别时,Eric Evans的存储库定义就会绕开,您开始谈论有界上下文。每个有界上下文本质上都是其自己的应用程序。您可能有一个完善的审批系统,可用于将产品放入产品目录。在您的原始设计中,产品是核心,但在这种有限的背景下,产品目录就是中心。您仍然可以通过服务总线访问产品信息并更新产品,但是您必须意识到,在受限上下文之外的产品目录可能意味着完全不同的东西。 回到您的原始问题。如果您要从实体内部访问存储库,则意味着该实体实际上不是业务实体,而是可能应在服务层中存在的实体。这是因为实体是业务对象,因此应尽可能使自己像DSL(特定于域的语言)一样。在这一层中只有业务信息。如果您要对性能问题进行故障排除,那么您将知道在其他地方查找,因为此处仅应包含业务信息。如果突然之间出现了应用程序问题,那么您将很难扩展和维护应用程序,而这实际上是DDD的核心:开发可维护的软件。 对评论1的回应:是的,很好的问题。因此,并非所有验证都在域层进行。夏普(Sharp)有一个属性“ DomainSignature”,该属性可以满足您的需求。它具有持久性,但是作为属性可以使域层保持干净。它可以确保您没有名称相同的重复实体。 但是,让我们谈谈更复杂的验证规则。假设您是Amazon.com。您是否曾经使用过期的信用卡订购过商品?我有,但我还没有更新卡和买东西。它接受订单,并且UI通知我所有东西都是桃红色的。大约15分钟后,我会收到一封电子邮件,说我的订单有问题,我的信用卡无效。理想情况是,在域层中进行了一些正则表达式验证。这是正确的信用卡号吗?如果是,则继续执行订单。但是,在应用程序任务层还需要进行其他验证,在该层中,将查询外部服务以查看是否可以在信用卡上付款。如果没有,则实际上不发货,请暂停订单并等待客户。所有这些都应在服务层中进行。 不要害怕在可以访问存储库的服务层创建验证对象。只需将其放在域层之外即可。     
最初,我是有说服力的,允许我的某些实体访问存储库(即没有ORM的延迟加载)。后来我得出结论,我不应该这样做,并且可以找到其他方法: 我们应该知道请求中的意图以及从域中获得什么,因此我们可以在构造或调用聚合行为之前进行存储库调用。这也有助于避免内存状态不一致和延迟加载的问题(请参阅本文)。气味是您不能再创建实体的内存实例而不用担心数据访问。 CQS(命令查询分离)可以帮助减少为实体中的事物调用存储库的需求。 我们可以使用规范来封装和传达域逻辑需求,并将其传递给存储库(服务可以为我们编排这些东西)。规范可以来自负责维护该不变性的实体。存储库将把规范的某些部分解释为它自己的查询实现,并将规范中的规则应用于查询结果。这旨在将域逻辑保留在域层中。它还为无所不在的语言和交流提供了更好的服务。想象一下说“过期订单说明”与说“从tbl_order过滤器订单,其中placed_at在sysdate前不到30分钟”(请参阅​​此答案)。 由于违反了单一责任原则,这使得对实体行为的推理变得更加困难。如果您需要解决存储/持久性问题,则知道该往何处去。 它避免了授予实体双向访问全局状态(通过存储库和域服务)的危险。您也不想打破交易界限。 我所知道的红色书《实现域驱动设计》中的弗农·沃恩在两个地方都提到了这个问题(请注意:这本书得到了埃文斯的完全认可,您可以在前言中进行阅读)。在有关服务的第7章中,他使用域服务和规范来解决需要使用聚合存储库的聚合和使用另一个聚合来确定用户是否已通过身份验证的需求。引用他的话说:   根据经验,我们应该避免使用存储库   (12)如果可能,从聚集体内部进行。 弗农·沃恩(2013年2月6日)。实施域驱动设计(Kindle Location 6089)。培生教育。 Kindle版。 他在第10章“聚合”的“模型导航”部分中说道(就在他建议使用全局唯一ID引用其他聚合根之后):   按身份引用并不能完全阻止浏览   该模型。有些人将在聚合内部使用存储库(12)   查找。此技术称为“断开连接的域模型”,并且   它实际上是一种延迟加载的形式。有一个不同的推荐   但是,该方法是:使用存储库或域服务(7)进行查找   依赖对象,然后再调用聚合行为。一个客户   应用程序服务可以控制此,然后调度到聚合: 他继续在代码中显示此示例:
public class ProductBacklogItemService ... { 

   ... 
   @Transactional 
   public void assignTeamMemberToTask( 
        String aTenantId, 
        String aBacklogItemId, 
        String aTaskId, 
        String aTeamMemberId) { 

        BacklogItem backlogItem = backlogItemRepository.backlogItemOfId( 
                                        new TenantId( aTenantId), 
                                        new BacklogItemId( aBacklogItemId)); 

        Team ofTeam = teamRepository.teamOfId( 
                                  backlogItem.tenantId(), 
                                  backlogItem.teamId());

        backlogItem.assignTeamMemberToTask( 
                  new TeamMemberId( aTeamMemberId), 
                  ofTeam,
                  new TaskId( aTaskId));
   } 
   ...
}     
他接着还提到了如何将域服务与双调度一起用于Aggregate命令方法的另一种解决方案。 (我不能推荐足够多的方法来阅读他的书。在您厌倦了无休止地在互联网上翻阅后,请付清当之无愧的钱来阅读这本书。) 然后,我与总是和gra可亲的Marco Pivetta @Ocramius进行了讨论,他向我展示了一些有关从域中提取规范并使用该规范的代码: 1)不建议这样做:
$user->mountFriends(); // <-- has a repository call inside that loads friends? 
2)在域服务中,这很好:
public function mountYourFriends(MountFriendsCommand $mount) { /* see http://store.steampowered.com/app/296470/ */ 
    $user = $this->users->get($mount->userId()); 
    $friends = $this->users->findBySpecification($user->getFriendsSpecification()); 
    array_map([$user, \'mount\'], $friends); 
}
    
这是一个很好的问题。我期待对此进行一些讨论。但是我认为它在几本DDD书中以及Jimmy nilssons和Eric Evans中都提到过。我想通过示例也可以看到如何使用存储库模式。 但是让我们讨论。我认为一个非常有效的想法是,为什么一个实体应该知道如何持久另一个实体?对于DDD而言,重要的是每个实体都有责任管理自己的“知识领域”,并且不应该对如何读写其他实体一无所知。当然,您可以只向实体A添加一个存储库接口以读取实体B。但是风险是您公开了有关如何保留B的知识。实体A在将B保留到db中之前是否还会在B上进行验证? 如您所见,实体A可以更多地参与实体B \的生命周期,并且可以为模型增加更多的复杂性。 我猜(没有任何示例)单元测试将更加复杂。 但是我敢肯定,在某些情况下,您很想通过实体使用存储库。您必须查看每种情况才能做出有效的判断。利弊。但是我认为存储库实体解决方案始于很多缺点。专业人员必须平衡的缺点是非常特殊的情况。     
为什么要分开数据访问? 从书中,我认为模型驱动设计一章的前两页提供了一些理由,说明您为什么要从领域模型的实现中抽象出技术实现细节。 您想在域模型和代码之间保持紧密联系 分离技术问题有助于证明该模型对于实施是可行的 您希望无所不在的语言渗透到系统的设计中 这似乎是为了避免与系统的实际实现脱节的单独的“分析模型”。 从我对这本书的了解中可以看出,这种“分析模型”最终可以在不考虑软件实现的情况下进行设计。一旦开发人员尝试实现业务方面理解的模型,他们就会根据需要形成自己的抽象,从而造成沟通和理解上的障碍。 另一方面,开发人员在领域模型中引入太多技术问题也可能导致这种分歧。 因此,您可以考虑将关注点(例如持久性)分离开来,可以帮助防止这些设计与分析模型之间的差异。如果需要在模型中引入诸如持久性之类的东西,那么这是一个危险信号。也许该模型不适合实施。 报价单: “单一模型减少了错误的机会,因为设计现在是经过深思熟虑的模型的直接产物。设计甚至代码本身都具有模型的可通信性。” 我的解释方式是,如果最终有更多的代码行处理诸如数据库访问之类的事情,那么您将失去沟通能力。 如果访问数据库的需求是诸如检查唯一性之类的事情,请查看: 乌迪·达汉(Udi Dahan):团队在应用DDD时犯的最大错误 http://gojko.net/2010/06/11/udi-dahan-the-biggest-mistakes-teams-make-when-applying-ddd/ 在“所有规则都不相同”下 和 采用领域模型模式 http://msdn.microsoft.com/zh-CN/magazine/ee236415.aspx#id0400119 在“不使用域模型的方案”下,该主题涉及同一主题。 如何区分数据访问 通过界面加载数据 \“数据访问层\”已通过接口抽象化,您可以调用该接口以检索所需的数据:
var orderLines = OrderRepository.GetOrderLines(orderId);

foreach (var line in orderLines)
{
     total += line.Price;
}
优点:该接口分离出“数据访问”管道代码,使您仍然可以编写测试。数据访问可以根据情况进行处理,从而比常规策略具有更好的性能。 缺点:调用代码必须假定已加载和未加载。 假设出于性能原因,GetOrderLines返回具有空ProductInfo属性的OrderLine对象。开发人员必须对接口背后的代码有深入的了解。 我已经在实际系统上尝试过这种方法。您最终一直在更改加载内容的范围,以尝试解决性能问题。您最终会在界面后面偷看,以查看数据访问代码以查看正在加载和正在加载的内容。 现在,关注点分离应该使开发人员可以尽可能一次地专注于代码的一个方面。接口技术删除了如何加载此数据,但未加载如何,何时加载数据以及在何处加载。 结论:分离度很低! 延迟加载 数据按需加载。调用加载数据的操作隐藏在对象图本身中,在其中访问属性可以导致在返回结果之前执行sql查询。
foreach (var line in order.OrderLines)
{
    total += line.Price;
}
优点:专注于域逻辑的开发人员无法隐藏数据的“何时,何地和如何”。聚合中没有用于处理加载数据的代码。加载的数据量可以是代码所需的确切量。 缺点:当遇到性能问题时,如果拥有通用的“一刀切”解决方案,则很难修复。延迟加载可能会导致整体性能变差,并且实现延迟加载可能会比较棘手。 角色界面/渴望获取 通过聚合类实现的角色接口使每个用例都明确,从而允许按每个用例处理数据加载策略。 提取策略可能如下所示:
public class BillOrderFetchingStrategy : ILoadDataFor<IBillOrder, Order>
{
    Order Load(string aggregateId)
    {
        var order = new Order();

        order.Data = GetOrderLinesWithPrice(aggregateId);

        return order;
    }

}
然后,您的聚合看起来像:
public class Order : IBillOrder
{
    void BillOrder(BillOrderCommand command)
    {
        foreach (var line in this.Data.OrderLines)
        {
            total += line.Price;
        }

        etc...
    }
}
BillOrderFetchingStrategy用于构建聚合,然后聚合执行其工作。 优点:允许每个用例使用自定义代码,以实现最佳性能。符合接口隔离原则。没有复杂的代码要求。聚合单元测试不必模仿加载策略。通用加载策略可用于大多数情况(例如“全部加载”策略),并在必要时实施特殊的加载策略。 缺点:更改域代码后,开发人员仍然必须调整/查看获取策略。 使用获取策略方法,您仍然可能会发现自己正在更改自定义获取代码以更改业务规则。这不是关注点的完美分离,但是最终将更易于维护,并且比第一种方法更好。提取策略确实封装了HOW,WHEN和WHERE数据的加载方式。它具有更好的关注点分离,而不会失去灵活性,就像一种尺寸适合所有延迟加载方法一样。     
我发现此博客有很多反对将存储库封装在实体中的观点: http://thinkbeforecoding.com/post/2009/03/04/How-not-to-inject-services-in-entities     
多么好的问题。我走的是同一条路,整个互联网上的大多数答案似乎带来的问题与解决方案一样多。 因此(冒着一年以后可能会写出我不同意的事情的风险),这是我到目前为止的发现。 首先,我们喜欢一个丰富的域模型,该模型为我们提供了很高的可发现性(对于聚合,我们可以做到)和可读性(表达性方法调用)。
// Entity
public class Invoice
{
    ...
    public void SetStatus(StatusCode statusCode, DateTime dateTime) { ... }
    public void CreateCreditNote(decimal amount) { ... }
    ...
}
我们希望在不将任何服务注入实体的构造函数中的情况下实现此目标,因为: 引入新行为(使用新服务)可能会导致构造函数更改,这意味着更改会影响实例化实体的每一行! 这些服务不是模型的一部分,但是构造函数注入将表明它们是模型的一部分。 服务(甚至是其接口)通常是实现细节,而不是域的一部分。域模型将具有面向外部的依赖关系。 令人困惑的是,如果没有这些依赖项,实体就无法存在。 (您说信用证服务吗?我什至不会对信用证做任何事情...) 这将使其难以实例化,因此难以测试。 这个问题很容易传播,因为其他包含此实体的实体将获得相同的依赖关系-在它们看来可能是非常不自然的依赖关系。 那么,我们该怎么做呢?到目前为止,我的结论是方法依赖和双重调度提供了不错的解决方案。
public class Invoice
{
    ...

    // Simple method injection
    public void SetStatus(IInvoiceLogger logger, StatusCode statusCode, DateTime dateTime)
    { ... }

    // Double dispatch
    public void CreateCreditNote(ICreditNoteService creditNoteService, decimal amount)
    {
        creditNoteService.CreateCreditNote(this, amount);
    }

    ...
}
CreateCreditNote()
现在需要负责创建贷方通知单的服务。它使用双重调度,将工作完全移交给负责的服务,同时保持了来自“ 10”实体的可发现性。 ѭ11现在对记录器有一个简单的依赖关系,显然它将完成部分工作。 对于后者,为了使客户端代码更容易处理,我们可以改用might12ѭ登录。毕竟,发票记录似乎是发票所固有的。这样的单个“ 12”有助于避免为各种操作而需要各种微型服务。不利的一面是,该服务的确切功能变得晦涩难懂。它甚至可能看起来像是双重调度,而大部分工作实际上仍在ѭ11本身完成。 我们仍然可以将参数命名为“ logger”,以期揭示我们的意图。不过似乎有些虚弱。 取而代之的是,我选择要求
IInvoiceLogger
(就像我们在代码示例中所做的那样),并让
IInvoiceService
实现该接口。客户代码可以简单地将单个
IInvoiceService
用于要求任何这种非常特殊的,发票固有的“迷你服务”的
Invoice
方法,而方法签名仍然清楚地表明了他们的要求。 我注意到我没有明确地访问存储库。记录器是或使用存储库,但让我也提供一个更明确的示例。如果只需一两个方法就需要存储库,我们可以使用相同的方法。
public class Invoice
{
    public IEnumerable<CreditNote> GetCreditNotes(ICreditNoteRepository repository)
    { ... }
}
实际上,这为麻烦的惰性负载提供了替代方案。 更新:我出于历史目的保留了以下文字,但我建议避免100%的延迟加载。 对于真正的,基于属性的延迟加载,我目前确实使用构造函数注入,但是以一种对持久性无知的方式。
public class Invoice
{
    // Lazy could use an interface (for contravariance if nothing else), but I digress
    public Lazy<IEnumerable<CreditNote>> CreditNotes { get; }

    // Give me something that will provide my credit notes
    public Invoice(Func<Invoice, IEnumerable<CreditNote>> lazyCreditNotes)
    {
        this.CreditNotes = new Lazy<IEnumerable<CreditNotes>>() => lazyCreditNotes(this));
    }
}
一方面,从数据库中加载“ 10”的存储库可以自由访问将加载相应贷方通知单的功能,并将该功能注入“ 10”中。 另一方面,创建实际的新ѭ10的代码将仅传递返回空列表的函数:
new Invoice(inv => new List<CreditNote>() as IEnumerable<CreditNote>)
(自定义“ 25”可以使我们摆脱“ 26”的丑陋表象,但这会使讨论变得复杂。)
// Or just an empty IEnumerable
new Invoice(inv => IEnumerable.Empty<CreditNote>())
我很高兴听到您的意见,偏好和改进!     
在我看来,这似乎是与OOD相关的一般惯例,而不是DDD所特有的。 我能想到的原因有: 关注点分离(实体应与持久化方式分开。因为根据使用情况,可能存在多个策略可以持久化同一实体) 从逻辑上讲,可以在低于存储库操作级别的级别看到实体。较低级别的组件不应了解较高级别的组件。因此,条目不应该具有存储库知识。     
在所有这些单独的层级嗡嗡声出现之前,我学会了编写面向对象的编程代码,而我的第一个对象/类DID直接映射到数据库。 最终,我添加了一个中间层,因为我不得不迁移到另一台数据库服务器。我已经多次看到/听说过相同的情况。 我认为将数据访问(也称为“存储库”)与您的业务逻辑分开是其中之一,这些东西已经被重新发明了几次,并加入了“域驱动设计”书,使它变得很多“杂色”。 我现在像许多开发人员一样使用3层(GUI,逻辑,数据访问),因为它是一种很好的技术。 将数据分成28层(又称为29层),可以看作是一种好的编程技术,而不仅仅是遵循规则。 像许多方法一样,您可能希望通过未实现的方法开始,并在了解它们之后最终更新程序。 引用: 伊利亚特不是荷马发明的,卡米娜·布拉纳(Carmina Burana)并不是卡尔·奥尔夫(Carl Orff)的发明,在这两种情况下,使他人工作的人,全都是托格特人,得到了荣誉;-)     
简单地,Vernon Vaughn给出了一个解决方案:   使用存储库或域服务来提前查找依赖对象   调用聚合行为。客户端应用程序服务可能   控制这个。     
  这是来自Eric Evans域驱动设计书,还是来自其他地方? 它是旧东西。埃里克(Eric)的书使它变得更加流行。   背后的原因在哪里有很好的解释? 道理很简单-当人们面对模糊相关的多种环境时,他们的思维就会变得虚弱。它们导致歧义性(南美洲/北美的意思是南美/北美洲),歧义性导致每当人们“触碰信息”时就不断地映射信息,这归结为效率低下和错误。 业务逻辑应尽可能清晰地反映出来。外键,规范化,对象关系映射来自完全不同的领域-这些东西与技术,计算机有关。 打个比方:如果您正在学习如何手写,那么您就不应该了解制作笔的位置,为什么在纸上保留墨水,什么时候发明纸以及中国还有哪些其他著名的发明。   编辑:澄清:我不是在谈论将数据访问从业务逻辑分离到一个单独的层的经典OO做法-我是在谈论DDD中的具体安排,实体不应与完全是数据访问层(即,它们不应保存对存储库对象的引用) 原因仍然与我上面提到的相同。在这里,仅一步之遥。如果实体可以全部(至少接近),为什么它们应该部分不了解持久性?我们的模型所涉及的与领域无关的问题更少-当需要重新解释它时,我们的思想有了更多的喘息空间。     
引用Carolina Lilientahl的话,“模式应该防止循环”,https://www.youtube.com/watch?v = eJjadzMRQAk,她指的是类之间的循环依赖性。对于聚合中的存储库,出于唯一的原因,出于对象导航的便利性,倾向于创建循环依赖项。 Vernon Vaughn推荐了prograhammer上面提到的模式,在该模式中,其他集合由id而不是根实例引用(此模式有名称吗?),它建议了一种可以指导其他解决方案的替代方法。 类之间的循环依赖(自白)示例: (Time0):Sample和Well这两个类互相引用(循环依赖)。为方便起见,“孔”指的是“样品”,而“样品”指的是“孔”,有时是循环采样,有时是在板上循环所有孔。我无法想象Sample不会引用回放置它的Well的情况。 (Time1):一年后,实施了许多用例..现在有些情况下Sample不应该参考其所放置的油井。在一个工作步骤中有一些临时标牌。这里的孔是指样品,而样品又是指另一块板上的孔。因此,当有人尝试实现新功能时,有时会发生奇怪的行为。需要时间来渗透。 上面提到的有关延迟加载的负面影响的这篇文章也对我有所帮助。     
在理想情况下,DDD提出实体不应引用数据层。但是我们并不生活在理想世界中。域可能需要引用其他域对象以获取与之不相关的业务逻辑。实体出于只读目的引用存储库层以获取值是合乎逻辑的。     

要回复问题请先登录注册