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

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

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

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

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

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


当前回答

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

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

域服务捕获关于实体或聚合之间更改的规则。我们可以这样改变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.

其他回答

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

弗农·沃恩给出了一个解决方案:

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

To cite Carolina Lilientahl, "Patterns should prevent cycles" https://www.youtube.com/watch?v=eJjadzMRQAk, where she refers to cyclic dependencies between classes. In case of repositories inside aggregates, there is a temptation to create cyclic dependencies out of conveniance of object navigation as the only reason. The pattern mentioned above by prograhammer, that was recommended by Vernon Vaughn, where other aggregates are referenced by ids instead of root instances, (is there a name for this pattern?) suggests an alternative that might guide into other solutions.

类之间循环依赖的例子(忏悔):

(Time0): Sample和Well这两个类彼此引用(循环依赖)。Well指的是Sample,而Sample指的是Well,这是为了方便(有时是对样品进行循环,有时是对一个板中的所有孔进行循环)。我无法想象样本不会指向它所在的井。

(Time1):一年之后,许多用例实现了....现在有一些情况下,样本不应该指向它所在的井。在一个工作步骤内有临时板。这里的孔指的是样品,而样品又指的是另一个盘子上的孔。正因为如此,当有人试图实现新功能时,有时会出现奇怪的行为。渗透需要时间。

我也从上面提到的这篇关于惰性加载的负面方面的文章中得到了帮助。

这里有点混乱。存储库访问聚合根。聚合根是实体。这样做的原因是关注点的分离和良好的分层。这在小型项目中没有意义,但如果您在一个大型团队中,您会说:“您通过产品存储库访问产品。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.

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

真是个好问题。我也在探索的路上,互联网上的大多数答案带来的问题似乎和它们带来的解决方案一样多。

所以(冒着写一些我一年后不同意的东西的风险),这里是我迄今为止的发现。

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

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

我们希望在不向实体的构造函数中注入任何服务的情况下实现这一点,因为:

Introduction of a new behavior (that uses a new service) could lead to a constructor change, meaning the change affects every line that instantiates the entity! These services are not part of the model, but constructor-injection would suggest that they were. Often a service (even its interface) is an implementation detail rather than part of the domain. The domain model would have an outward-facing dependency. It can be confusing why the entity cannot exist without these dependencies. (A credit note service, you say? I am not even going to do anything with credit notes...) It would make it hard instantiate, thus hard to test. The problem spreads easily, because other entities containing this one would get the same dependencies - which on them may look like very unnatural dependencies.

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

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”,希望能够揭示我们的意图。不过看起来有点弱。

相反,我将选择请求一个iinvoiceogger(正如我们在代码示例中已经做的那样),并让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中。

另一方面,创建实际新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>())

我很高兴听到你的意见,喜好和改进!