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

cod*_*ike 166 oop domain-driven-design s#arp-architecture repository-pattern

在领域驱动设计,似乎有很多协议,任何单位不得直接访问存储库.

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

对于它背后的推理,哪里有一些很好的解释?

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

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

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

ker*_*sis 44

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

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

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

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

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

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

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

  • 谢谢.但我应该努力将尽可能多的业务逻辑纳入实体(及其相关的工厂和规范等),对吧?但如果他们都不允许通过存储库获取数据,那么我应该如何编写任何(相当复杂的)业务逻辑?例如:不允许聊天室用户将其名称更改为已被其他人使用的名称.我希望ChatUser实体能够构建该规则,但如果您无法从那里访问存储库,那么它就不容易实现.所以我该怎么做? (12认同)
  • 谢谢亚历克.这是表达它的明确方式.但对我而言,似乎埃文斯以"以所有业务逻辑进入领域层"的以域为中心的黄金法则与"实体不应访问存储库"的规则相冲突.如果我理解为什么会这样,我就可以忍受,但我无法在网上找到任何实体不应该访问存储库的好解释.埃文斯似乎没有明确提到它.它从哪里来的?如果你可以发一个指向一些好文献的答案,你可以给自己一个50pt的赏金:) (8认同)
  • 您的实体应该知道如何保护自己免受伤害.这包括确保它无法进入无效状态.您在聊天室用户中描述的是业务逻辑,它位于实体必须保持自身有效的逻辑中.业务逻辑就像你想要的真正属于Chatroom服务,而不是ChatUser实体. (4认同)
  • "他在小事上没有意义"这是团队所犯的一个大错误......这是一个小项目,因此我可以做到这一点......那就不要那么想了.由于业务需求,我们合作的许多小项目最终变得很大.如果你做了小或大的事情,那就做对了. (3认同)

pro*_*mer 32

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

  1. 我们应该知道我们在请求中的意图以及我们对域的要求,因此我们可以在构造或调用Aggregate行为之前进行存储库调用.这也有助于避免内存状态不一致的问题以及延迟加载的需要(参见本文).气味是您不能再创建实体的内存实例而不必担心数据访问.
  2. CQS(命令查询分离)可以帮助减少需要为我们的实体中的事物调用存储库的需要.
  3. 我们可以使用规范来封装和传递域逻辑需求,并将其传递给存储库(服务可以为我们编排这些东西).规范可以来自负责维护该不变量的实体.存储库会将规范的某些部分解释为它自己的查询实现,并根据规范对查询结果应用规则.这旨在将域逻辑保留在域层中.它还可以更好地服务于无处不在的语言和交流.想象一下,说"过期订单规范"而不是"从tbl_order过滤订单,其中placement_at在sysdate之前不到30分钟"(参见本答复).
  4. 由于违反了单一责任原则,因此对实体行为的推理更加困难.如果您需要解决存储/持久性问题,您需要知道去哪里以及不去哪里.
  5. 它避免了实体双向访问全局状态的危险(通过存储库和域服务).您也不想破坏您的交易边界.

Vernon Vaughn在红皮书"实施领域驱动设计"中提到了我所知道的两个地方的这个问题(注意:这本书完全由Evans认可,你可以在前言中读到).在关于服务的第7章中,他使用域服务和规范来解决聚合使用存储库和另一个聚合的需要,以确定用户是否经过身份验证.引用他的话说:

根据经验,我们应尽量避免在聚合内部使用存储库(12),如果可能的话.

弗农,沃恩(2013-02-06).实施域驱动设计(Kindle Location 6089).皮尔逊教育.Kindle版.

在关于聚合的第10章中,他在标题为"模型导航"部分中说(就在他建议使用全局唯一ID来引用其他聚合根)之后:

通过身份引用并不完全阻止通过模型导航.有些人会在聚合内部使用Repository(12)进行查找.这种技术称为Disconnected Domain Model,它实际上是一种延迟加载形式.但是,有一种不同的推荐方法:使用Repository或Domain Service(7)在调用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));
   } 
   ...
}     
Run Code Online (Sandbox Code Playgroud)

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

然后我与总是亲切的Marco Pivetta @Ocramius进行了一些讨论,他向我展示了一些关于从域中提取规范并使用它的代码:

1)不建议这样做:

$user->mountFriends(); // <-- has a repository call inside that loads friends? 
Run Code Online (Sandbox Code Playgroud)

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); 
}
Run Code Online (Sandbox Code Playgroud)

  • 问题:我们总是被教导不要在无效或不一致的状态下创建对象。当您从存储库加载用户,然后在执行任何其他操作之前调用 `getFriends()` 时,它将是空的或延迟加载的。如果为空,则该对象处于无效状态。对此有何想法? (2认同)

Mag*_*eus 26

这是一个非常好的问题.我期待着对此进行一些讨论.但我认为在几本DDD书籍和Jimmy nilssons以及Eric Evans中提到过它.我想通过示例还可以看到如何使用reposistory模式.

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

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

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

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

  • 好点子。我猜,老派的域模型可能会让实体 B 在让自己持久化之前负责验证自己。您确定 Evans 提到实体不使用存储库吗?这本书已经读到一半了,它还没提到…… (2认同)
  • 从书中下载 DDD SmartCA 示例 http://p2p.wrox.com/book-net-domain-driven-design-c-problem-design-solution-isbn-978-0-470-14756-6-401/你会看到另一种方法(虽然这是一个 RIA Windows 客户端),其中存储库用于服务(这里没什么奇怪的),但服务在实体内部使用。这是我不会做的事情,但我是 webb 应用程序的人。考虑到 SmartCA 应用程序必须能够脱机工作的场景,ddd 设计可能会有所不同。 (2认同)

小智 12

为什么要分开数据访问?

从本书开始,我认为模型驱动设计章节的前两页为您希望从域模型的实现中抽象出技术实现细节提供了一些理由.

  • 您希望在域模型和代码之间保持紧密的连接
  • 分离技术问题有助于证明该模型对于实施是切实可行的
  • 您希望无处不在的语言能够渗透到系统的设计中

这似乎都是为了避免单独的"分析模型"脱离系统的实际实现.

根据我对本书的理解,它说这种"分析模型"最终可以在不考虑软件实现的情况下进行设计.一旦开发人员尝试实现业务方面理解的模型,他们就会因必要而形成自己的抽象,从而在沟通和理解方面造成障碍.

另一方面,开发人员在域模型中引入太多技术问题也会导致这种差异.

因此,您可以考虑实施持久性等关注点的分离可以帮助防止这些设计分析模型的分歧.如果觉得有必要在模型中引入持久性之类的东西,那么它就是一面红旗.也许该模型不适用于实施.

引用:

"单一模型减少了出错的可能性,因为设计现在是精心考虑的模型的直接产物.设计,甚至代码本身,都具有模型的交流性."

我解释这个的方式,如果你最终处理了更多的代码来处理数据库访问等问题,你会失去那种沟通能力.

如果需要访问数据库是为了检查唯一性,请查看:

Udi Dahan:团队在应用DDD时犯的最大错误

http://gojko.net/2010/06/11/udi-dahan-the-biggest-mistakes-teams-make-when-applying-ddd/

在"所有规则不平等"下

使用域模型模式

http://msdn.microsoft.com/en-us/magazine/ee236415.aspx#id0400119

在"不使用域模型的场景"下,涉及同一主题.

如何分离数据访问

通过接口加载数据

"数据访问层"已通过接口抽象,您可以调用该接口以检索所需的数据:

var orderLines = OrderRepository.GetOrderLines(orderId);

foreach (var line in orderLines)
{
     total += line.Price;
}
Run Code Online (Sandbox Code Playgroud)

优点:界面分离出"数据访问"管道代码,允许您仍然编写测试.可以根据具体情况处理数据访问,从而实现比通用策略更好的性能.

缺点:调用代码必须假定已加载的内容和未加载的内容.

假设出于性能原因,GetOrderLines返回具有null ProductInfo属性的OrderLine对象.开发人员必须熟悉界面背后的代码.

我在真实系统上尝试过这种方法.为了解决性能问题,您最终会一直更改所加载的内容的范围.您最终在界面后面查看数据访问代码以查看正在加载和未加载的内容.

现在,关注点的分离应该允许开发人员尽可能多地关注代码的一个方面.接口技术删除了这个数据加载的HOW,但是没有加载多少数据,加载时加载,加载WHERE.

结论:分离相当低!

延迟加载

数据按需加载.加载数据的调用隐藏在对象图本身中,访问属性可能导致在返回结果之前执行sql查询.

foreach (var line in order.OrderLines)
{
    total += line.Price;
}
Run Code Online (Sandbox Code Playgroud)

优点:关注域逻辑的开发人员隐藏了数据访问的"WHEN,WHERE和HOW".聚合中没有处理加载数据的代码.加载的数据量可以是代码所需的确切数量.

缺点:当您遇到性能问题时,如果您拥有通用的"一刀切"解决方案,则很难解决.延迟加载可能会导致整体性能下降,并且实现延迟加载可能会非常棘手.

角色界面/渴望获取

每个用例都通过聚合类实现的角色接口显式化,允许根据用例处理数据加载策略.

获取策略可能如下所示:

public class BillOrderFetchingStrategy : ILoadDataFor<IBillOrder, Order>
{
    Order Load(string aggregateId)
    {
        var order = new Order();

        order.Data = GetOrderLinesWithPrice(aggregateId);

        return order;
    }

}
Run Code Online (Sandbox Code Playgroud)

然后你的聚合看起来像:

public class Order : IBillOrder
{
    void BillOrder(BillOrderCommand command)
    {
        foreach (var line in this.Data.OrderLines)
        {
            total += line.Price;
        }

        etc...
    }
}
Run Code Online (Sandbox Code Playgroud)

BillOrderFetchingStrategy用于构建聚合,然后聚合完成其工作.

优点:允许每个用例自定义代码,以实现最佳性能.符合接口隔离原则.没有复杂的代码要求.聚合单元测试不必模仿加载策略.通用加载策略可用于大多数情况(例如"加载所有"策略),并且可在必要时实施特殊加载策略.

缺点:开发人员在更改域代码后仍需调整/审核提取策略.

使用提取策略方法,您可能仍会发现自己正在更改自定义提取代码以更改业务规则.这不是一个完美的关注点分离,但最终会更易于维护,并且比第一个选项更好.获取策略确实封装了加载HOW,WHEN和WHERE数据.它具有更好的关注点分离,而不会失去灵活性,就像一种尺寸适合所有延迟加载方法一样.


aha*_*man 11

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

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


Tim*_*imo 9

真是个好问题.我在发现的同一条道路上,整个互联网上的大多数答案似乎带来了解决方案带来的问题.

所以(冒着写一些我从现在开始不同意的事情)这是我迄今为止的发现.

首先,我们喜欢一个丰富的域模型,它为我们提供了高可发现性(我们可以用聚合做什么)和可读性(表达方法调用).

// Entity
public class Invoice
{
    ...
    public void SetStatus(StatusCode statusCode, DateTime dateTime) { ... }
    public void CreateCreditNote(decimal amount) { ... }
    ...
}
Run Code Online (Sandbox Code Playgroud)

我们希望在不将任何服务注入实体的构造函数的情况下实现此目的,因为:

  • 引入新行为(使用新服务)可能会导致构造函数更改,这意味着更改会影响实例化实体的每一行!
  • 这些服务不是模型的一部分,但构造函数注入表明它们是.
  • 通常,服务(甚至是其接口)是实现细节而不是域的一部分.域模型将具有外向依赖性.
  • 如果没有这些依赖关系,实体就不能存在,这可能会让人感到困惑.(信用票据服务,你说?我甚至不会用信用票据做任何事......)
  • 它会使它难以实例化,因此难以测试.
  • 问题很容易传播,因为包含这个问题的其他实体会获得相同的依赖关系 - 在它们上面可能看起来非常不自然的依赖关系.

那么,我们怎么做呢?到目前为止,我的结论是方法依赖双重调度提供了一个不错的解决方案.

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

    ...
}
Run Code Online (Sandbox Code Playgroud)

CreateCreditNote()现在需要一个负责创建信用票据的服务.它采用双调度,完全卸载工作,以负责任的服务,同时保持曝光率Invoice实体.

SetStatus()现在对记录器有一个简单的依赖,显然它将执行部分工作.

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

我们仍然可以将参数命名为"logger",希望揭示我们的意图.但是看起来有点弱.

相反,我会选择要求IInvoiceLogger(正如我们在代码示例中所做的那样)并IInvoiceService实现该接口.客户端代码可以简单地将其单一IInvoiceService用于所有Invoice要求任何这种非常特殊的,发票固有的"迷你服务"的方法,而方法签名仍然非常清楚他们要求的是什么.

我注意到我没有明确地处理存储库.好吧,记录器是或使用存储库,但我还提供了一个更明确的示例.如果只需要一两个方法就可以使用存储库,我们可以使用相同的方法.

public class Invoice
{
    public IEnumerable<CreditNote> GetCreditNotes(ICreditNoteRepository repository)
    { ... }
}
Run Code Online (Sandbox Code Playgroud)

事实上,这提供了一个替代偶然麻烦的懒惰负载.

更新:我已经将下面的文字留给了历史目的,但我建议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));
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

new Invoice(inv => new List<CreditNote>() as IEnumerable<CreditNote>)
Run Code Online (Sandbox Code Playgroud)

(习惯ILazy<out T>可以让我们摆脱丑陋的演员阵容IEnumerable,但这会使讨论变得复杂.)

// Or just an empty IEnumerable
new Invoice(inv => IEnumerable.Empty<CreditNote>())
Run Code Online (Sandbox Code Playgroud)

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