DDD:领域驱动设计和六边形体系结构

1,982 阅读38分钟

领域驱动设计和六边形体系结构

翻译自:vaadin.com/learn/tutor…

在前两篇文章中,我们了解了战略和战术领域驱动的设计。现在是时候学习如何将域模型转换为可用的软件了-更具体地说,是如何使用六边形体系结构来做到这一点。

尽管代码示例是用Java编写的,但前两篇文章还是相当通用的。尽管本文中的许多理论也可以在其他环境和语言中应用,但是我已经明确地将Java和Vaadin编写为它。

同样,内容基于埃里克·埃文斯(Eric Evans)的《域驱动设计:解决软件核心中的复杂性》和沃恩·弗农(Vaughn Vernon)的《实现域驱动设计》一书,我强烈建议您阅读它们。但是,即使我在前几篇文章中也表达了自己的想法,思想和经验,但我的想法和信念却使本篇文章更加鲜明。就是说,首先是Evans和Vernon的书使我开始使用DDD,我想认为我在这里写的内容与书中的内容相距不远。

这是本文的第二版。在第一个中,我弄错了端口的概念。读者在评论中指出了这一点,我非常感谢。我现在已经纠正了该错误,并相应地更新了示例和图表。总是欢迎对我对这种建筑风格和DDD的解释发表评论

为什么称其为六边形?

该名六边形结构来源于此架构通常被描述方式:

我们将回到本文后面为何使用六边形的原因。这种结构还以端口和适配器(更好地解释其背后的中心思想)和洋葱体系结构(由于其分层方式)为名。

在下文中,我们将仔细研究“洋葱”(Onion)。我们将从核心模型(领域模型)开始,然后一次一层地进行工作,直到到达适配器以及与它们交互的系统和客户端为止。

六边形与传统层 一旦我们深入研究六边形体系结构,您会发现它与更传统的分层体系结构有一些相似之处。确实,您可以将六边形架构视为分层架构的演变。但是,特别是在系统与外界交互方面存在一些差异。为了更好地理解这些差异,让我们从分层架构的概述开始:

原理是该系统由彼此堆叠的层组成。较高的层可以与较低的层交互,但不能反过来。通常,在领域驱动的分层体系结构中,UI层应位于顶部。该层又与应用程序服务层交互,该应用程序服务层与驻留在领域层中的领域模型进行交互。在底部,我们有一个基础架构层,可与外部系统(例如数据库)进行通信。

在六边形系统中,您会发现应用程序层和域层仍然几乎相同。但是,以完全不同的方式对待UI层和基础结构层。继续阅读以了解操作方法。

领域模型

六边形体系结构的核心是领域模型,该领域模型是使用我们在上一篇文章中介绍的战术DDD构建块实现的。这就是所谓的业务逻辑所在的地方,所有业务决策都在此制定。这也是软件中最稳定的部分,希望它的更改最少(除非业务本身发生变化)。

领域模型一直是本系列前两篇文章的主题,因此我们在这里不再赘述。但是,如果无法与领域模型进行交互,则仅领域模型无法提供任何价值。为此,我们必须上移到“洋葱”的下一层。

应用服务

应用程序服务充当客户端与域模型进行交互的基础。应用程序服务具有以下特征:

  • 他们是无状态的

  • 他们加强系统安全性

  • 他们控制数据库事务

  • 他们编排业务运营,但不做出任何业务决策(即,它们不包含任何业务逻辑)

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

六边形与实体控制边界

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

无状态

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

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

业务流程作为输入参数

public class MyBusinessProcess {
    // Current process state
}

public interface MyApplicationService {

    MyBusinessProcess performSomeStuff(MyBusinessProcess input);

    MyBusinessProcess performSomeMoreStuff(MyBusinessProcess input);
}

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

有关如何实施更复杂且运行时间更长的业务流程的提示,我建议您阅读弗农的书

安全执行

应用程序服务确保允许当前用户执行所讨论的操作。从技术上讲,您可以在每种应用程序服务方法的顶部手动执行此操作,也可以使用诸如AOP之类的更复杂的方法。只要在应用程序服务层而不在域模型内发生安全性,如何强制实施安全性都没有关系。现在,为什么这很重要?

当我们谈论应用程序中的安全性时,我们倾向于将重点放在防止未经授权的访问上,而不是允许授权的访问上。因此,我们添加到系统中的任何安全检查实际上都会使它变得更难使用。如果将这些安全检查添加到域模型中,我们可能会陷入无法执行重要操作的情况,因为在添加安全检查时我们并未想到它,而现在它们却挡在了后面。通过将所有安全检查都排除在域模型之外,我们可以得到一个更加灵活的系统,因为我们可以按照任何需要的方式与域模型进行交互。由于仍然要求所有客户端都必须通过应用程序服务,因此该系统仍然是安全的。与更改域模型相比,创建新的应用程序服务要容易得多。

程式码范例

这是两个Java示例,说明应用程序服务中的安全性强制可能是什么样的。该代码尚未经过测试,应被视为比实际Java代码更多的伪代码。

声明式安全实施

@Service
class MyApplicationService {

    @Secured("ROLE_BUSINESS_PROCESSOR") // 
    public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
        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. 注释指示框架仅允许具有角色的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()) { // 
            throw new AuthenticationException("No user logged on");
        }
        if (!SecurityContext.holdsRole("ROLE_BUSINESS_PROCESSOR")) { // 
            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 // 
    public void resetPassword(UserId userId) {
        var user = userRepository.findByUserId(userId); // 
        user.resetPassword(); // 
        userRepository.save(user);
    }
}
  1. 该框架将确保整个方法在单个事务中运行。如果引发异常,则事务将回滚。否则,在方法返回时将提交它。
  2. 应用程序服务在域模型中调用存储库以查找User聚合根。
  3. 应用程序服务在User聚合根上调用业务方法。

手动事务管理

@Service
class UserAdministrationService {

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

编排

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

通过编排,我的意思是查找并以正确的顺序调用正确的领域对象,传递正确的输入参数并返回正确的输出。以最简单的形式,应用程序服务可以基于ID查找聚合,在该聚合上调用方法,保存并返回。但是,在更复杂的情况下,该方法可能必须查找多个聚合,与领域服务进行交互,执行输入验证等。如果发现自己编写了冗长的应用程序服务方法,则应问自己以下问题:

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

话虽这么说,让一些业务逻辑最终出现在应用程序服务方法中并不是世界末日。它仍然非常接近领域模型,并且封装得很好,以后应该很容易重构为领域模型。不要浪费太多时间来思考是否应该立即将某些内容放入领域模型或应用程序服务中。

程式码范例

这是Java的示例,展示了典型业务流程的外观。该代码尚未经过测试,应被视为比实际Java代码更多的伪代码。

涉及多个领域对象的业务流程

@Service
class CustomerRegistrationService {

    @Transactional // 
    @PermitAll // 
    public Customer registerNewCustomer(CustomerRegistrationRequest request) {
        var violations = validator.validate(request); // 
        if (violations.size() > 0) {
            throw new InvalidCustomerRegistrationRequest(violations);
        }
        customerDuplicateLocator.checkForDuplicates(request); // 
        var customer = customerFactory.createNewCustomer(request); // 
        return customerRepository.save(customer); // 
    }
}
  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 { // 
    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(); // 
        var dtos = new ArrayList<CustomerListEntryDTO>();
        for (var customer : customers) {
            var lastInvoiceDate = invoiceService.findLastInvoiceDate(customer.getId()); // 
            dto = new CustomerListEntryDTO(); // 
            dto.setId(customer.getId());
            dto.setName(customer.getName());
            dto.setLastInvoiceDate(lastInvoiceDate);
            dtos.add(dto);
        }
        return dto;
    }
}
  1. 数据传输对象只是一个没有任何业务逻辑的数据结构。该特定的DTO旨在用于用户界面列表视图,该视图仅需要显示客户名称和最后发票日期。
  2. 我们从数据库中查找所有客户集合。在实际的应用程序中,这将是一个分页查询,仅返回一部分客户。
  3. 最后发票日期未存储在客户实体中,因此我们必须调用域服务来为我们查找。
  4. 我们创建DTO实例并用数据填充它。

领域有效负载对象示例

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

输入验证

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

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

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

格式验证

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

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

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

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

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

内容验证

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

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

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

程式码范例

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

具有格式验证的值对象

public class PhoneNumber implements ValueObject {
    private final String phoneNumber;

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

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

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

具有内置验证的实体

public class Customer implements Entity {

    // Fields omitted

    public Customer(CustomerNo customerNo, String name, PostalAddress address) {
        setCustomerNo(customerNo); // 
        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) { // 
            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方法标记为final,但是某些持久性框架可能对此有问题。您只需要知道自己在做什么。

  2. 在这里,我们检查字符串的长度。下限是一项业务要求,因为每个客户都必须有一个名称。上级是数据库的要求,因为在这种情况下,数据库具有仅允许存储50个字符的字符串的架构。通过在此处添加验证,可以避免在稍后尝试将太长的字符串插入数据库时​​烦人的SQL错误。

具有JSR-303验证的实体

public class Customer implements Entity {

    @NotNull 
    private CustomerNo customerNo;

    @NotBlank 
    @Size(max = 50) 
    private String name;

    @NotNull
    private PostalAddress address;

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

大小重要吗?

在继续介绍端口和适配器之前,我还要简要介绍一件事。与所有外墙一样,应用程序服务也存在不断增长的风险,这些应用程序服务会成长为知道太多和做太多事情的巨大上帝类别。这些类型的类经常因为它们太大而难以阅读和维护。

那么,如何使应用程序服务保持较小规模?第一步当然是将太大的服务拆分为较小的服务。但是,这也存在风险。我已经看到两种服务的相似之处,以至于开发人员都不知道它们之间有什么区别,也不知道哪种方法应该用于哪种服务。结果是服务方法分散在两个单独的服务类上,有时甚至实现两次(每个服务一次),但由不同的开发人员执行。

在设计应用程序服务时,我尝试使它们尽可能一致。在CRUD应用程序中,这可能意味着每个聚合仅提供一项应用程序服务。在更多领域驱动的应用程序中,这可能意味着每个业务流程一项应用程序服务,或者甚至是针对特定用例或用户界面视图的单独服务。

在设计应用程序服务时,命名是一个很好的指南。尝试根据应用程序服务的名称(而不是它们关注的聚合)来命名您的应用程序服务。例如,EmployeeCrudService 或EmploymentContractTerminationUsecase比EmployeeService其含义更好的名称好得多。还花一些时间考虑一下您的命名约定:您真的需要用Service后缀结尾所有服务吗?将使其在某些情况下,更有意义的使用后缀,例如Usecase或者Orchestrator甚至完全离开后缀?

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

程式码范例

我不会给您一个有关神职人员的模样的例子,因为这会占用太多空间。此外,我认为大多数从事该行业一段时间的开发人员都看到了他们在此类课程中应有的份额。相反,我们将看一个基于命令的应用程序服务的示例。该代码尚未经过测试,应被视为比实际Java代码更多的伪代码。

基于命令的应用程序服务

public interface Command<R> { // 
}

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

    R handleCommand(C command);
}

public class CommandGateway { // 

    // 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> { // 
    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> { // 

    @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. 客户端与进行交互,CommandGateway以避免必须查找各个命令处理程序。网关知道所有可用的命令处理程序以及如何根据任何给定命令找到正确的命令处理程序。查找处理程序的代码未包含在示例中,因为它取决于注册处理程序的基础机制。
  4. 每个命令都实现该Command接口,并包括执行命令所需的所有必要信息。我喜欢使用内置验证使命令不可变,但是您也可以使它们可变并使用JSR-303验证。您甚至可以将命令保留为接口,并让客户端自己实现它们,以实现最大的灵活性。
  5. 每个命令都有其自己的处理程序,该处理程序执行该命令并返回结果。

端口和适配器

到目前为止,我们已经讨论了领域模型和围绕它并与之交互的应用程序服务。但是,如果客户端无法调用它们,那就是端口和适配器位于图片的地方,这些应用程序服务将完全无用。

什么是端口?

端口是系统与外界之间的接口,已针对特定目的或协议进行了设计。端口不仅用于允许外部客户端访问系统,而且还用于允许系统访问外部系统。

现在,开始很容易将端口视为网络端口,将协议视为网络协议(例如HTTP)。我本人就犯了这个错误,实际上,弗农在他的书中至少有一个例子也做到了这一点。但是,如果您仔细看一下弗农所指的Alistair Cockburn的文章,您会发现事实并非如此。实际上,它比这有趣得多。

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

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

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

让我们再次看一下六边形结构图

内六边形的每一侧代表一个端口。这就是为什么这种体系结构经常这样表示的原因:您可以立即使用六个侧面,可用于不同的端口,并且有足够的空间来插入所需的尽可能多的适配器。但是什么是适配器?

什么是适配器?

我已经提到过,端口与技术无关。尽管如此,您仍可以通过某些技术与系统进行交互-Web浏览器,移动设备,专用硬件设备,台式机客户端等。这是适配器进入的地方。

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

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

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

代码中的端口和适配器

到目前为止,您应该对概念上的端口和适配器有一些了解。但是,如何将这些概念转换为代码?我们来看一下!

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

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

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

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

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

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

示例1:REST API

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

端口是一些适合通过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:

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

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

在第三个示例中,我们将转过头来研究一个适配器,该适配器允许我们的系统调出到外部系统,更具体地说是关系数据库:

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

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

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

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

由于应用程序服务需要与外部系统联系,因此它已声明要用于此接口。您可以将其视为反腐层的第一部分(如果需要进一步了解DDD,请回过头阅读有关战略性DDD的文章)。

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

多个有界上下文

到目前为止,我们仅研究了六边形体系结构应用于单个有界上下文时的外观。但是,当您有多个需要相互通信的有限上下文时,会发生什么呢?

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

不同上下文之间的映射将在下游系统的适配器中进行。 如果上下文在单个整体系统中作为模块运行,则仍可以使用类似的体系结构,但只需要一个适配器:

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