DDD – 实体无法直接访问存储库的规则
在域驱动devise中,似乎有很多 协议 ,实体不应该直接访问存储库。
这是Eric Evans 领域驱动devise书籍,还是来自其他地方?
它背后的理由在哪里有一些很好的解释?
编辑:澄清:我不是在谈论经典的OO实践,将数据访问从业务逻辑分离到一个单独的层 – 我正在谈论的具体安排,在DDD中,实体不应该与数据交谈访问层(即它们不应该保存对Repository对象的引用)
更新:我给了BacceSR赏金,因为他的答案看起来最接近,但我对这件事还是很了解。 如果这样一个重要的原则,应该有一些关于它在网上的好文章,当然?
更新:2013年3月,关于这个问题的提问意味着对此有很多兴趣,即使有很多答案,我仍然认为如果人们有这个想法,还有更多的空间。
这里有一点困惑。 存储库访问聚合根。 聚合根是实体。 原因是关注点分离和分层。 这对于小型项目来说是没有意义的,但是如果你在一个大团队中,你想说:“你通过Product Repository访问一个产品,Product是一个实体集合的集合根,包括ProductCatalog对象。如果您想更新ProductCatalog,则必须通过ProductRepository。“
通过这种方式,您可以非常清楚地分离业务逻辑和事物更新的位置。 你没有一个自己独立的孩子,把这个复杂的东西写在产品目录上,把它整合到上游项目中,你就坐在那里看着它,实现它都必须放弃。 这也意味着当人们join团队时,添加新的function,他们知道去哪里以及如何构build程序。
可是等等! 存储库也是指持久层,就像存储库模式一样。 在一个更好的世界里,Eric Evans的Repository和Repository Pattern将会有不同的名字,因为它们往往有很大的重叠。 要获取存储库模式,您可以与使用服务总线或事件模型系统访问数据的其他方式进行对比。 通常,当你达到这个级别时,Eric Evans的Repository定义就会走到一边,开始讨论一个有界的上下文。 每个有界的上下文本质上都是它自己的应用。 你可能需要一个复杂的审批系统来把东西放到产品目录中。 在您的原始devise中,产品是中心产品,但在这个有限的背景下,产品目录是。 您仍然可以通过服务总线访问产品信息和更新产品,但是您必须认识到,有界环境之外的产品目录可能意味着完全不同的东西。
回到你原来的问题。 如果你从一个实体中访问一个仓库,这意味着这个实体真的不是一个业务实体,但可能是一个应该存在于一个服务层中的东西。 这是因为实体是业务对象,应该尽可能像DSL(域特定语言)一样关心自己。 只有这一层的商业信息。 如果您正在解决性能问题,那么您应该了解其他地方,因为只有业务信息应该在这里。 如果突然之间出现应用程序问题,则很难扩展和维护应用程序,而应用程序确实是DDD的核心:制作可维护的软件。
对评论1的回应 :对,好问题。 所以不是所有的validation都发生在域图层中。 夏普有一个属性“域名签名”,你想要什么。 这是持久性知道,但作为一个属性保持领域层干净。 它确保你没有重复的实体,在你的例子中有相同的名字。
但是让我们来谈谈更复杂的validation规则。 假设你是Amazon.com。 你有没有订购过期的信用卡? 我有,我没有更新卡,买了东西。 它接受命令和UI告诉我,一切都很好。 大约15分钟后,我会收到一封电子邮件,说我的订单有问题,我的信用卡无效。 这里发生的是,理想的情况是,在领域层有一些正则expression式validation。 这是一个正确的信用卡号码? 如果是的话,坚持下单。 但是,在应用程序任务层还有额外的validation,查询外部服务是否可以在信用卡上进行支付。 如果不是的话,实际上不要发货,暂停订单,等待客户。 这应该全部发生在服务层。
不要害怕在可访问存储库的服务层上创buildvalidation对象。 只要保持它在领域层。
这是一个非常好的问题。 我将期待对此进行一些讨论。 但是我认为在几本DDD书籍 ,吉米·尼尔森和埃里克·埃文斯中提到过。 我想通过例子也可以看出如何使用Reposistory模式。
但让我们讨论。 我认为一个非常有效的想法是为什么实体应该知道如何坚持另一个实体? DDD的重要之处在于,每个实体都有责任pipe理自己的“知识领域”,不应该知道如何读写其他实体。 当然,你可能只需要添加一个知识库接口到实体A来读取实体B.但是风险是你知道如何坚持B.实体A还会在把B保存到数据库之前对B进行validation吗?
正如您所看到的,实体A可以更多地参与实体B的生命周期,并且可以增加模型的复杂性。
我想(没有任何例子)unit testing会更复杂。
但是我确定总会有一些场景试图通过实体来使用存储库。 你必须看每一个场景来作出有效的判断。 优点和缺点。 但在我看来,存储库实体解决scheme从很多缺点开始。 对于Pros来说,这必定是一个非常特殊的场景,可以平衡…
起初,我是劝说让我的一些实体访问存储库(即没有ORM的延迟加载)。 后来我得出结论,我不应该和我能find替代方法:
- 我们应该知道我们在请求中的意图以及我们想从域中获得什么,因此我们可以在构造或调用聚合行为之前进行存储库调用。 这也有助于避免内存状态不一致的问题以及延迟加载的需要(请参阅本文 )。 气味是你不能创build一个在你的实体的内存实例,而不必担心数据访问。
- CQS(命令查询分离)可以帮助减less需要调用存储库的东西在我们的实体。
- 我们可以使用规范来封装和传递域逻辑需求,并将其传递给存储库(服务可以为我们编排这些东西)。 规范可以来自负责维护这个不variables的实体。 存储库将把规范的各个部分解释为它自己的查询实现,并将规范中的规则应用于查询结果。 这旨在将领域逻辑保留在领域层。 它也为无处不在的语言和沟通提供了更好的服务。 想象一下,说“过期的订单规格”,而不是说“从tbl_order的过滤顺序,其中puts_at小于sysdate前30分钟”(见本答案 )。
- 由于单一责任原则被侵犯,它使得对实体行为的推理变得更加困难。 如果你需要解决存储/持久性问题,你知道去哪里和哪里不去。
- 它避免了给实体双向访问全局状态(通过存储库和域服务)的危险。 你也不想破坏你的交易界限。
Vernon Vaughn在红皮书的“实施域驱动devise”中提到了我所知道的两个地方的这个问题(注:本书完全由Evans赞同,你可以在前言中阅读)。 在“服务”的第7章中,他使用域服务和规范来解决聚合使用存储库和另一个聚合的需求,以确定用户是否已通过身份validation。 他被引述说:
作为一个经验法则,如果可能的话,我们应该尽量避免使用Aggregates内部的Repositories(12)。
Vernon,Vaughn(2013-02-06)。 实施领域驱动devise(Kindle Location 6089)。 培生教育。 Kindle版。
在第10章关于Aggregates的部分,标题为“模型导航”的部分中,他说(就在他build议使用全局唯一ID来引用其他聚合根之后):
通过身份引用并不能完全阻止通过模型的导航。 有些会使用一个聚合内部的Repository(12)进行查找。 这种技术被称为断开域模型,它实际上是一种延迟加载的forms。 不过,有一种不同的推荐方法:使用存储库或域服务(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)); } ... }
他接着又提到另一个解决scheme,即如何在一个集合命令方法中使用域服务以及双重分派 。 (我不能推荐阅读他的书有多大的好处,当你厌倦了通过互联网search的时候,请把这本书当好钱,然后阅读这本书。)
然后,我和总是亲切的Marco Pivetta @Ocramius进行了一些讨论 ,他向我展示了一些关于从域中取出规范的代码,并使用它:
1)不build议这样做:
$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); }
我发现这个博客有相当好的论据反对在实体内封装存储库:
http://thinkbeforecoding.com/post/2009/03/04/How-not-to-inject-services-in-entities
为什么分开数据访问?
从本书中,我认为模型驱动devise一章的前两页给出了一些理由,说明为什么要从领域模型的实现中抽象出技术实现细节。
- 你想保持领域模型和代码之间的紧密联系
- 分离技术问题有助于certificate该模型对于实施是实用的
- 你想要无处不在的语言渗透到系统的devise中
这似乎都是为了避免与系统的实际实施脱节的一个单独的“分析模型”。
据我所了解的这本书,它说这个“分析模型”最终可能不考虑软件的实现而devise。 一旦开发人员试图实现商业方面所理解的模型,他们就会根据需要形成自己的抽象,从而在沟通和理解上造成了一堵墙。
另一方面,将太多技术问题引入领域模型的开发人员也会造成这种分歧。
所以你可以认为,实践分离的问题,如持久性可以帮助防止这些devise分析模型分歧。 如果觉得有必要在模型中引入持久性的东西,那么它就是一面红旗。 也许这个模型对于实现来说是不实际的。
引用:
“单一模型减less了错误的可能性,因为devise现在是仔细考虑的模型的直接产物,devise甚至代码本身都具有模型的交stream性。
我解释这个的方式,如果你最后有更多的代码处理数据库访问等事情,你就失去了交stream。
如果需要访问数据库是为了检查唯一性,请查看:
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; }
优点:界面分离出“数据访问”pipe道代码,使您仍然可以编写testing。 数据访问可以根据具体情况进行处理,比通用策略具有更好的性能。
缺点:调用代码必须假定什么已经加载,哪些没有。
说出于性能的原因,GetOrderLines返回带有空的ProductInfo属性的OrderLine对象。 开发人员必须熟悉界面背后的代码。
我在真实的系统上试过这个方法。 您最终会随时更改所加载内容的范围,尝试修复性能问题。 你最后偷看后面的界面,看看数据访问代码,看看什么是和未被加载。
现在,分离关注点应该允许开发人员尽可能多地关注代码的一个方面。 接口技术消除了这个数据是如何加载的,而不是加载了多less数据,何时加载,何时加载。
结论:相当低的分离!
懒加载
数据按需加载。 加载数据的调用隐藏在对象图本身内部,访问属性的地方可能导致sql查询在返回结果之前执行。
foreach (var line in order.OrderLines) { total += line.Price; }
优点:数据访问的“时间,地点和方式”对于专注于域逻辑的开发人员是隐藏的。 处理加载数据的聚合中没有代码。 加载的数据量可以是代码所需的确切数量。
缺点:当遇到性能问题时,当你有一个通用的“一刀切”解决scheme时,很难修复。 延迟加载可能会导致整体性能变差,而实施延迟加载可能会非常棘手。
angular色接口/渴望提取
每个用例都通过由聚合类实现的angular色接口进行显式指定,从而允许在每个用例中处理数据加载策略。
获取策略可能如下所示:
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用于构build聚合,然后聚合完成其工作。
优点:允许每个用例的自定义代码,以实现最佳性能。 与界面隔离原则是一致的 。 没有复杂的代码要求。 集合unit testing不必模拟加载策略。 通用加载策略可以用于大多数情况(例如“加载全部”策略),并且在必要时可以实施特殊的加载策略。
缺点:开发人员在更改域代码之后仍然需要调整/审阅获取策略。
使用获取策略方法,您可能仍然会发现自己正在更改自定义获取代码以更改业务规则。 这不是一个完美的问题分离,但最终会更容易维护,比第一种scheme更好。 获取策略确实封装了HOW,WHEN和WHERE数据的加载。 它具有更好的关注点分离,而不会像一刀切的加载方法那样失去灵活性。
多么好的问题 我处在发现的同一条路上,互联网上的大多数答案似乎带来了许多问题,因为他们带来了解决scheme。
所以(写下一些我不同意的东西的风险)到目前为止,这是我的发现。
首先,我们喜欢一个丰富的领域模型 ,它给了我们很高的发现能力 (我们可以对聚合做什么)和可读性 (expression方法调用)。
// Entity public class Invoice { ... public void SetStatus(StatusCode statusCode, DateTime dateTime) { ... } public void CreateCreditNote(decimal amount) { ... } ... }
我们希望在不向实体的构造函数中注入任何服务的情况下实现这一点,因为:
- 引入新行为(使用新服务)可能导致构造器更改,这意味着更改会影响每个实例化实体的行 !
- 这些服务不是模型的一部分 ,但build设者注入会表明他们是。
- 服务(甚至是其接口)通常是实现细节,而不是域的一部分。 领域模型将有一个外向的依赖 。
- 这可能会混淆为什么没有这些依赖关系,实体就不能存在。 (一个信用票据服务,你说?我甚至不会用信用票据做任何事情…)
- 这会使其难以实例化,因此很难进行testing 。
- 这个问题很容易传播,因为包含这个的其他实体会得到相同的依赖关系 – 这些依赖关系可能看起来非常不自然的依赖关系 。
那么,我们可以这样做吗? 到目前为止我的结论是, 方法依赖和双重调度提供了一个体面的解决scheme。
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()
现在需要一个负责创build贷项通知单的服务。 它使用双重调度 , 将工作完全卸载到负责的服务,同时保持 Invoice
实体的可发现性 。
SetStatus()
现在对logging器有一个简单的依赖关系 ,显然它将执行部分工作 。
对于后者,为了使客户端代码更容易,我们可以通过IInvoiceService
login。 毕竟,发票日志似乎是一个发票内在的。 这样一个单一的IInvoiceService
帮助避免各种操作需要各种小型服务。 其缺点是,这个服务将做什么变得模糊。 它甚至可能开始看起来像双派,而大部分工作仍然在SetStatus()
本身中完成。
我们仍然可以命名参数“logging器”,希望揭示我们的意图。 似乎有点弱,但。
相反,我会select要求IInvoiceLogger
(正如我们已经在代码示例中所做的那样)并让IInvoiceService
实现该接口。 客户端代码可以简单地使用其单一的IInvoiceService
作为所有Invoice
方法,要求任何这样一个非常特殊的,发票内置的“小型服务”,而方法签名仍然清楚地表明他们要求的是什么。
我注意到我没有明确地解决仓库问题。 那么,logging器是或使用存储库,但让我也提供一个更明确的例子。 我们可以使用相同的方法,如果只需要一个或两个方法来存储库。
public class Invoice { public IEnumerable<CreditNote> GetCreditNotes(ICreditNoteRepository repository) { ... } }
事实上,这提供了一个替代永远麻烦的懒惰负载 。 我们可以简单地使Invoice
记住它已经加载的信用票据,并避免重新加载。
对于真正的,基于属性的懒加载,我目前使用构造函数注入,但以持久性无知的方式。
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
。
另一方面,创build实际新 Invoice
代码只会传递一个返回空列表的函数:
new Invoice(inv => new List<CreditNote>() as IEnumerable<CreditNote>)
(一个自定义的ILazy<out T>
可以将我们的丑陋的ILazy<out T>
成IEnumerable
,但这会使讨论复杂化。)
我很乐意听取您的意见,偏好和改进!
对我来说,这似乎是一般的良好的OOD相关的做法,而不是特定于DDD。
我能想到的原因是:
- 分离关注点(实体应与持续存在的方式分开,因为根据使用场景,可能会有多个策略持续存在)
- 从逻辑上讲,实体可以在低于存储库运行水平的水平上看到。 较低级别的组件不应该具有关于较高级别组件的知识。 所以参赛作品不应该有知识库。
在所有这些单独的图层出现之前,我学会了编写面向对象编程,而我的第一个对象/类DID直接映射到数据库。
最后,我添加了一个中间层,因为我不得不迁移到另一个数据库服务器。 我曾多次看到/听说过相同的情况。
我认为将数据访问(又名“Repository”)从业务逻辑中分离出来,就是其中之一,已经被重新创build了几次,或者说Domain Driver Driven Design的书,使它成为很多“噪音”。
我目前使用3层(GUI,逻辑,数据访问),像许多开发者一样,因为它是一个很好的技术。
将数据分离到一个Repository
层(也称为Data Access
层)中可能被看作是一种很好的编程技术,而不仅仅是一个规则。
像许多方法一样,您可能希望从未实现的开始,并最终在您了解它们之后更新您的程序。
Quote:伊利亚特并不是完全由荷马发明,卡尔米纳布拉纳并不完全是由卡尔奥尔夫发明的,在这两种情况下,把其他人的工作,所有人都得到信贷;-)
这是Eric Evans领域驱动devise书籍,还是来自其他地方?
这是旧的东西。 埃里克的书让它更加嗡嗡。
它背后的理由在哪里有一些很好的解释?
理由很简单 – 当人们面对模糊的多重背景时,人的头脑会变得虚弱。 它们导致模棱两可(南美洲/北美洲意味着南美/北美),模棱两可导致信息不断映射,只要头脑“接触到”,总结为不良的生产力和错误。
业务逻辑应尽可能清晰地反映出来。 外键,规范化,对象关系映射来自完全不同的领域 – 这些东西是技术性的,与计算机相关的。
比方说,如果你正在学习如何手写,你不应该背负笔的制造,墨水为什么在纸上发明,纸张是什么时候发明,还有什么是中国的其他着名发明。
编辑:澄清:我不是在谈论经典的OO实践,将数据访问从业务逻辑分离到一个单独的层 – 我正在谈论的具体安排,在DDD中,实体不应该与数据交谈访问层(即它们不应该保存对Repository对象的引用)
我之前提到的原因还是一样的。 这里只是更进一步。 为什么实体如果完全可以(至less接近)应该是部分持久性的无知? 我们的模型所缺less的与领域无关的关注 – 当它需要重新解释的时候,我们的思维会获得更多的呼吸空间。
简单的弗农·沃恩给出了一个解决scheme:
使用存储库或域服务在调用集合行为之前查找依赖对象。 客户端应用程序服务可以控制这个。
在理想的世界中,DDDbuild议实体不应该参考数据层。 但我们不住在理想的世界。 域可能需要引用其他域对象的业务逻辑,他们可能没有依赖关系。 对于实体来说,为了只读目的而引用存储库层来提取值是合乎逻辑的。