【问题标题】:How do I gracefully avoid dependencies on infrastructure services from domain entities using DDD?如何优雅地避免使用 DDD 的域实体对基础设施服务的依赖?
【发布时间】:2017-11-13 00:53:54
【问题描述】:

背景

假设我的任务是使用领域驱动设计 (DDD) 在通知发送领域构建一个系统。该系统的关键要求之一是它需要支持各种“类型”的通知,例如短信、电子邮件等。

在开发领域模型的几次迭代之后,我继续将Notification 基类作为一个实体,将子类SMSNotificationEmailNotification 等作为子类(每个也是一个实体) )。

Notification

public abstract class Notification extends Entity<UUID> {
    //...fields...

    public abstract void send();
}

SMSNotification

public class SMSNotification extends Notification {

    public void send(){
         //logic for sending the SMS notification using an infrastructure service.
    }
}

EmailNotification

public class EmailNotification extends Notification {

    public void send(){
        //logic for sending the email notification using an infrastructure service.
    }
}

问题

  • 使用这种当前的设计方法,Notification 的每个子类都与基础设施服务进行交互,其中基础设施的任务是与某些外部系统进行交互。

在介绍domain services 的概念时,Eric Evans 在他的《领域驱动设计》一书的第 107 页上专门用了一点页面空间:

...,在大多数开发系统中,在域对象和外部资源之间建立直接接口是很尴尬的。我们可以用一个根据模型接受输入的外观来装扮这样的外部服务,......但是无论我们可能拥有什么中介,即使它们不属于我们,这些服务也正在履行域责任...... .

  • 如果相反,我使用 Evans 的建议在我的域模型中获取 SendNotificationService,而不是在 Notification 的每个子类上使用 send 方法,我不确定如何避免需要了解 提供了什么类型的通知,以便可以采取适当的基础设施操作:

SendNotificationService(域服务)

public class SendNotificationService {
    public void send(Notification notification){
        //if notification is an SMS notification...
        //    utilize infrastructure services for SMS sending.
        //if notification is an email notification...
        //    utilize infrastructure services for email sending.
        //
        //(╯°□°)╯︵ ┻━┻)
    }
}

我在这里错过了什么?

  • 面向对象的设计原则促使我倾向于首先提出模型,包括NotificationSMSNotificationEmailNotification 类。在Notification 的每个子类上实现send 方法是有意义的,因为所有通知都需要发送(证明其​​在Notification 中的位置)并且Notification 的每个“类型”或子类将具有特殊的行为方式通知已发送(证明在Notification 中制作send 是合理的)。这种方法还遵循开放/封闭原则 (OCP),因为 Notification 类将禁止修改,并且随着支持新的通知类型,可以创建 Notification 的新子类来扩展功能。 不管怎样,似乎在没有实体与外部服务接口以及在 DDD 中根本没有实体的子类方面达成了共识。
  • 如果从Notification中删除发送通知的行为,那么它的放置位置必须知道通知的“类型”,并采取相应的行动,我只能将其概念化为if...else...语句链,它直接与 OCP 相矛盾。

【问题讨论】:

  • 答案是:永远不要在域实体中注入任何东西。域实体应该照顾它自己的逻辑。不适合实体的所有其他逻辑都应该在域服务中实现,这应该是非常罕见的。如果您想采用这种方法,即实体负责在发生某些事情时进行通知,您应该转向基于事件的架构,在这种架构中,域事件和域处理程序会触发通知。
  • @FreerFactor 与技术能力紧密耦合的领域是最难建模的领域之一。对您的业务问题的 3 行描述并没有告诉我们您打算如何处理这些通知,而不是发送通知、对它们采取行动的用例是什么,以及最终是否使用 DDD 战术模式或其他方式。
  • 如果我们坚持您对问题域的描述,您可以使用简单的 CRUD,甚至可以依赖外部技术平台的日志记录和审核,然后对您的通知的良好历史感到满意。
  • @guillaume31 我对文档模板也有同样的疑问,DocumentTemplate 有一些状态,但大多数行为都是技术性的。我想知道我是否应该做类似docTemplate.generate(service)service.generate(docTemplate) 之类的事情,甚至让docTemplate 持有对在基础设施中实现的TemplateFile 接口的引用,其中每种模板都有一个(直接服务参考) ?如果我根本没有对域模型进行建模,我应该只使用普通结果集和所有逻辑到服务中吗?没有领域模型就无法思考......
  • 也许实体(好)与服务(坏)是错误的二分法,而 DDD 是错误的角度。将DocumentGenerator 称为“服务”是否有意义?如果模板管理部分非常简单,你不能将它建模为 CRUD 并将技术生成部分实现为普通的旧非 DDD 对象吗?如果模板部分很复杂,为什么不使用两个 BC - 一个用于模板,一个用于生成?

标签: oop domain-driven-design


【解决方案1】:

TLDR:如果您需要针对您的域执行一些基础架构逻辑,并且您需要从域中对其进行一些输入 - 不要内置它,只需使用适当的数据/标记声明意图。然后,您稍后将在基础设施层处理此声明的意图。

除了传递机制之外,各种通知是否有任何不同?如果没有 - 使用带有附加字段(枚举,如果列表已知,或某种标记)的通知值对象(或实体,如果您的域模型需要)来存储传递方法名称就足够了。也许,每个通知实例可能有许多这样的方法。

然后你有一个业务逻辑 - 一个域服务 - 来触发通知。域服务应该只依赖于域词汇。例如 NotificationDeliveryMethodProvider。

在您的适配器层中,您可以实现各种交付方法提供程序以与基础架构进行交互。以及根据 DeliveryMethod 枚举(或标记)中的值获取提供者的工厂。

基本上,以任何方式“发送”自己的操纵不是聚合的责任。它的职责应该是维护其状态,以一致的方式执行状态转换并协调其封闭实体/值的状态。并触发有关其状态变化的事件。

在我的一个项目中,我在domain 包下使用了以下子包:

  • provides - 提供给客户端的域服务接口
  • cousumes - 上游依赖的接口
  • businesslogic - 领域服务的实现
  • values - 带有代码的值对象以强制执行其不变量
  • ...

除了domain 包还有:

  • adapters 处理基础设施的包
  • App 对象,所有接口都绑定到实现。
  • [也可能有]config 包,但在我的情况下它很轻。

这些domainadaptersAppconfig可以部署为具有明确依赖结构的不同jar文件,如果您需要为其他人强制执行它。

【讨论】:

  • 您能澄清一下“不要内置,只需使用适当的数据/标记声明意图”的意思吗?
  • 另外,您建议的工厂不会基于枚举具有相同的 if/else 块链,类似于问题中描述的情况吗?我知道工厂将是集中该逻辑的更好地方,但归根结底,我不禁喜欢基于“通知类型”或“交付方法”的可能不断扩展的条件链是缺少多态性的症状。
  • “声明意图”意味着您不需要对特定实现的任何依赖来声明应该通过电子邮件发送通知。此声明是域逻辑的一部分。适配器实现是基础架构逻辑的一部分。您不需要在域模块依赖项中使用它来完成域逻辑。它只应在运行时绑定。在域内存储枚举 DeliveryMethod 值就足够了。
  • 接下来,工厂不可避免地必须有一些分支逻辑来选择实现。选择需要坐在某个地方。它既不是领域逻辑,也不是基础设施逻辑。相反,它是应用程序逻辑。因此,应用程序是代码的另一部分,它依赖于域和适配器(基础设施)。它的职责是绑定运行时配置、实现和域。 OOP 无法帮助您摆脱这些责任。它只是有助于将它们分开以方便处理。
  • 但是,如果我们考虑您的领域,我们不仅可以谈论工厂(选择实现),还可以谈论策略,其职责是选择交付方法的类型(接口)供应商根据声明的交付方式。然后它成为域逻辑的一部分。但是这样一来,战略又不与任何实施相结合。我认为,只要域需要有一些分支,并且这个分支会操纵域概念(而不是实现)——在域层中包含这个逻辑就可以了。
【解决方案2】:

我同意你的观点,Notification 的主要职责应该是它可以发送自己。这就是它存在的全部原因,所以它是一个很好的抽象。

public interface Notification {
    void send();
}

此接口的实现您正在寻找的基础设施服务。它们不会(不应该)被其他“业务”或“核心”类直接引用。

注意Entity:我自己阅读蓝皮书的收获是,DDD不是关于使用实体、服务、聚合根和类似的东西。要点是无处不在的语言,上下文,如何工作域本身。 Eric Evans 本人表示,这种思维可以应用于不同的范式。它不必总是涉及相同的技术问题。

注意其他评论 (@VoiceOfUnreason) 中的“常规”设计:至少在面向对象中,“保持状态”不是真正的责任。责任只能直接来自通用语言,换句话说,来自业务。 “常规”(即程序)设计将数据和功能分开,而面向对象则恰恰相反。因此,请务必确定您的目标是哪种范式,然后选择解决方案可能会更容易。

【讨论】:

    【解决方案3】:

    在开发域模型的几次迭代之后,我继续将 Notification 基类作为一个实体,将 SMSNotification、EmailNotification 等子类作为子类

    这可能是一个错误。

    public abstract class Notification extends Entity<UUID> {
        public abstract void send();
    }
    

    几乎可以肯定。你可以让它工作,如果你扭曲得足够多,但你走错路了。

    领域模型中实体的职责是管理状态。还要让实体对跨进程边界发送消息的副作用负责,这违反了关注点分离。所以应该有一个合作者。

    正如您将注意到的,对于 Evans,协作采用域服务的形式,该域服务本身将与基础设施服务协作以产生所需的结果。

    授予实体访问域服务的最直接方法是简单地将域服务作为参数传递。

    public class SMSNotification extends Notification {
        public void send(SMSNotificationService sms) {
            //logic for sending the SMS notification using an infrastructure service.
        }
    

    SMSNotification 支持与 SMSNoticationService 提供者的协作,我们对此进行了明确说明。

    您在此处提供的界面看起来更像Command Pattern。如果你想让它工作,你通常会在 constructor

    中连接特定的实现
    public class SMSCommand extends NotificationCommand {
        private final SMSNotificationService sms;
        private final SMSNotification notification;
    
        public final send() {
            notification.send(sms);
        }
    }
    

    您可以使用泛型做一些事情(取决于您选择的语言),这些事情可以使这些不同服务之间的相似之处更加明显。例如

    public abstract class Notification<SERVICE> extends Entity<UUID> {
        public abstract void send(SERVICE service);
    }
    
    public class SMSNotification extends Notification<SMSNotificationService> {
        public void send(SMSNotificationService service){
            //logic for sending the SMS notification using an infrastructure service.
        }
    }
    
    public class NotificationCommand<SERVICE> {
        private final SERVICE service;
        private final Notification<SERVICE> notification;
    
        public final send() {
            notification.send(service);
        }
    }
    

    这是主要的方法。

    有时适合的替代方法是使用穷人的模式匹配。不是传入特定类型实体所需的特定服务,而是将它们全部传入....

    public abstract class Notification extends Entity<UUID> {
        public abstract void send(SMSNotificationService sms, EmailNotificationService email, ....);
    }
    

    然后让每个实现精确地选择它需要的东西。我不认为这种模式在这里是一个好的选择,但它是一个偶尔有用的俱乐部。

    您有时会看到的另一种方法是在构造实体时将所需的服务注入到实体中

    SMSNotificationFactory {
        private final SMSNotificationService sms;
    
        SMSNotification create(...) {
            return new SMSNotification(sms, ...);
        }
    }
    

    再一次,一个很好的俱乐部在包里,但不适合这个用例 - 你可以做到,但突然间需要了解很多额外的组件来了解通知服务,以便将它们送到需要的地方成为。

    notification.send(service) 和 service.send(notification) 之间的最佳选择

    大概

    notification.send(service)
    

    使用“告诉,不要问”作为理由。您将协作者传递给域实体,它决定 (a) 是否协作,(b) 将什么状态传递给域服务,以及 (c) 如何处理返回的任何状态。

    SMSNotification::send(SMSNotificationService service {
        State currentState = this.getCurrentState();
        {
            Message m = computeMessageFrom(currentState);
            service.sendMessage(m);
        }
    }
    

    在边界,应用程序不是object oriented;我怀疑当我们从域的核心向域移动时,我们会看到实体让位于值,让位于更原始的表示。

    在阅读了一些关于纯域模型的内容之后,我不确定那里不应该有任何 IO

    事实上,这有点混乱。域服务的动机之一是将域模型与 IO 分离——所有 IO 关注点都由域服务实现处理(或者更可能是由域服务与之协作的应用程序/基础架构服务)。就实体而言,所涉及的方法只是一个函数。

    另一种方法是在关注点之间建立更多的分离;你让两个部分之间的编排变得明确

    List<SMSRequest> messages = domainEntity.getMessages();
    List<SMSResult> results = sms.send(messages)
    domainEntity.onSMS(results)
    

    在这种方法中,所有的 IO 都发生在 sms 服务本身内;与模型的交互被限制在内存表示中。您已经有效地获得了一个协议,可以管理模型中的更改和边界处的副作用。

    我觉得 Evans 是在建议 service.send(notification) 作为接口。

    我认为,对于课程而言,将实体传递给负责协调模型中多个更改的域服务是有意义的。在聚合更改的上下文中,我不会选择这种模式来与边界进行状态通信。

    【讨论】:

    • notification.send(service)service.send(notification) 之间的最佳选择。首先,我的目标是前者,但是在阅读了一些关于纯域模型的内容以及其中不应该有任何 IO 的事实之后,我不再确定了。似乎需要更多的模拟来进行测试等。
    • 我觉得 Evans 是在建议 service.send(notification) 做接口。本书的更多引用供参考(第 105 页):"A good (domain) service has three characteristics: 1. The operation relates to a domain concept that is not a natural part of an entity or value object. 2. The interface is defined in terms of the other elements of the domain model. 3. The operation is stateless." 以及(第 106 页)"...domain services are built on top of populations of entities and values, behaving like scripts that organize the potential of the domain to actually get something done."
    • 然后他甚至继续使用一个例子来涉及从假设的Account 类中删除“资金转移”的银行领域概念,而是改为FundsTransferService,这只是 协调参与事务的两个Account 对象之间的适当调用。 Account 对象没有按照您的建议接收服务并对其进行操作,而是由域服务本身进行编排。
    猜你喜欢
    • 1970-01-01
    • 2021-01-08
    • 2010-11-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-03-26
    • 2017-06-10
    • 2013-09-19
    相关资源
    最近更新 更多