DDD - 实体无法直接访问存储库的规则

时间:2011-04-17 15:02:03

标签: oop domain-driven-design repository-pattern s#arp-architecture

在域驱动设计中,似乎lots agreement实体不应直接访问存储库。

这是来自Eric Evans Domain Driven Design的书,还是来自其他地方?

对于背后的推理有哪些好的解释?

编辑:澄清:我不是在谈论将数据访问分离到业务逻辑的单独层的经典OO实践 - 我在谈论DDD中的特定安排,实体不应该谈论根本不是数据访问层(即它们不应该保存对Repository对象的引用)

更新:我给了BacceSR赏金,因为他的回答似乎最接近,但我仍然对这个问题一无所知。如果有这么重要的原则,肯定会在网上有一些关于它的好文章吗?

更新:2013年3月,关于这个问题的投票意味着人们对此很感兴趣,尽管有很多答案,但我仍然认为如果人们有这方面的想法,还有更多的空间。

13 个答案:

答案 0 :(得分:43)

这里有点混乱。存储库访问聚合根。聚合根是实体。这样做的原因是关注点分离和良好的分层。这对小型项目没有意义,但是如果你是一个大型团队,你想说,“你通过产品库访问产品。产品是实体集合的聚合根,包括ProductCatalog对象。如果要更新ProductCatalog,则必须通过ProductRepository。“

通过这种方式,您可以非常非常清晰地分离业务逻辑以及更新内容。你没有一个孩子独自离开,并将完成所有这些复杂事情的整个程序编写到产品目录中,当它将它集成到上游项目时,你坐在那里看着它并意识到它所有人都必须被抛弃。这也意味着当人们加入团队,添加新功能,他们知道去哪里以及如何构建程序。

但是等等!存储库还引用持久层,如存储库模式中所示。在一个更好的世界中,Eric Evans的存储库和存储库模式将有不同的名称,因为它们往往会重叠很多。要获得存储库模式,您可以使用服务总线或事件模型系统与访问数据的其他方式进行对比。通常当你达到这个级别时,埃里克埃文斯的存储库定义会顺便说一下,你开始谈论有限的上下文。每个有界上下文基本上是它自己的应用程序。您可能拥有一个复杂的审批系统,可以将内容放入产品目录中。在您的原始设计中,产品是中心产品,但在这个有限的背景下,产品目录是。您仍然可以通过服务总线访问产品信息和更新产品,但您必须意识到有限上下文之外的产品目录可能意味着完全不同的东西。

回到原来的问题。如果您从实体内部访问存储库,则意味着该实体实际上不是业务实体,但可能存在于服务层中。这是因为实体是业务对象,应该尽可能地关注DSL(特定于域的语言)。仅在此层中包含业务信息。如果您正在对性能问题进行故障排除,那么您将知道要查看其他地方,因为只有业务信息应该在此处。如果突然间,你在这里遇到了应用程序问题,那么你就很难扩展和维护一个应用程序,这实际上是DDD的核心:制作可维护的软件。

对评论1的回应:对,好问题。因此,所有验证都不会在域层中发生。 Sharp有一个属性“DomainSignature”可以满足您的需求。它具有持久性,但作为属性可以保持域层清洁。它确保您没有重复的实体,在您的示例中具有相同的名称。

但是让我们谈谈更复杂的验证规则。假设您是Amazon.com。你有订购过期信用卡的东西吗?我有,我没有更新卡,买了东西。它接受订单,UI告诉我一切都很好。大约15分钟后,我会收到一封电子邮件,说我的订单有问题,我的信用卡无效。这里发生的事情是,理想情况下,域层中有一些正则表达式验证。这是一个正确的信用卡号码吗?如果是,请坚持下订单。但是,在应用程序任务层还有其他验证,其中查询外部服务以查看是否可以在信用卡上进行付款。如果没有,请不要发货,暂停订单并等待客户。这应该都发生在服务层。

不要害怕在可以访问存储库的服务层创建验证对象。只需将其保留在域层之外。

答案 1 :(得分:29)

起初,我有一种说服力,允许我的一些实体访问存储库(即没有ORM的延迟加载)。后来我得出结论,我不应该,我可以找到其他方法:

  1. 我们应该知道我们在请求中的意图以及我们对域的要求,因此我们可以在构造或调用Aggregate行为之前进行存储库调用。这也有助于避免内存状态不一致的问题以及延迟加载的需要(请参阅此article)。气味是您不能再创建实体的内存实例而不必担心数据访问。
  2. CQS(命令查询分离)可以帮助减少需要为我们实体中的事物调用存储库的需要。
  3. 我们可以使用specification来封装和传达域逻辑需求,并将其传递给存储库(服务可以为我们编排这些东西)。规范可以来自负责维护该不变量的实体。存储库会将规范的某些部分解释为它自己的查询实现,并根据规范对查询结果应用规则。这旨在将域逻辑保留在域层中。它还可以更好地服务于无处不在的语言和交流。想象一下,说“过期订单规范”与“从tbl_order过滤订单,其中placement_at在sysdate之前不到30分钟”(请参阅​​此answer)。
  4. 由于违反了单一责任原则,因此对实体行为的推理更加困难。如果您需要解决存储/持久性问题,您应该知道去哪里以及不去哪里。
  5. 它避免了让实体双向访问全局状态(通过存储库和域服务)的危险。您也不想破坏您的交易边界。
  6. Vernon Vaughn在红皮书“实施领域驱动设计”中提到了我所知道的两个地方的这个问题(注意:这本书完全得到了Evans的认可,你可以在前言中读到) 。在关于服务的第7章中,他使用域服务和规范来解决聚合使用存储库和另一个聚合的需要,以确定用户是否经过身份验证。引用他的话说:

      

    根据经验,我们应该尽量避免使用存储库   (12)如果可能的话,从聚合体内部开始。

    Vernon,Vaughn(2013-02-06)。实施域驱动设计(Kindle Location 6089)。皮尔逊教育。 Kindle版。

    在关于聚合的第10章中,他在section titled "Model Navigation"中说(在他建议使用全局唯一ID引用其他聚合根之后):

      

    按身份引用并不能完全阻止导航   该模型。有些人会在聚合内部使用Repository(12)   用于查找。这种技术称为断开域模型,和   它实际上是一种延迟加载的形式。有一个不同的推荐   但是:使用存储库或域服务(7)进行查找   在调用Aggregate行为之前的依赖对象。一个客户   Application Service可以控制它,然后发送到Aggregate:

    他在代码中展示了这个例子:

    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));
       } 
       ...
    }     
    

    他接着还提到了另一种解决方案,即如何在聚合命令方法中使用域服务以及 double-dispatch 。 (我不能推荐阅读他的书有多么有益。在你厌倦了通过互联网进行最后翻找之后,掏出当之无愧的钱并阅读这本书。)

    然后我和一些总是很优雅的Marco Pivetta discussion有一些@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); 
    }
    

答案 2 :(得分:27)

这是一个非常好的问题。我期待着对此进行一些讨论。但我认为several DDD books和Jimmy nilssons以及Eric Evans都提到了这一点。我想通过示例还可以看到如何使用reposistory模式。

但我们可以讨论一下。我认为一个非常有效的想法是为什么一个实体应该知道如何坚持另一个实体? DDD的重要性在于每个实体都有责任管理自己的“知识领域”,并且不应该知道如何读取或写入其他实体。当然你可以只为实体A添加一个存储库接口来读取实体B.但是风险在于你暴露了如何持久存在的知识B.在将B持久存储到数据库之前,实体A是否也会在B上进行验证?

正如您所看到的,实体A可以更多地参与实体B的生命周期,这会增加模型的复杂性。

我猜(没有任何例子)单元测试会更复杂。

但我确信总会有一些情况,你很想通过实体使用存储库。您必须查看每个方案以做出有效判断。优点和缺点。但在我看来,存储库实体解决方案始于很多缺点。专业人士必须是一个非常特殊的场景,以平衡善意......

答案 3 :(得分:12)

答案 4 :(得分:11)

我发现这个博客有很好的论据反对在实体中封装存储库:

http://thinkbeforecoding.com/post/2009/03/04/How-not-to-inject-services-in-entities

答案 5 :(得分:9)

这是一个很棒的问题。我正处于发现的同一条道路上,整个互联网上的大多数答案似乎带来了解决方案带来的许多问题。

所以(冒着写一些我不同意的东西的风险)到目前为止,这是我的发现。

首先,我们喜欢富域模型,它为我们提供了高可发现性(我们可以使用聚合)和可读性 strong>(表达方法调用)。

// 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()现在需要一项负责创建信用票据的服务。它使用双重调度,完全卸载工作到负责服务,同时<{>>维护可发现性来自Invoice实体。

SetStatus()现在在记录器上有一个简单依赖,显然会执行 部分

对于后者,为了使客户端代码更容易,我们可能会登录IInvoiceService。毕竟,发票记录似乎是发票的固有内容。这样的单IInvoiceService有助于避免为各种操作提供各种迷你服务。缺点是它会使的服务变得模糊不清。它甚至可能开始看起来就像双重调度一样,而大部分工作仍然在SetStatus()本身完成。

我们仍然可以将参数“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));
    }
}

一方面,从数据库加载Invoice的存储库可以免费访问将加载相应信用票据的函数,并将该函数注入Invoice

另一方面,创建实际 new Invoice的代码只会传递一个返回空列表的函数:

new Invoice(inv => new List<CreditNote>() as IEnumerable<CreditNote>)

(自定义ILazy<out T>可以让我们摆脱IEnumerable的丑陋演员,但这会使讨论复杂化。)

// Or just an empty IEnumerable
new Invoice(inv => IEnumerable.Empty<CreditNote>())

我很高兴听到您的意见,偏好和改进!

答案 6 :(得分:2)

对我而言,这似乎是一般的良好的OOD相关实践,而不是专门针对DDD。

我能想到的原因是:

  • 关注点分离(实体应该与它们持久化的方式分开。因为根据使用场景,可能存在多个策略,其中同一实体将被保留)
  • 从逻辑上讲,可以在低于存储库运行级别的级别中看到实体。较低级别的组件不应该具有较高级别组件的知识。因此,条目不应该具有存储库的知识。

答案 7 :(得分:1)

我学会了在所有这些单独的图层嗡嗡声出现之前编写面向对象的编程,并且我的第一个对象/类DID直接映射到数据库。

最后,我添加了一个中间层,因为我必须迁移到另一个数据库服务器。我已多次看到/听到过同样的情况。

我认为将数据访问(也就是“存储库”)与业务逻辑分离,就是其中之一,已经多次重新发明,而不是领域驱动设计书籍,使其成为很多“噪音”。 / p>

我目前使用3层(GUI,逻辑,数据访问),就像许多开发人员一样,因为它是一种很好的技术。

将数据分成Repository图层(a.k.a。Data Access图层),可能会被视为一种优秀的编程技术,而不仅仅是一条规则。

与许多方法一样,您可能希望一旦理解就开始,通过NOT实现,最终更新您的程序。

引用: 伊利亚特并非完全是由荷马发明的,Carmina Burana并非完全由卡尔奥尔夫发明,在这两种情况下,让其他人工作的人都得到了信誉; - )

答案 8 :(得分:1)

简单地说Vernon Vaughn给出了一个解决方案:

  

使用存储库或域服务提前查找依赖对象   调用聚合行为。客户端应用程序服务可以   控制这个。

答案 9 :(得分:0)

  

这是来自Eric Evans Domain Driven Design的书,还是来自其他地方?

这是旧事。埃里克的书让它更加流行起来。

  

对于背后的推理有哪些好的解释?

理由很简单 - 当人类的头脑面临模糊相关的多重背景时,它会变得虚弱。它们导致模棱两可(南美/北美的美国意味着南美/北美),模糊不清导致信息不断映射,只要心灵“触及它”,并且总结为生产力和错误。

应尽可能清楚地反映业务逻辑。外键,规范化,对象关系映射来自完全不同的域 - 那些是技术的,与计算机相关的。

类比:如果你正在学习如何手写,那么你就不应该理解制作笔的地方,为什么墨水存放在纸上,什么时候发明纸张以及其他着名的中国发明。

  

编辑:澄清:我不是在谈论将数据访问分离到业务逻辑的单独层的经典OO实践 - 我在谈论DDD中的特定安排,实体不应该谈论根本不是数据访问层(即它们不应该保存对Repository对象的引用)

我上面提到的原因仍然相同。这只是更进一步。为什么实体应该是部分持久性无知的,如果它们可以(至少接近)完全?我们的模型所关注的领域无关紧要 - 当我们必须重新解释它时,我们的思维会有更多喘息的空间。

答案 10 :(得分:0)

援引Carolina Lilientahl的话,“模式应防止循环” https://www.youtube.com/watch?v=eJjadzMRQAk,其中指的是类之间的循环依赖性。对于聚合内的存储库,出于唯一的原因,出于对象导航的便利性,倾向于创建循环依赖项。 Prograhammer上面提到的模式是Vernon Vaughn推荐的,在该模式中,其他聚合是通过id而不是根实例来引用的(这种模式有名称吗?)提出了一种可能会引入其他解决方案的替代方法。

类之间的循环依赖(自白)示例:

(Time0):Sample和Well这两个类互相引用(循环依赖)。为方便起见,“孔”指的是“样品”,而“样品”指的是“孔”,有时是循环采样,有时是在板上循环所有孔。我无法想象Sample不会引用回放置它的Well的情况。

(Time1):一年后,实现了许多用例..现在有些情况下Sample不应该参考其所放置的孔。在一个工作步骤中有一些临时标牌。这里的孔是指样品,而样品又是指另一块板上的孔。因此,当有人尝试实现新功能时,有时会发生奇怪的行为。需要时间来渗透。

上面提到的关于延迟加载的负面影响的article也帮助了我。

答案 11 :(得分:0)

聚会很晚,但我会给我 2 美分。

从性能的角度来看,在域模型中抽象 REST API 操作的存储库和域服务可能是一个重大灾难。我认为域服务(尽管在红皮书中另有说明!),聚合也不应该尝试与它们一起工作,并且这两个概念应该只保留在 应用服务 的领域中,它全权负责无论您使用 Layers 还是 Hexagon(端口和适配器),都可以与外部世界进行通信。

通过这种方式,所有昂贵的 I/O 通信都由一个应用程序服务分配和完全控制。它会:

  1. 防止任何类型的性能瓶颈。
  2. 防止域模型中的任何类型的全局访问(存储库是全局的)。

构建适当的对象图,在应用服务中使用正确的获取策略,并将纯内存对象传递给富域模型。延迟加载会潜入您的代码中,并在最痛处击中您。

答案 12 :(得分:-1)

在理想世界中,DDD建议实体不应该引用数据层。但我们并不生活在理想的世界里。域可能需要为其可能没有依赖关系的业务逻辑引用其他域对象。实体引用存储库层以进行只读,以获取值是合乎逻辑的。