在前两篇文章中,我们了解了战略战术领域驱动设计。现在是学习如何将领域模型转化为工作软件的时候了——更具体地说,是如何使用六边形架构来实现。

尽管代码示例是用 Java 编写的,但前两篇文章非常通用。虽然本文中的很多理论也可以应用到其他环境和语言中,但我在编写时明确考虑了 Java 和 Vaadin。

同样,内容基于Eric Evans 的《Domain-Driven Design: Tackling Complexity in the Heart of Software》和Vaughn *Vernon**的《*实现领域驱动设计》这本书,我强烈建议您阅读这两本书。然而,即使我在之前的文章中也提出了自己的想法、想法和经验,但这篇文章更加强烈地被我的想法和信仰所影响。也就是说,是 Evans 和 Vernon 的书让我首先开始使用 DDD,我想我在这里写的内容与您在书中找到的内容相差无几。

为什么叫六边形?

六边形架构的名称来自于这种架构通常被描绘的方式:

六边形结构

我们将在本文后面讨论为什么使用六边形。这种架构也被命名为端口和适配器(这更好地解释了它背后的中心思想)和洋葱架构(因为它是分层的)。

下面,我们将仔细看看“洋葱”。我们将从核心开始——域模型——然后自己解决,一次一层,直到我们到达适配器以及与它们交互的系统和客户端。

六边形与传统层

一旦我们深入研究六边形架构,您会发现它与更传统的分层架构有几个相似之处。实际上,您可以将六边形架构视为分层架构的演变。但是,存在一些差异,尤其是在系统如何与外部世界交互方面。为了更好地理解这些差异,让我们先回顾一下分层架构:

传统的分层架构

原理是该系统由相互堆叠的层组成。较高层可以与较低层交互,但反之则不行。通常,在域驱动的分层架构中,UI 层位于顶部。反过来,该层与应用程序服务层交互,后者与位于域层中的域模型交互。在底部,我们有一个与外部系统(如数据库)通信的基础设施层。

在六边形系统中,你会发现应用层和领域层还是差不多的。但是,UI 层和基础设施层的处理方式非常不同。继续阅读以了解如何。

领域模型

六边形架构的核心在于域模型,它使用我们在上一篇文章中介绍的战术 DDD 的构建块来实现。这就是所谓的业务逻辑所在,所有业务决策都在这里。这也是软件中最稳定的部分,希望变化最少(当然,除非业务本身发生变化)。

领域模型一直是本系列前两篇文章的主题,因此我们将不再介绍它。但是,如果没有办法与之交互,则单独的领域模型不会提供任何价值。为此,我们必须向上移动到“洋葱”中的下一层。

应用服务

应用程序服务充当外观,客户端将通过它与域模型进行交互。应用服务具有以下特点:

  • 他们是无国籍的
  • 他们强制执行系统安全
  • 他们控制数据库事务
  • 他们协调业务运营,但不做出任何业务决策(即,它们不包含任何业务逻辑)

让我们仔细看看这意味着什么。

无国籍状态

应用程序服务不维护任何可以通过与客户端交互来更改的内部状态。执行操作所需的所有信息都应可用作应用程序服务方法的输入参数。这将使系统更简单,更易于调试和扩展。

如果您发现自己必须在单个业务流程的上下文中进行多个应用程序服务调用,您可以在其自己的类中对业务流程进行建模并将其实例作为输入参数传递给应用程序服务方式。然后,该方法将发挥作用并返回业务流程对象的更新实例,该实例又可用作其他应用程序服务方法的输入:

public class MyBusinessProcess {
    // Current process state
}

public interface MyApplicationService {

    MyBusinessProcess performSomeStuff(MyBusinessProcess input);

    MyBusinessProcess performSomeMoreStuff(MyBusinessProcess input);
}

您还可以使业务流程对象可变,并让应用程序服务方法直接更改对象的状态。我个人不喜欢这种方法,因为我相信它会导致不必要的副作用,尤其是在事务最终回滚的情况下。这取决于客户端如何调用应用程序服务,稍后将在有关端口和适配器的部分中讨论此问题。

有关如何实现更复杂和长期运行的业务流程的技巧,我鼓励您阅读 Vernon 的书。

安全执法

应用程序服务确保允许当前用户执行相关操作。从技术上讲,您可以在每个应用程序服务方法的顶部手动执行此操作,或者使用更复杂的东西,例如 AOP。只要安全性发生在应用程序服务层而不是域模型内部,如何实施安全性并不重要。现在,为什么这很重要?

当我们谈论应用程序的安全性时,我们往往更强调防止未经授权的访问,而不是允许授权的访问。因此,我们添加到系统中的任何安全检查基本上都会使其更难使用。如果我们将这些安全检查添加到域模型中,我们可能会发现自己处于无法执行重要操作的情况,因为我们在添加安全检查时没有想到它,现在它们阻碍了。通过将所有安全检查排除在域模型之外,我们得到了一个更灵活的系统,因为我们可以以任何我们想要的方式与域模型进行交互。系统仍然是安全的,因为无论如何都要求所有客户端通过应用程序服务。创建新的应用程序服务比更改域模型要容易得多。

代码示例

下面是两个 Java 示例,说明应用程序服务中的安全实施可能是什么样子。该代码尚未经过测试,应该更多地被视为伪代码而不是实际的 Java 代码。

第一个示例演示了声明式安全实施:

@Service
class MyApplicationService {

    @Secured("ROLE_BUSINESS_PROCESSOR") // <1>
    public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
        var customer = customerRepository.findById(input.getCustomerId()) // <2>
            .orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
        var someResult = myDomainService.performABusinessOperation(customer); // <3>
        customer = customerRepository.save(customer);
        return input.updateMyBusinessProcessWithResult(someResult); // <4>
    }
}
  1. 注释指示框架仅允许具有角色ROLE_BUSINESS_PROCESSOR的经过身份验证的用户调用该方法。
  2. 应用程序服务从域模型中的存储库中查找聚合根。
  3. 应用程序服务将聚合根传递给域模型中的域服务,存储结果(无论它是什么)。
  4. 应用程序服务使用域服务的结果来更新业务流程对象并将其返回,以便可以将其传递给参与同一长期运行流程的其他应用程序服务方法。

第二个示例演示手动安全实施:

@Service
class MyApplicationService {

    public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
        // We assume SecurityContext is a thread-local class that contains information
        // about the current user.
        if (!SecurityContext.isLoggedOn()) { // <1>
            throw new AuthenticationException("No user logged on");
        }
        if (!SecurityContext.holdsRole("ROLE_BUSINESS_PROCESSOR")) { // <2>
            throw new AccessDeniedException("Insufficient privileges");
        }

        var customer = customerRepository.findById(input.getCustomerId())
            .orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
        var someResult = myDomainService.performABusinessOperation(customer);
        customer = customerRepository.save(customer);
        return input.updateMyBusinessProcessWithResult(someResult);
    }
}
  1. 在实际应用程序中,您可能会创建在用户未登录时抛出异常的辅助方法。我在这个例子中只包含了一个更详细的版本来显示需要检查的内容。
  2. 与前一种情况一样,只允许具有该角色的用户ROLE_BUSINESS_PROCESSOR调用该方法。

事务管理

无论底层数据存储是否使用事务,每个应用程序服务方法都应设计为形成自己的单个事务。如果一个应用服务方法成功了,除了显式调用另一个应用服务来逆转操作(如果这样的方法甚至存在的话),没有办法撤销它。

如果您发现自己需要在同一个事务中调用多个应用程序服务方法,您应该检查应用程序服务的粒度是否正确。也许您的应用程序服务正在做的一些事情实际上应该在域服务中?您可能还需要考虑重新设计您的系统以使用最终一致性而不是强一致性(有关此的更多信息,请查看上一篇关于战术领域驱动设计的文章)。

从技术上讲,您可以在应用程序服务方法中手动处理事务,也可以使用 Spring 和 Java EE 等框架和平台提供的声明性事务。

代码示例

下面是两个 Java 示例,说明应用程序服务中的事务管理可能是什么样子。该代码尚未经过测试,应该更多地被视为伪代码而不是实际的 Java 代码。

第一个示例演示了声明式事务管理:

@Service
class UserAdministrationService {

    @Transactional // <1>
    public void resetPassword(UserId userId) {
        var user = userRepository.findByUserId(userId); // <2>
        user.resetPassword(); // <3>
        userRepository.save(user);
    }
}
  1. 该框架将确保整个方法在单个事务中运行。如果抛出异常,事务将回滚。否则,当方法返回时提交。
  2. 应用程序服务调用域模型中的存储库来查找User聚合根。
  3. 应用程序服务调用User聚合根上的业务方法。

第二个示例演示手动事务管理:

@Service
class UserAdministrationService {

    @Transactional
    public void resetPassword(UserId userId) {
        var tx = transactionManager.begin(); // <1>
        try {
            var user = userRepository.findByUserId(userId);
            user.resetPassword();
            userRepository.save(user);
            tx.commit(); // <2>
        } catch (RuntimeException ex) {
            tx.rollback(); // <3>
            throw ex;
        }
    }
}
  1. 事务管理器已被注入到应用程序服务中,以便服务方法可以显式地启动一个新事务。
  2. 如果一切正常,则在重置密码后提交事务。
  3. 如果发生错误,则回滚事务并重新抛出异常。

编排

正确编排可能是设计一个好的应用程序服务最困难的部分。这是因为您需要确保不会意外地将业务逻辑引入应用程序服务,即使您认为自己只是在进行编排。那么在这种情况下,编排意味着什么?

编排是指以正确的顺序查找和调用正确的域对象,传入正确的输入参数并返回正确的输出。在最简单的形式中,应用程序服务可以根据 ID 查找聚合,调用聚合上的方法,保存并返回。然而,在更复杂的情况下,该方法可能必须查找多个聚合、与域服务交互、执行输入验证等。如果你发现自己编写了很长的应用服务方法,你应该问自己以下问题:

  • 方法是做出业务决策还是要求领域模型做出决策?
  • 是否应该将某些代码移至域事件侦听器?

话虽如此,在应用程序服务方法中结束一些业务逻辑并不是世界末日。它仍然非常接近域模型并且封装得很好,并且应该很容易在以后重构到域模型中。不要浪费太多宝贵的时间来考虑是否应该将某些东西放入域模型或应用程序服务中,如果您不能立即清楚的话。

代码示例

这是一个典型的编排可能是什么样子的 Java 示例。该代码尚未经过测试,应该更多地被视为伪代码而不是实际的 Java 代码。

@Service
class CustomerRegistrationService {

    @Transactional // <1>
    @PermitAll // <2>
    public Customer registerNewCustomer(CustomerRegistrationRequest request) {
        var violations = validator.validate(request); // <3>
        if (violations.size() > 0) {
            throw new InvalidCustomerRegistrationRequest(violations);
        }
        customerDuplicateLocator.checkForDuplicates(request); // <4>
        var customer = customerFactory.createNewCustomer(request); // <5>
        return customerRepository.save(customer); // <6>
    }
}
  1. 应用程序服务方法在事务内部运行。
  2. 任何用户都可以访问应用服务方法。
  3. 我们调用一个 JSR-303 验证器来检查传入的注册请求是否包含所有必要的信息。如果请求无效,我们会抛出一个异常,该异常将报告给用户。
  4. 我们调用一个域服务来检查数据库中是否已经存在具有相同信息的客户。如果是这种情况,域服务将抛出异常(此处未显示),该异常将传播回用户。
  5. 我们调用一个域工厂,它将Customer使用来自注册请求对象的信息创建一个新的聚合。
  6. 我们调用域存储库来保存客户并返回新创建和保存的客户聚合根。

领域事件监听器

在上一篇关于战术领域驱动设计的文章中,我们谈到了领域事件和领域事件监听器。但是,我们没有讨论域事件侦听器在整个系统架构中的位置。我们从上一篇文章中回忆起,域事件侦听器不应首先影响发布事件的方法的结果。实际上,这意味着域事件侦听器应该在其自己的事务中运行。

因此,我认为域事件侦听器是一种特殊类型的应用程序服务,它不是由客户端调用,而是由域事件调用。换句话说:领域事件监听器属于应用服务层而不是领域模型。这也意味着域事件侦听器是一个不应该包含任何业务逻辑的编排器。根据发布某个域事件时需要发生的情况,您可能必须创建一个单独的域服务,以决定在有多个前进路径时如何处理它。

话虽如此,在上一篇文章中关于聚合的部分中,我提到有时可能有理由在同一个事务中更改多个聚合,即使这违反了聚合设计指南。我还提到,这最好通过领域事件来实现。在这种情况下,域事件侦听器必须参与当前事务,从而可能影响发布事件的方法的结果,从而违反域事件和应用程序服务的设计准则。只要您有意识地这样做并意识到您将来可能面临的后果,这并不是世界末日。有时你只需要务实。

输入和输出

设计应用程序服务时的一个重要决定是决定使用哪些数据(方法参数)以及返回哪些数据。你有三个选择:

  1. 直接使用领域模型中的实体和值对象。
  2. 使用单独的数据传输对象 (DTO)。
  3. 使用结合了上述两者的域有效负载对象 (DPO)。

每种替代方案都有其优点和缺点,因此让我们仔细研究一下。

实体和聚合

在第一个备选方案中,应用程序服务返回整个聚合(或其部分)。客户端可以对它们做任何想做的事情,当需要保存更改时,聚合(或其部分)作为参数传递回应用程序服务。

当域模型贫乏(即它只包含数据而没有业务逻辑)并且聚合小而稳定(因为在不久的将来不太可能发生太大变化)时,这种替代方案效果最好。

如果客户端将通过 REST 或 SOAP 访问系统并且聚合可以轻松地序列化为 JSON 或 XML 并返回,它也可以工作。在这种情况下,客户端实际上不会直接与您的聚合进行交互,而是与可能以完全不同的语言实现的聚合的 JSON 或 XML 表示进行交互。从客户的角度来看,聚合只是 DTO。

这种替代方案的优点是:

  • 您可以使用已有的课程
  • 无需在域对象和 DTO 之间进行转换。

缺点是:

  • 它将域模型直接耦合到客户端。如果域模型发生变化,您也必须更改您的客户端。
  • 它对验证用户输入的方式施加了限制(稍后会详细介绍)。
  • 您必须以这样一种方式设计您的聚合,即客户端不能将聚合置于不一致的状态或执行不允许的操作。
  • 您可能会遇到聚合 (JPA) 中实体的延迟加载问题。

就个人而言,我尽量避免这种方法。

数据传输对象

在第二种选择中,应用程序服务使用并返回数据传输对象。DTO 可以对应领域模型中的实体,但更多时候它们是为特定的应用程序服务甚至特定的应用程序服务方法(例如请求和响应对象)而设计的。然后,应用程序服务负责在 DTO 和域对象之间来回移动数据。

当域模型的业务逻辑非常丰富,聚合很复杂,或者当域模型预计会发生很大变化同时保持客户端 API 尽可能稳定时,这种替代方案效果最好。

这种替代方案的优点是:

  • 客户端与域模型解耦,无需更改客户端即可更轻松地对其进行演进。
  • 只有实际需要的数据在客户端和应用程序服务之间传递,从而提高性能(特别是如果客户端和应用程序服务在分布式环境中通过网络进行通信)。
  • 控制对域模型的访问变得更容易,特别是如果只允许某些用户调用某些聚合方法或查看某些聚合属性值。
  • 只有应用程序服务会与活动事务中的聚合进行交互。这意味着您可以在聚合 (JPA) 中利用延迟加载实体。
  • 如果 DTO 是接口而不是类,那么您将获得更大的灵活性。

缺点是:

  • 您将获得一组新的 DTO 类来维护。
  • 您必须在 DTO 和聚合之间来回移动数据。如果 DTO 和实体在结构上几乎相似,这可能会特别乏味。如果您在一个团队中工作,您需要准备好解释为什么需要分离 DTO 和聚合。

就个人而言,这是我在大多数情况下开始的方法。有时我最终会将我的 DTO 转换为 DPO,这是我们要研究的下一个替代方案。

域负载对象

在第三种选择中,应用程序服务使用并返回域有效负载对象。域有效负载对象是了解域模型并且可以包含域对象的数据传输对象。这本质上是前两种选择的组合。

这种替代方案在域模型贫乏、聚合小而稳定并且您希望实现涉及多个不同聚合的操作的情况下效果最佳。就个人而言,我会说我更频繁地将 DPO 用作输出对象而不是输入对象。但是,如果可能,我会尝试将 DPO 中域对象的使用限制为值对象。

这种替代方案的优点是:

  • 您不需要为所有内容创建 DTO 类。当将域对象直接传递给客户端已经足够好时,您可以这样做。当您需要自定义 DTO 时,您可以创建一个。当你需要两者时,你同时使用两者。

缺点是:

  • 与第一种选择相同。可以通过仅在 DPO 中包含不可变值对象来减轻这些缺点。

代码示例

下面是分别使用 DTO 和 DPO 的两个 Java 示例。DTO 示例演示了一个用例,其中使用 DTO 比直接返回实体更有意义:只需要一小部分实体属性,我们需要包含实体中不存在的信息。DPO 示例演示了一个使用 DPO 有意义的用例:我们需要包含许多以某种方式相互关联的不同聚合。

该代码尚未经过测试,应该更多地被视为伪代码而不是实际的 Java 代码。

首先,我们看一下数据传输对象示例:

public class CustomerListEntryDTO { // <1>
    private CustomerId id;
    private String name;
    private LocalDate lastInvoiceDate;

    // Getters and setters omitted
}

@Service
public class CustomerListingService {

    @Transactional 
    public List<CustomerListEntryDTO> getCustomerList() {
        var customers = customerRepository.findAll(); // <2>
        var dtos = new ArrayList<CustomerListEntryDTO>();
        for (var customer : customers) {
            var lastInvoiceDate = invoiceService.findLastInvoiceDate(customer.getId()); // <3>
            dto = new CustomerListEntryDTO(); // <4>
            dto.setId(customer.getId());
            dto.setName(customer.getName());
            dto.setLastInvoiceDate(lastInvoiceDate);
            dtos.add(dto);
        }
        return dto;
    }
}
  1. 数据传输对象只是一个没有任何业务逻辑的数据结构。此特定 DTO 旨在用于仅需要显示客户名称和最后发票日期的用户界面列表视图。
  2. 我们从数据库中查找所有客户聚合。在现实世界的应用程序中,这将是一个仅返回客户子集的分页查询。
  3. 最后发票日期未存储在客户实体中,因此我们必须调用域服务来为我们查找。
  4. 我们创建 DTO 实例并用数据填充它。

其次,我们看一下 Domain Payload Object 的例子:

public class CustomerInvoiceMonthlySummaryDPO { // <1>
    private Customer customer;
    private YearMonth month;
    private Collection<Invoice> invoices;

    // Getters and setters omitted
}

@Service
public class CustomerInvoiceSummaryService {

    public CustomerInvoiceMontlySummaryDPO getMonthlySummary(CustomerId customerId, YearMonth month) {
        var customer = customerRepository.findById(customerId); // <2>
        var invoices = invoiceRepository.findByYearMonth(customerId, month); // <3>
        var dpo = new CustomerInvoiceMonthlySummaryDPO(); // <4>
        dpo.setCustomer(customer);
        dpo.setMonth(month);
        dpo.setInvoices(invoices);
        return dpo;
    }
}
  1. 域有效负载对象是一种没有任何业务逻辑的数据结构,它包含域对象(在本例中为实体)和附加信息(在本例中为年和月)。
  2. 我们从存储库中获取客户的聚合根。
  3. 我们获取客户指定年份和月份的发票。
  4. 我们创建 DPO 实例并用数据填充它。

输入验证

正如我们之前提到的,聚合必须始终处于一致状态。这意味着我们需要正确验证用于更改聚合状态的所有输入。我们如何以及在哪里做到这一点?

从用户体验的角度来看,用户界面应该包括验证,这样如果数据无效,用户甚至无法执行操作。然而,仅仅依靠用户界面验证在六边形系统中是*不够的。*这样做的原因是用户界面只是系统的许多潜在入口点之一。如果 REST 端点允许任何垃圾进入域模型,则用户界面正确验证数据无济于事。

在考虑输入验证时,实际上有两种不同的验证:格式验证和内容验证。当我们验证格式时,我们检查某些类型的某些值是否符合某些规则。例如,社会安全号码预计将采用特定模式。当我们验证内容时,我们已经有了一个格式良好的数据,并且有兴趣检查该数据是否有意义。例如,我们可能想要检查一个格式正确的社会安全号码实际上是否对应于一个真实的人。您可以以不同的方式实现这些验证,让我们仔细看看。

格式验证

如果您在域模型中使用大量值对象(我个人倾向于这样做),它们是原始类型(例如字符串或整数)的包装器,那么将格式验证直接构建到值对象构造函数中是有意义的. 换句话说,如果不传入格式良好的参数,就不可能创建例如一个EmailAddress或实例。SocialSecurityNumber这有一个额外的好处,如果有多种输入有效数据的已知方法,您可以在构造函数内部进行一些解析和清理(例如,当输入电话号码时,有些人可能使用空格或破折号将号码分成组,而其他人可能根本不使用任何空格)。

现在,当值对象有效时,我们如何验证使用它们的实体?Java 开发人员有两种选择。

第一个选项是将验证添加到您的构造函数、工厂和 setter 方法中。这里的想法是,甚至不可能将聚合置于不一致状态:必须在构造函数中填充所有必填字段,任何必填字段的设置器都不会接受空参数,其他设置器不会接受不正确的值格式或长度等。当我使用业务逻辑非常丰富的领域模型时,我个人倾向于使用这种方法。它使域模型非常健壮,但实际上也迫使您在客户端和应用程序服务之间使用 DTO,因为它或多或少不可能正确绑定到 UI。

第二个选项是使用 Java Bean Validation (JSR-303)。在所有字段上添加注释,并确保您的应用程序服务在对其Validator进行任何其他操作之前运行聚合。当我使用贫血的域模型时,我个人倾向于使用这种方法。即使聚合本身并不能阻止任何人将其置于不一致的状态,您也可以放心地假设所有从存储库中检索到或已通过验证的聚合都是一致的。

您还可以通过使用域模型中的第一个选项和传入 DTO 或 DPO 的 Java Bean 验证来组合这两个选项。

内容验证

内容验证的最简单情况是确保同一聚合中的两个或多个相互依赖的属性有效(例如,如果设置了一个属性,则另一个必须为空,反之亦然)。您可以直接在实体类本身中实现这一点,也可以使用类级别的 Java Bean Validation 约束。这种类型的内容验证在执行格式验证时将免费提供,因为它使用相同的机制。

内容验证的一个更复杂的情况是检查某个值是否存在(或不存在)在某处的查找列表中。这在很大程度上是应用程序服务的责任。在允许任何业务或持久性操作继续之前,应用程序服务应该执行查找并在需要时抛出异常。这不是您想要放入实体的内容,因为实体是可移动的域对象,而查找所需的对象通常是静态的(有关可移动和静态对象的更多信息,请参阅上一篇关于战术 DDD 的文章)。

内容验证最复杂的情况是根据一组业务规则验证整个聚合。在这种情况下,职责在域模型和应用程序服务之间进行划分。域服务将负责执行验证本身,但应用程序服务将负责调用域服务。

代码示例

在这里,我们将研究处理验证的三种不同方式。在第一种情况下,我们将研究在值对象(电话号码)的构造函数中执行格式验证。在第二种情况下,我们将查看一个内置验证的实体,这样一开始就不可能将对象置于不一致的状态。在第三种也是最后一种情况下,我们将查看相同的实体,但使用 JSR-303 验证实现。这使得可以将对象置于不一致的状态,但不能将其保存到数据库中。

具有格式验证的值对象可能如下所示:

public class PhoneNumber implements ValueObject {
    private final String phoneNumber;

    public PhoneNumber(String phoneNumber) {
        Objects.requireNonNull(phoneNumber, "phoneNumber must not be null"); // <1>
        var sb = new StringBuilder();
        char ch;
        for (int i = 0; i < phoneNumber.length(); ++i) {
            ch = phoneNumber.charAt(i);
            if (Character.isDigit(ch)) { // <2>
                sb.append(ch);
            } else if (!Character.isWhitespace(ch) && ch != '(' && ch != ')' && ch != '-' && ch != '.') { // <3>
                throw new IllegalArgument(phoneNumber + " is not valid");
            }
        }
        if (sb.length() == 0) { // <4>
            throw new IllegalArgumentException("phoneNumber must not be empty");
        }
        this.phoneNumber = sb.toString();
    }

    @Override
    public String toString() {
        return phoneNumber;
    }

    // Equals and hashCode omitted
}
  1. 首先,我们检查输入值是否不为空。
  2. 我们在实际存储的最终电话号码中仅包含数字。对于国际电话号码,我们也应该支持将“+”号作为第一个字符,但我们将把它作为练习留给读者。
  3. 我们允许但忽略人们经常在电话号码中使用的空格和某些特殊字符。
  4. 最后,当所有的清理完成后,我们检查电话号码是否为空。

具有内置验证的实体可能如下所示:

public class Customer implements Entity {

    // Fields omitted

    public Customer(CustomerNo customerNo, String name, PostalAddress address) {
        setCustomerNo(customerNo); // <1>
        setName(name);
        setPostalAddress(address);
    }

    public setCustomerNo(CustomerNo customerNo) {
        this.customerNo = Objects.requireNonNull(customerNo, "customerNo must not be null");
    }

    public setName(String name) {
        Objects.requireNonNull(nanme, "name must not be null");
        if (name.length() < 1 || name.length > 50) { // <2>
            throw new IllegalArgumentException("Name must be between 1 and 50 characters");
        }
        this.name = name;
    }

    public setAddress(PostalAddress address) {
        this.address = Objects.requireNonNull(address, "address must not be null");
    }
}
  1. 我们从构造函数调用 setter 以执行在 setter 方法中实现的验证。如果子类决定覆盖其中任何一个,则从构造函数中调用可覆盖方法的风险很小。在这种情况下,最好将 setter 方法标记为 final,但某些持久性框架可能会遇到问题。你只需要知道你在做什么。
  2. 这里我们检查一个字符串的长度。下限是业务要求,因为每个客户都必须有姓名。上层是数据库要求,因为在这种情况下,数据库有一个模式,它只允许它存储 50 个字符的字符串。通过在此处添加验证,您可以避免在稍后尝试将过长的字符串插入数据库时出现烦人的 SQL 错误。

具有 JSR-303 验证的实体可能如下所示:

public class Customer implements Entity {

    @NotNull <1>
    private CustomerNo customerNo;

    @NotBlank <2>
    @Size(max = 50) <3>
    private String name;

    @NotNull
    private PostalAddress address;

    // Setters omitted
}
  1. 此注释确保保存实体时客户编号不能为空。
  2. 此注解确保实体保存时名称不能为空或 null。
  3. 此注释可确保保存实体时名称不能超过 50 个字符。

大小重要吗?

在我们继续讨论端口和适配器之前,我还想简要提及一件事。与所有门面一样,应用程序服务一直存在发展成为知道太多、做得太多的巨大神类的风险。这些类型的类通常很难阅读和维护,因为它们太大了。

那么如何保持应用程序服务小呢?第一步当然是将增长过大的服务拆分为更小的服务。但是,这也存在风险。我见过的情况是两个服务非常相似,以至于开发人员不知道它们之间有什么区别,也不知道哪个方法应该进入哪个服务。结果是服务方法分散在两个独立的服务类中,有时甚至实现了两次——每个服务一次——但由不同的开发人员执行。

当我设计应用程序服务时,我尽量让它们连贯一致。在 CRUD 应用程序中,这可能意味着每个聚合一个应用程序服务。在更多由领域驱动的应用程序中,这可能意味着每个业务流程一个应用程序服务,甚至是针对特定用例或用户界面视图的单独服务。

在设计应用程序服务时,命名是一个非常好的指南。尝试根据它们所做的而不是它们关注的聚合来命名您的应用程序服务。例如EmployeeCrudServiceorEmploymentContractTerminationUsecase是比EmployeeServicewhich 可能意味着任何更好的名字。还要花一些时间考虑你的命名约定:你真的需要用Service后缀结束你的所有服务吗?在某些情况下使用后缀是否更有意义,例如Usecase甚至Orchestrator完全不使用后缀?

最后,我只想提一下基于命令的应用程序服务。在这种情况下,您将每个应用程序服务模型建模为具有相应命令处理程序的命令对象。这意味着每个应用程序服务都只包含一个方法来处理一个命令。您可以使用多态性来创建专门的命令或命令处理程序。这种方法会产生大量的小类,尤其适用于用户界面本质上是命令驱动的应用程序,或者客户端通过某种消息传递机制(如消息队列 (MQ) 或企业服务总线)与应用程序服务交互的应用程序( ESB)。

代码示例

我不会给你举一个神级的例子,因为那样会占用太多的空间。此外,我认为大多数从事该行业一段时间的开发人员都看到了此类课程的公平份额。相反,我们将看一个基于命令的应用程序服务可能是什么样子的示例。该代码尚未经过测试,应该更多地被视为伪代码而不是实际的 Java 代码。

public interface Command<R> { // <1>
}

public interface CommandHandler<C extends Command<R>, R> { // <2>

    R handleCommand(C command);
}

public class CommandGateway { // <3>

    // Fields omitted

    public <C extends Command<R>, R> R handleCommand(C command) {
        var handler = commandHandlers.findHandlerFor(command)
            .orElseThrow(() -> new IllegalStateException("No command handler found"));
        return handler.handleCommand(command);
    }
}

public class CreateCustomerCommand implements Command<Customer> { // <4>
    private final String name;
    private final PostalAddress address;
    private final PhoneNumber phone;
    private final EmailAddress email;

    // Constructor and getters omitted
}

public class CreateCustomerCommandHandler implements CommandHandler<CreateCustomerCommand, Customer> { // <5>

    @Override
    @Transactional
    public Customer handleCommand(CreateCustomerCommand command) {
        var customer = new Customer();
        customer.setName(command.getName());
        customer.setAddress(command.getAddress());
        customer.setPhone(command.getPhone());
        customer.setEmail(command.getEmail());
        return customerRepository.save(customer);
    }
}
  1. Command接口只是一个标记接口,它也指示命令的结果(输出)。如果命令没有输出,结果可以是Void.
  2. CommandHandler接口由一个知道如何处理(执行)特定命令并返回结果的类实现。
  3. 客户端与 a 交互CommandGateway以避免必须查找单个命令处理程序。网关知道所有可用的命令处理程序以及如何根据任何给定的命令找到正确的处理程序。示例中不包含用于查找处理程序的代码,因为它取决于注册处理程序的底层机制。
  4. 每个命令都实现Command接口并包含执行命令所需的所有信息。我喜欢通过内置验证使我的命令不可变,但您也可以使它们可变并使用 JSR-303 验证。您甚至可以将命令保留为接口,让客户端自己实现它们以获得最大的灵活性。
  5. 每个命令都有自己的处理程序来执行命令并返回结果。

六边形与实体控制边界

如果您之前听说过实体-控制-边界模式,您会发现六边形架构很熟悉。您可以将聚合视为实体,将域服务、工厂和存储库视为控制器,将应用程序服务视为边界

端口和适配器

到目前为止,我们已经讨论了域模型以及围绕它并与之交互的应用程序服务。但是,如果客户端无法调用这些应用程序服务,那么这些应用程序服务就完全没用了,这就是端口和适配器进入画面的地方。

什么是端口?

端口是系统与外部世界之间的接口,专为特定目的或协议而设计。端口不仅用于允许外部客户端访问系统,还用于允许系统访问外部系统。

现在,很容易开始将端口视为网络端口,将协议视为网络协议,例如 HTTP。我自己也犯了这个错误,事实上弗农在他的书中至少有一个例子也犯了这个错误。但是,如果您仔细查看弗农所指的Alistair Cockburn的文章,您会发现情况并非如此。事实上,它比这更有趣。

端口是一种与技术无关的应用程序编程接口 (API),设计用于与应用程序进行特定类型的交互(因此称为“协议”一词)。如何定义此协议完全取决于您,这就是使这种方法令人兴奋的原因。以下是您可能拥有的不同端口的几个示例:

  • 应用程序用来访问数据库的端口
  • 应用程序用来发送消息(例如电子邮件或文本消息)的端口
  • 人类用户用来访问您的应用程序的端口
  • 其他系统用来访问您的应用程序的端口
  • 特定用户组用于访问您的应用程序的端口
  • 暴露特定用例的端口
  • 为轮询客户端设计的端口
  • 为订阅客户端设计的端口
  • 为同步通信而设计的端口
  • 为异步通信设计的端口
  • 为特定类型的设备设计的端口

此列表绝不是详尽无遗的,我相信您可以自己提出更多示例。您也可以组合这些类型。例如,您可以有一个端口,允许管理员使用使用异步通信的客户端来管理用户。您可以根据需要向系统添加任意数量的端口,而不会影响其他端口或域模型。

我们再来看看六边形架构图:

六边形结构

内六边形的每一边代表一个端口。这就是为什么这种架构经常被描绘成这样的原因:您有六个开箱即用的面,您可以将它们用于不同的端口,并且有足够的空间可以根据需要插入尽可能多的适配器。但什么是适配器?

什么是适配器?

我已经提到端口与技术无关。尽管如此,您还是通过一些技术与系统交互——网络浏览器、移动设备、专用硬件设备、桌面客户端等等。这就是适配器的用武之地。

适配器允许使用特定技术通过特定端口进行交互。例如:

  • REST 适配器允许 REST 客户端通过某个端口与系统交互
  • RabbitMQ 适配器允许 RabbitMQ 客户端通过某些端口与系统交互
  • SQL 适配器允许系统通过某个端口与数据库交互
  • Vaadin 适配器允许人类用户通过某些端口与系统交互

您可以为单个端口使用多个适配器,甚至可以为多个端口使用单个适配器。您可以根据需要向系统添加任意数量的适配器,而不会影响其他适配器、端口或域模型。

代码中的端口和适配器

到现在为止,您应该对什么是端口以及什么是适配器在概念上有所了解。但是如何将这些概念转化为代码呢?我们来看一下!

在大多数情况下,端口将在您的代码中实现为接口。对于允许外部系统访问您的应用程序的端口,这些接口是您的应用程序服务接口:

使用端口接口的适配器

接口的实现驻留在应用程序服务层内,适配器仅通过其接口使用服务。这非常符合经典的分层架构,其中适配器只是另一个使用您的应用程序层的客户端。主要区别在于端口的概念可以帮助您设计更好的应用程序接口,因为您实际上必须考虑接口的客户端是什么,并承认不同的客户端可能需要不同的接口,而不是一刀切-所有方法。

当我们查看一个允许您的应用程序通过某个适配器访问外部系统的端口时,事情会变得更加有趣:

实现端口接口的适配器

在这种情况下,实现接口的是适配器。然后应用服务通过这个接口与适配器进行交互。接口本身要么存在于您的应用程序服务层(例如工厂接口)中,要么存在于您的域模型(例如存储库接口)中。这种方法在传统的分层架构中是不允许的,因为接口将在上层(“应用层”或“域层”)中声明,但在下层(“基础设施层”)中实现。

请注意,在这两种方法中,依赖箭头都指向接口。应用程序始终与适配器保持分离,并且适配器始终与应用程序的实现保持分离。

为了使这一点更加具体,让我们看一些代码示例。

示例 1:REST API

在第一个示例中,我们将为我们的 Java 应用程序创建一个 REST API:

一个 REST 适配器

端口是一些适合通过 REST 暴露的应用服务。REST 控制器充当适配器。自然地,我们使用 Spring 或 JAX-RS 等框架,它们提供 servlet 和 POJO(普通旧 Java 对象)和 XML/JSON 开箱即用之间的映射。我们只需要实现 REST 控制器,它将:

  1. 将原始 XML/JSON 或反序列化 POJO 作为输入,
  2. 调用应用服务,
  3. 将响应构造为原始 XML/JSON 或将由框架序列化的 POJO,以及
  4. 将响应返回给客户端。

客户端,无论它们是在浏览器中运行的客户端 Web 应用程序还是在它们自己的服务器上运行的其他系统,都不是这个特定六边形系统的一部分。系统也不必关心客户端是谁,只要它们符合端口和适配器支持的协议和技术。

示例 2:服务器端 Vaadin UI

在第二个示例中,我们将研究一种不同类型的适配器,即服务器端 Vaadin UI:

Vaadin 适配器和 HTTP 端口

端口是一些适合通过 Web UI 公开的应用程序服务。适配器是 Vaadin UI,它将传入的用户操作转换为应用程序服务方法调用,并将输出转换为可以在浏览器中呈现的 HTML。将用户界面视为另一个适配器是将业务逻辑保留在用户界面之外的绝佳方式。

示例 3:与关系数据库通信

在第三个示例中,我们将扭转局面,看看一个适配器,它允许我们的系统调用外部系统,更具体地说是关系数据库:

存储库适配器和 JDBC 端口

这一次,因为我们使用 Spring Data,端口是来自域模型的存储库接口(如果我们不使用 Spring Data,端口可能是某种数据库网关接口,提供对存储库实现、事务管理的访问等等)。

适配器是 Spring Data JPA,所以我们实际上不需要自己编写,只需正确设置即可。当应用程序启动时,它将使用代理自动实现接口。Spring 容器将负责将代理注入到使用它的应用程序服务中。

示例 4:通过 REST 与外部系统通信

在第四个也是最后一个示例中,我们将查看一个适配器,它允许我们的系统通过 REST 调用外部系统:

一个 REST 客户端适配器和 HTTP 端口

由于应用程序服务需要与外部系统联系,因此它声明了一个要用于此目的的接口。您可以将其视为反腐败层的第一部分(如果您需要复习一下战略 DDD 的内容,请返回并阅读有关战略 DDD 的文章)。

然后适配器实现这个接口,形成反腐败层的第二部分。与前面的示例一样,适配器使用某种依赖注入(例如 Spring)注入到应用程序服务中。然后,它使用一些内部 HTTP 客户端调用外部系统,并将接收到的响应转换为集成接口所指示的域对象。

多个有界上下文

到目前为止,我们只看到了六边形架构在应用于单个有界上下文时的样子。但是当您有多个需要相互通信的有界上下文时会发生什么?

如果上下文在不同的系统上运行并通过网络进行通信,您可以执行以下操作:为上游系统创建一个 REST 服务器适配器,为下游系统创建一个 REST 客户端适配器:

在不同节点上运行的两个有界上下文

不同上下文之间的映射将发生在下游系统的适配器中。

如果上下文在单个单体系统中作为模块运行,您仍然可以使用类似的架构,但您只需要一个适配器:

同一单体内部的两个有界上下文

由于两个上下文都在同一个虚拟机中运行,我们只需要一个直接与两个上下文交互的适配器。适配器实现下游上下文的端口接口,调用上游上下文的端口。任何上下文映射都发生在适配器内部。