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

这是出自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用于构建聚合,然后聚合完成它的工作。

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

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

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

其他回答

起初,我相信允许我的一些实体访问存储库(即没有ORM的惰性加载)。后来我得出结论,我不应该这样做,我可以找到其他方法:

We should know our intentions in a request and what we want from the domain, therefore we can make repository calls before constructing or invoking Aggregate behavior. This also helps avoid the problem of inconsistent in-memory state and the need for lazy loading (see this article). The smell is that you cannot create an in memory instance of your entity anymore without worrying about data access. CQS can help reduce the need for wanting to call the repository for things in our entities. We can use a specification to encapsulate and communicate domain logic needs and pass that to the repository instead (a service can orchestrate these things for us). The specification can come from the entity that is in charge of maintaining that invariant. The repository will interpret parts of the specification into it's own query implementation and apply rules from the specification on query results. This aims to keep domain logic in the domain layer. It also serves the Ubiquitous Language and communication better. Imagine saying "overdue order specification" versus saying "filter order from tbl_order where placed_at is less than 30 minutes before sysdate" (see this answer). It makes reasoning about the behavior of entities more difficult since the Single-Responsibility Principle is violated. If you need to work out storage/persistence issues you know where to go and where not to go. It avoids the danger of giving an entity bi-directional access to global state (via the repository and domain services). You also don't want to break your transaction boundary.

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

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

沃恩·弗农(2013-02-06)。实现领域驱动设计(Kindle位置6089)。培生教育。Kindle版。

在关于聚合的第10章中,在“模型导航”一节中,他说(就在他建议使用全局唯一id来引用其他聚合根之后):

通过标识引用并不完全阻止导航通过 该模型。有些人会在聚合中使用存储库(12) 查找。这种技术称为断开域模型 它实际上是惰性加载的一种形式。有一个不同的建议 但是,方法是:使用存储库或域服务(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));
   } 
   ...
}

他接着还提到了另一种解决方案,即如何在聚合命令方法中使用域服务以及双重分派。(读他的书大有益处,我怎么推荐都不为过。在你厌倦了无休止地在网上翻找之后,拿出你应得的钱去读这本书。)

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

不建议这样做:

$user->mountFriends(); // <-- has a repository call inside that loads friends? 

在域服务中,这是很好的:

public function mountYourFriends(MountFriendsCommand $mount) {
    $user = $this->users->get($mount->userId()); 
    $friends = $this->users->findBySpecification($user->getFriendsSpecification()); 
    array_map([$user, 'mount'], $friends); 
}

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

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

虽然来晚了,但我还是会说点我的意见。

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

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

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

建立正确的对象图,在应用服务中使用正确的抓取策略,将纯内存中的对象传递给富域模型。惰性加载会潜入你的代码,并在最痛的地方打击你。

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

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

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

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

// 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>())

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