如何集成多个限界上下文?

在进行领域驱动设计时,发现有两个情境很容易产生误解?他们是:多个限界上下文集成聚合设计。在本文中,主要介绍多个限界上下文集成。

通用语言

任何研究过 DDD 的人都已经了解到与其一起使用的许多概念:限界上下文、实体、聚合、值对象等。其中一些概念(实体,聚合,值对象)与代码直接关联,很容易理解和使用,而另一些(限界上下文)则由于其抽象性而难以吸收应用。通用语言就是这样一种情况,尽管它简单而强大,但很容易被忽视或遗忘。

通用语言的目标是建立一种明确的交流方式或语言,用于专家和开发人员之间的交流。这意味着无论是否以代码表示的所有概念,例如类和进程,都不应随意命名或从以开发人员为中心的角度来看,而是集中讨论您的软件解决方案如何尝试解决给定的问题。

也许一个典型的例子是我们倾向于将使用系统的个人称为用户,而领域专家将其称为客户。或者倾向于将我们操纵的概念称为 SomethingData 或 SomethingInformation。

这种看似无害的差异是可能导致混乱或成为“破电话游戏”的一个例子,在这种情况下,您的意图从业务预期到实际实施的时候,意图就会丢失。

达成正确的通用语言上是很强大的,因为它可以帮助您将隐含的定义明确化,这总是一件好事。想象一下,您发现它确实是一个目的地地址,而不是调用某些运输信息。

现在让我们谈谈作用域。在编程语言中,作用域很容易理解为某个项目(例如变量)的程序作用域。在该作用域之外,无法保证存在相同的概念,甚至无法保证具有相同的价值/意义。当我们将此讨论带到我们的非编程语言时,我们承认我们精心设计的语言存在局限性。这意味着可以甚至鼓励不追求在其所有应用程序中使用的概念的通用企业作用域定义。要定义此类限制并探索为什么需要这样做,有必要讨论限界上下文。

让我们设定一些边界

在讨论限界上下文 (BC) 的概念时,Eric Evans 表示我们应该“明确定义模型应用的上下文。 在团队组织、应用程序特定部分内的使用以及代码库和数据库模式等物理表现方面明确设置界限。 在这些范围内保持模型严格一致,但不要被外部问题分散注意力或混淆”。

该声明揭示了一个事实,即我们的模型以及最终定义它们的语言的有效性是有限的。它还强调,有些事情“超出”我们不应该“分心”的界限。虽然讨论如何定义这些边界超出了本文的范围,但我想重点关注限制的存在,如图 1 所示。

Figure 1. Different Bounded Contexts and their relationships.

在此图中,我们注意到以下特征:

1、限界上下文之间可能有也可能没有关系

2、从一个限界上下文到另一个模型可以具有相同的名称但不同的定义

3、模型在一个限界上下文和另一个限界上下文之间可以有不同的名称,但具有一些或所有共同特征

对于没有任何关系的限界上下文,除了重申一下,如果你在两者中找到相同的模型名称就可以了,其他没什么好说的。假设这是仔细决定/迭代的结果。

另一方面,有共享关系的限界上下文,让我们看一下上下文映射,它提供了关于它们的关系如何影响它们的模型和语言的额外信息。

Upstream/Downstream

Figure 2. An upstream/downstream relationship.

在这里,上游是决定关系的人,使下游采用该语言作为自己的一部分。在我们的示例中,不仅在代码中使用了 Customer Profile,而且还被识别为 Checkout 限界上下文的概念部分。

Anti-Corruption Layer

Figure 3. An upstream/downstream with an anti-corruption layer.

在此示例中,Warehouse 不需要从产品信息管理 (PIM) 导入产品定义,而是定义一个本地概念,该概念具有所需信息的子集。

让我们从实现的角度仔细看看这些例子。

Connecting the Dots

我们的第一个实现将说明如何表示上游/下游关系。代码和讨论将集中在集成方面,而不是其他与 DDD 相关的方面。

我们虚构的 Customer Profile 定义了一个名为 Ordering Preferences 的模型,供 Checkout 使用。想象一下,作为其运营的一部分,它需要了解客户的预定义决定,以加快交付所有购买的商品。

class PrepareCheckoutCommandHandler {
    // other dependencies would also be injected here
    constructor(private readonly orderingPreferenceService: OrderingPreferenceService) {
    }

    // omitting error handling aspects
    async handle(command: PrepareCheckoutCommand): Promise<CheckoutDTO> {
        // obtain the ordering preferences for a given customer
        const orderingPreferences = await this.orderingPreferenceService.getOrderingPreferences(command.customerId);
        // ... potentially call other dependencies
        const checkout = new Checkout(command.customerId, command.cart, orderingPreferences);
        return toDTO(checkout);
    }       
}

我们的应用程序服务——命令处理程序——需要为试图启动结账流程的客户获取订购偏好。有了这些信息,就可以创建一个 Checkout 来封装预期要遵循的业务规则。

所以 OrderingPreferences 是一个本地概念,从 Customer Profile 导入并通过 OrderingPreferencesService 获得。一种常见的方法是将其表示为一个接口。

interface OrderingPreferencesService {
    getOrderingPreference(customerId: string): OrderingPreference;
}

在我们的基础设施层中,我们有这样一个接口的具体实现,它实际上与客户资料交互并产生预期值对象。

class CustomerProfileOrderingPreferencesService implements OrderingPreferencesService {
    constructor(private readonly httpClient: HttpClient) {
    }

    async getOrderingPreferences(customerId: string): Promise<OrderingPreferences> {
        const response = await this.httpClient.get(`/customers/${customerId}/ordering-preferences`);
        return OrderingPreferences.create(response.data);
    }
}

在这里我们看到我正在使用某种 HTTP 客户端发出此请求以检索数据,此时它只是一个数据集合。然后我使用该数据创建 OrderingPreferences,这是公认的本地概念。

现在让我们看看我们的第二个实现,它旨在说明您何时不导入概念,而是必须根据外部定义构建一个本地概念。

我们有一个仓库操作,为了处理和运送客户订单,要求我们提供一些用于海关目的的监管信息。与下订单时收到的上下文信息相反,这些额外的信息位于产品信息管理 (PIM) 的另一个限界上下文中。在 PIM 中,此信息是产品模型的一部分。

Figure 4. Free Trade Agreement made up of portions of the Product definition.

经过讨论,决定在当地没有必要有相同的产品概念,所需的信息被称为自由贸易协定。

像以前一样,我们有一个应用程序服务,它注入了依赖项。

class CreateOutboundHandler {
    constructor(private readonly freeTradeAgreementService: FreeTradeAgreementService) {
    }

    async handle(command: CreateOutboundCommand): Promise<OutboundDTO> {
        const freeTradeAgreement = await this.freeTradeAgreementService.getFromProduct(command.productId);
        const outbound = new Outbound(command.productId, command.quantity, freeTradeAgreement);
        return toDTO(outbound);
    }
}

首先要提到的是,您没有将 PIM 的产品定义公开给您的应用程序。它期望一个自由贸易协定值对象作为输出。和以前一样,一种方法是定义一个具有具体基础设施实现的接口。

// In your application
interface FreeTradeAgreementService {
    getFromProduct(productId: string): FreeTradeAgreement;
}

// In your infrastructure
class TranslatingFreeTradeAgreementService implements FreeTradeAgreementService {
    constructor(private readonly adapter: FreeTradeAgreementAdapter) {
    }

    async getFromProduct(productId: string): Promise<FreeTradeAgreement> {
        // More complex situations could require multiple calls to separate services in order to
        // build the local concept
        return await this.adapter.getFreeTradeAgreement(productId);
    }
}

TranslatingFreeTradeAgreementService 的前缀旨在明确此服务的作用,即采用外部概念并生成遵循本地语言的内容。

至于信息的实际检索及其操作,一种推荐的方法是在适配器和翻译器之间拆分职责,前者提出实际请求,后者负责验证并将必要的信息传递给您 值对象。

class FreeTradeAgreementAdapter {
    constructor(private readonly httpClient: HttpClient, private readonly translator: FreeTradeAgreementTranslator) {
    }

    getFreeTradeAgreement(productId: string): FreeTradeAgreement {
        const response = this.httpClient.get(`/products/${productId}`);
        return this.translator.translate(response.data);
    }
}

class FreeTradeAgreementTranslator {
    
    async translate(product: any): FreeTradeAgreement {
        // do some validation, conversion from string to number, etc.
        return FreeTradeAgreement.create(product.limitAmount, product.percentage);
    }
}

很好,但我需要所有这些吗?

使用的结构肯定有很多部分:接口、具体的翻译实现、适配器和转化器。您的第一反应可能是认为它在现实生活中没有用处太多或太复杂。最后,决定将取决于您和您采用的开发生态系统、编程语言和工具。

这里简要总结了每个组件的用途和一些实际注意事项。

The Service Interface

如果您知道并相信 SOLID 原则可以促进良好实践,那么该界面可以帮助您专注于意图,而不是被迫预先对太多实施细节做出决定。

一些开发语言允许您动态替换现有的实现以方便测试,这可以说可以让您跳过接口定义。

The Translating Service

这里的目标是确保您的翻译服务知道它需要联系哪些服务才能返回您的应用程序期望的本地概念。

它依靠一个或多个适配器来执行其命令,更像是一个编排器。

The Adapter

它是实际使用基础设施、http、gRPC 等从外部限界上下文获取数据并将其传递给转化器的一种。

如果您只有一个 BC 要联系,一个潜在的简化方法是直接从翻译服务进行远程呼叫和转化。

The Translator

获取从外部限界上下文接收的数据,验证它是否包含我们需要的结构,并使用它来创建所需的值对象。

转化中不应该存在任何实际的业务规则。在这里,您要验证预期存在的数据是否确实存在,丢弃我们不需要的数据,必要时在数据中执行最小转换,并将其传递给值对象以进行实际的域验证。

由于您将不得不编写代码来执行此操作,如果您想避免创建单独的类,您可以将此功能分组为适配器的内部部分。

从视觉上看,这是所有部分之间的关​​系:

我的建议是遵循垂直开发方法并不断重构您的代码。随着您的进步和获得更多关于所需集成的知识,这种功能分解将变得更加容易。

总结

集成多个限界上下文更像是一种规范而不是例外,因此花时间了解可以维护这些关系的多种形式是必不可少的。正如我们所看到的,这不是“魔法”,但确实需要一些时间来了解您如何进行这种集成,而不仅仅是技术方面。

如果您将依赖同步或异步通信,REST 或 gRPC 是重要且必要的方面,通常应推迟到您实际需要做出决定时再考虑。而且,这是在了解关系类型和将采用的(领域)语言之后发生的。

版权声明:著作权归作者所有。

thumb_up 1 | star_outline 0 | textsms 0