在领域驱动设计中,似乎有很多一致认为实体不应该直接访问存储库。

这是出自Eric Evans的《领域驱动设计》一书,还是来自其他地方?

对其背后的原因有什么好的解释吗?

编辑:澄清一下:我不是在谈论将数据访问从业务逻辑分离到一个单独层的经典OO实践——我是在谈论DDD中的具体安排,即实体根本不应该与数据访问层对话(即它们不应该持有对Repository对象的引用)。

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

更新:2013年3月,对这个问题的点赞表明人们对此很感兴趣,即使有很多答案,我仍然认为如果人们对此有想法,还有更多的空间。


当前回答

为什么要分离数据访问?

从书中,我认为模型驱动设计一章的前两页给出了一些理由,说明为什么要从领域模型的实现中抽象出技术实现细节。

您希望在领域模型和代码之间保持紧密的连接 分离技术关注点有助于证明模型对于实现是可行的 你希望无处不在的语言渗透到系统的设计中

这似乎都是为了避免独立的“分析模型”与系统的实际实现分离。

根据我对这本书的理解,它说这个“分析模型”可以在不考虑软件实现的情况下被设计出来。一旦开发人员试图实现业务方面理解的模型,他们就会由于需要而形成自己的抽象,从而在交流和理解中造成障碍。

在另一个方向上,开发人员在领域模型中引入太多的技术关注也会导致这种分歧。

因此,您可以考虑实践关注点分离,例如持久性,可以帮助防止这些设计和分析模型出现分歧。如果觉得有必要在模型中引入持久性之类的东西,那么这就是一个危险信号。也许这个模型对于实现来说并不实用。

引用:

“单一模型减少了出错的可能性,因为现在的设计是经过仔细考虑的模型的直接产物。设计,甚至代码本身,都具有模型的交流性。”

我对此的解释是,如果你最终要用更多行代码来处理数据库访问之类的事情,你就失去了交流能力。

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

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

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

"所有规则都是不平等的"

and

使用领域模型模式

http://msdn.microsoft.com/en-us/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用于构建聚合,然后聚合完成它的工作。

优点:允许为每个用例定制代码,从而实现最佳性能。符合界面隔离原则。没有复杂的代码需求。聚合单元测试不必模拟加载策略。一般的加载策略可以用于大多数情况下(例如“加载全部”策略),特殊的加载策略可以在必要时实现。

缺点:开发者在修改域代码后仍然需要调整/审查抓取策略。

使用获取策略方法,您可能仍然会发现自己在更改业务规则时更改自定义获取代码。这不是一个完美的关注点分离,但最终会更易于维护,并且比第一种选择更好。抓取策略确实封装了如何、何时和何处加载数据。它具有更好的关注点分离,而不会像“一刀切”的惰性加载方法那样失去灵活性。

其他回答

这是一个非常好的问题。我期待着就此进行一些讨论。但我想DDD的几本书里都提到了吉米·尼尔森斯和埃里克·埃文斯。我想通过示例也可以看到如何使用存储库模式。

BUT lets discuss. I think a very valid thought is why should an entity know about how to persist another entity? Important with DDD is that each entity has a responsibility to manage its own "knowledge-sphere" and shouldn't know anything about how to read or write other entities. Sure you can probably just add a repository interface to Entity A for reading Entities B. But the risk is that you expose knowledge for how to persist B. Will entity A also do validation on B before persisting B into db?

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

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

但是我确信总会有这样的场景:您想通过实体来使用存储库。你必须考虑每一种情况才能做出有效的判断。优点和缺点。但是在我看来,存储库实体解决方案有很多缺点。它一定是一个非常特殊的场景,优点抵消了缺点....

实体只捕获与其有效状态相关的规则。其中的数据有效吗?其中的数据可以这样改变吗?

聚合根对一组实体执行相同的操作。汇总的数据有效吗?总体数据能以这种方式改变吗?

域服务捕获关于实体或聚合之间更改的规则。我们可以这样改变X和Y吗?

None of this ever requires access to a repository or to infrastructure. What you do is that an application service will offer up a domain use case, for that use case, the application service will gather all the needed data from the repositories, that will return it your domain entities and/or aggregate roots and their value objects. The entities/aggregate roots and value objects would have validated that they are in a good state when created by the repository. Then the application service will use a combination of those entities (some of them could be aggregate roots), to perform the domain use case. If the domain use case requires changing X, Y and Z, the application service will ask X, Y and Z entities/aggregate roots if the current use case request of changes can be made to X, Y and Z, and if so, how should it be made. Finally, the application service will commit those changes back to the repository.

如果某些更改跨越实体或聚合,应用程序服务将使用域服务询问是否可以进行更改以及如何进行更改,并再次使用存储库提交这些更改。

如果一个域用例跨越多个有界上下文,这意味着它需要跨有界上下文的信息或更改,这被称为流程,并且您可以让一个流程服务管理整个流程生命周期,它将利用多个有界上下文的应用程序服务来跨所有有界上下文协调整个流程。

Finally, the application service can also use other application services, could be other micro-services in a shared bounded context, that would imply they share the same domain model, or it could do so across to application services in other bounded contexts, in which case you'd want to model those within your own bounded context's domain model as well, you'd treat those other bounded contexts much like a repository in a way. The application service communicates with another bounded context to get info about that other context, it then creates a representation of that info within its own domain model, using its own entities and VOs, and aggregates, which will again validate that state within their context. Similarly, you can commit changes to your domain model to other bounded contexts by asking them to change accordingly. All this can be implemented with direct method calls, remote API calls, async events, shared kernel, etc.

And to answer why it is like so, that's because the whole point is building software that can evolve over time without it becoming slower to make changes to it and add/modify its behavior while retaining its current correctness with regards to its current functionality. A good way to do this is by making it a change in one place doesn't break things elsewhere. This is why bounded contexts exist, already changes are restricted to each context, so a change in one is less likely to break another. This is also why the domain model validates all changes to the domain state, so you can't change part of the state in ways that breaks other usage of it. This is why aggregates are used, to maintain a change boundary between the things that need one, and clearly not have one where it doesn't need one. Finally, by having the whole domain layer, with domain model and domain services, not depend on any infrastructure, like the repository (and thus the DB), a change to the DB or repository will also not be able to break your domain model or services.

P.S.: Also note I use the term "state" loosely. It doesn't have to be a static value; state could be the application of some dynamic computation or rules that generates state when requested. You can have something like totalItemsCount on some entity which computes it when asked about what is the current totalItemsCount for the entity. Again, the entity will make sure to return you valid state, that means it will know how to correctly count the total and make sure that what is returned is the correct application of the domain rules for totalItemsCount.

为什么要分离数据访问?

从书中,我认为模型驱动设计一章的前两页给出了一些理由,说明为什么要从领域模型的实现中抽象出技术实现细节。

您希望在领域模型和代码之间保持紧密的连接 分离技术关注点有助于证明模型对于实现是可行的 你希望无处不在的语言渗透到系统的设计中

这似乎都是为了避免独立的“分析模型”与系统的实际实现分离。

根据我对这本书的理解,它说这个“分析模型”可以在不考虑软件实现的情况下被设计出来。一旦开发人员试图实现业务方面理解的模型,他们就会由于需要而形成自己的抽象,从而在交流和理解中造成障碍。

在另一个方向上,开发人员在领域模型中引入太多的技术关注也会导致这种分歧。

因此,您可以考虑实践关注点分离,例如持久性,可以帮助防止这些设计和分析模型出现分歧。如果觉得有必要在模型中引入持久性之类的东西,那么这就是一个危险信号。也许这个模型对于实现来说并不实用。

引用:

“单一模型减少了出错的可能性,因为现在的设计是经过仔细考虑的模型的直接产物。设计,甚至代码本身,都具有模型的交流性。”

我对此的解释是,如果你最终要用更多行代码来处理数据库访问之类的事情,你就失去了交流能力。

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

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

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

"所有规则都是不平等的"

and

使用领域模型模式

http://msdn.microsoft.com/en-us/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用于构建聚合,然后聚合完成它的工作。

优点:允许为每个用例定制代码,从而实现最佳性能。符合界面隔离原则。没有复杂的代码需求。聚合单元测试不必模拟加载策略。一般的加载策略可以用于大多数情况下(例如“加载全部”策略),特殊的加载策略可以在必要时实现。

缺点:开发者在修改域代码后仍然需要调整/审查抓取策略。

使用获取策略方法,您可能仍然会发现自己在更改业务规则时更改自定义获取代码。这不是一个完美的关注点分离,但最终会更易于维护,并且比第一种选择更好。抓取策略确实封装了如何、何时和何处加载数据。它具有更好的关注点分离,而不会像“一刀切”的惰性加载方法那样失去灵活性。

对我来说,这似乎是与OOD相关的一般良好实践,而不是DDD特有的。

我能想到的原因是:

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

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

通过这种方式,您在业务逻辑和内容更新的位置上有非常非常清晰的分离。你不会有一个孩子独自编写了整个程序,对产品目录做了所有这些复杂的事情,当涉及到将其集成到上游项目时,你坐在那里看着它,意识到这一切都必须抛弃。这也意味着当人们加入团队,添加新功能时,他们知道该去哪里以及如何组织程序。

But wait! Repository also refers to the persistence layer, as in the Repository Pattern. In a better world an Eric Evans' Repository and the Repository Pattern would have separate names, because they tend to overlap quite a bit. To get the repository pattern you have contrast with other ways in which data is accessed, with a service bus or an event model system. Usually when you get to this level, the Eric Evans' Repository definition goes by the way side and you start talking about a bounded context. Each bounded context is essentially its own application. You might have a sophisticated approval system for getting things into the product catalog. In your original design the product was the center piece but in this bounded context the product catalog is. You still might access product information and update product via a service bus, but you must realize that a product catalog outside the bounded context might mean something completely different.

Back to your original question. If you're accessing a repository from within an entity it means the entity is really not a business entity but probably something that should exist in a service layer. This is because entities are business object and should concern themselves with being as much like a DSL (domain specific language) as possible. Only have business information in this layer. If you're troubleshooting a performance issue, you'll know to look elsewhere since only business information should be here. If suddenly, you have application issues here, you're making it very hard to extend and maintain an application, which is really the heart of DDD: making maintainable software.

对评论1的回应:好问题。所以并不是所有的验证都发生在领域层。夏普有一个属性“DomainSignature”,它可以做你想要的。它是持久性感知的,但是作为一个属性可以使域层保持干净。它确保在您的示例中,没有名称相同的重复实体。

But let's talk about more complicated validation rules. Let's say you're Amazon.com. Have you ever ordered something with an expired credit card? I have, where I haven't updated the card and bought something. It accepts the order and the UI informs me that everything is peachy. About 15 minutes later, I'll get an e-mail saying there's a problem with my order, my credit card is invalid. What's happening here is that, ideally, there's some regex validation in the domain layer. Is this a correct credit card number? If yes, persist the order. However, there's additional validation at the application tasks layer, where an external service is queried to see if payment can be made on the credit card. If not, don't actually ship anything, suspend the order and wait for the customer. This should all take place in a service layer.

不要害怕在服务层创建可以访问存储库的验证对象。让它远离域层。