Kang

重构一段简单用例的旅程与思考

· 26 min read

上一篇日志中,我们要处理的核心主题是 Status(特性),并讨论了一个简单的例子。该例子包含两个用例(Use Case):一个用于更新数据,另一个用于重置数据。前者通过 HTTP 触发,后者通过 Event 触发。二者在逻辑上的主要区别在于:前者需要通过外部服务获取设备当前已存在的数据并进行存储;而后者则直接存储即可。

在上一篇发布后,我收到了同事的反馈,并与其进行了一番讨论。讨论之后,我意识到上一篇日志存在一些问题:不仅对 Use Case 的定义不够明确,而且对经典的 DDD 和 Clean Architecture 的概念也存在模糊与混淆。

因此,我打算对当前的实现(Implementation)进行一次较为完整的重构,以便让自己对它有更清晰的认知。这段旅程将从核心 Domain 出发,经历多个 Use Case。其中一个用例涉及与外部系统的交互,逻辑相对复杂,因此我会作为重点进行考察。最后,我将以如何调用这些 Use Case 作为结尾。

从文件目录结构上看,我将其分为三个部分:

  • Application:主要负责应用逻辑,核心包含 Use Case 和 Port。
  • Domain:核心逻辑所在,仅包含最基本的算法,不依赖任何外部技术实现。
  • Infrastructure:主要负责 Port 的 Adapter 实现,即各种外部适配器,它们依赖于 Application 中的 Port。

这是一张完整的架构地图。不用担心,我们会从头开始搭建。

.
├── application
│   ├── exception
│   │   └── FeatureStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── FeatureStatusQueryPort.java
│   │   └── outbound
│   │       ├── FeatureStatusRepositoryPort.java
│   │       ├── CatalogFeaturePort.java
│   │       ├── NotificationServicePort.java
│   │       └── DeviceInventoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       ├── ResetFeatureStatusUseCase.java
│       └── UpdateFeatureStatus.java
├── domain
│   ├── exception
│   │   ├── PremiumFeatureNotFoundDomainException.java
│   │   ├── FeatureNotFoundDomainException.java
│   │   └── DeviceNotFoundDomainException.java
│   ├── model
│   │   ├── PremiumFeatureStatus.java
│   │   ├── DeviceFeatureStatuses.java
│   │   └── DeviceFeatureStatus.java
│   └── service
│       └── FeatureStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── PremiumFeatureStatusResource.java
        │       └── dto
        │           └── DeviceFeatureStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToPremiumFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── NotificationServiceAdapter.java
            └── DeviceInventoryAdapter.java

创建 domain 与简单的用例

混沌初开之时,数据结构是一切的起点。记得有人曾说过 ” 软件系统 = 数据结构 + 算法 “,最近浏览的一篇日志也提到,数据结构决定了产品的形态。

因此,我们要做的第一件事就是创建数据模型和结构。毫无疑问,它属于 Domain 中的 Model。以下是数据模型的代码:特性 ID 及其状态组成了最基本的 Status,而 Statuses 则包含了一个 Status 列表以及设备标识(DeviceId)。

package com.example.featuresystem.domain.model;

import com.example.foundation.core.FeatureId;
import lombok.NonNull;

public record DeviceFeatureStatus(@NonNull FeatureId featureId, Boolean isActive) {
    public DeviceFeatureStatus withStatusReset(){
        return new DeviceFeatureStatus(this.featureId(), false);
    }
}
package com.example.featuresystem.domain.model;

import java.util.List;
import java.util.Optional;
import com.example.foundation.core.FeatureId;
import com.example.foundation.core.DeviceId;
import lombok.NonNull;

public record DeviceFeatureStatuses(@NonNull DeviceId deviceId, @NonNull List<DeviceFeatureStatus> deviceFeatureStatus) {

    public Optional<DeviceFeatureStatus> getFeatureStatusFor(
            @NonNull final FeatureId featureId) {
        return deviceFeatureStatus.stream()
                .filter(fs -> fs.featureId().toString().equalsIgnoreCase(featureId.toString()))
                .findFirst();
    }
}

对应数据库的 Entity 和 Table 均已提前创建。

接下来,我们需要定义 Repository 接口。虽然其结构简单,符合模版化的增删改查(CRUD),但关键在于:它定义了我在 Use Case 中需要使用什么接口。Interface 及其对应的实现通常包含大量样板代码,因此这里只列出 Port 的部分,忽略具体的实现细节。

package com.example.featuresystem.application.port.outbound;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;

import lombok.NonNull;

public interface FeatureStatusRepositoryPort {
    DeviceFeatureStatuses findByDeviceId(@NonNull final DeviceId deviceId);
    boolean save(@NonNull final DeviceFeatureStatuses deviceFeatureStatuses);
}

在 Clean/Hexagonal Architecture 中,Repository 的 Port 属于 Application Outbound,它的存在是为了服务于 Use Case。Application 作为业务的主导者,明确知道自己需要外部世界提供什么样的服务和工具来实现特定业务。它不关心具体的实现细节,也不关心数据的来源或获取方式。

准备好了与数据库交互的 Port 后,我们现在可以以产品经理的视角出发,在 Application 中创建最基础的 Use Case。以下是两个简单的例子:

第一个用例是直接从数据库获取数据,并假设不需要记录任何日志。

package com.example.featuresystem.application.usecase;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;

public class FeatureStatusQueryUseCase {

    private final FeatureStatusRepositoryPort featureStatusRepository;

    public FeatureStatusQueryUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
        this.featureStatusRepository = featureStatusRepository;
    }

    public DeviceFeatureStatuses apply(@NonNull final DeviceId deviceId) {
        return featureStatusRepository.findByDeviceId(deviceId);
    }
}

第二个用例稍微复杂一点,但仍旧只需要依赖 Repository 的 Port:从数据库读取数据,重置状态,并保存回数据库。

Use Case 通过 Port 控制数据,不需要、也不应该知晓任何底层实现的细节。

package com.example.featuresystem.application.usecase;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ResetFeatureStatusUseCase {
    private final FeatureStatusRepositoryPort featureStatusRepository;

    public ResetFeatureStatusUseCase(final FeatureStatusRepositoryPort featureStatusRepository) {
        this.featureStatusRepository = featureStatusRepository;
    }

    public void apply(@NonNull final DeviceId deviceId) {
        log.info("Invoking factory reset of feature statuses for DeviceId={}", deviceId);
        final DeviceFeatureStatuses currentStatuses = featureStatusRepository.findByDeviceId(deviceId);
        final DeviceFeatureStatuses resetStatuses = new DeviceFeatureStatuses(
                currentStatuses.deviceId(),
                currentStatuses.deviceFeatureStatus()
                        .stream()
                        .map(DeviceFeatureStatus::withStatusReset)
                        .toList()
        );
        featureStatusRepository.save(resetStatuses);
    }
}

到这里我们会面临一个问题:试想如果我们有一个 Controller 或 Resource 需要查询数据库中的数据,是否可以直接通过 Controller 连接 Repository 的 Port,而不经过 Use Case 直接处理?答案是否定的。因为位于 Inbound Adapter 层的 Controller,其职责是翻译外部的调用。它就像前台员工,不能不经过中间经理(Use Case)的审批,就直接跑到数据中心调取数据。严格来讲,即便是非常简单的逻辑,也理应属于一种特定的 Case,不能因为它简单就让它 ” 钻后门 ”。

这是当前的目录树结构:

.
├── application
│   ├── port
│   │   └── outbound
│   │       └── FeatureStatusRepositoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       └── ResetFeatureStatusUseCase.java
└── domain
    └── model
        ├── DeviceFeatureStatus.java
        └── DeviceFeatureStatuses.java

第三个用例最为复杂,但也最接近现实情况。我们需要依次完成下列步骤:

  1. 从输入参数中获取 DeviceId。
  2. 通过外部 DeviceInventory 服务,确保 DeviceId 存在。
  3. 从外部 CatalogLookupCatalogFeatureService 中获取 CatalogFeature 清单,判断 Features 是否存在于该清单中。
  4. 判断 Features 是否属于 Premium 类型。
  5. 转换数据类型,并存储。
  6. 调用外部服务 NotificationService,触发 Cache 失效。

原始的核心代码简要地描述了这个过程:

public void apply(final FeatureStatusModel featureStatusModel) {
        // Step 1
        final DeviceId deviceId = featureStatusModel.deviceId();
		// Step 2
        guardDeviceExists(deviceId);
		// Step 3
        final List<CatalogFeature> catalogFeatures = lookupFeaturesForCatalog(deviceId, getContext());
        guardFeatureExists(featureStatusModel, catalogFeatures);
	    // Step 4
        guardFeatureIsPremium(featureStatusModel, catalogFeatures);
		// Step 5
        final FeatureStatusModel mappedFeatureStatusModel = mapFeatureIdToMatchCatalog(featureStatusModel, catalogFeatures);
        final boolean stateHasChanged = featureStatusRepository.save(mappedFeatureStatusModel);
		// Step 6
        if (stateHasChanged) {
            log.debug("Invalidating FeatureCache for DeviceId={}", deviceId);
            notificationService.invalidateCacheForDevices(List.of(deviceId.toString()));
        }
    }

深入并拆解用例

步骤一是从输入参数中获取 DeviceId,非常简单,我们可以直接跳到步骤二。其中 DeviceId 类型默认为系统定义的核心类型,在此不多赘述。

final DeviceId deviceId = featureStatusModel.deviceId();

步骤二

步骤二需要通过外部系统 DeviceInventory 判断该 DeviceId 是否存在。原始的方法如下:

private void guardDeviceExists(final DeviceId deviceId) {
        // 模拟专有驱动连接控制
        try (final var rc = InternalDriver.controlRouting(RoutingControl.READ_ONLY, getClass().getName())) {
            final boolean deviceExists = deviceInventory.deviceExists(deviceId);

            if (!deviceExists) {
                log.warn("No device found for id '{}'", deviceId);
                throw FeatureStatusApiException.unknownDevice(deviceId);
            }
        }
    }

这段代码看似简单,但却融合了三部分含义:

  • Infrastructure:具体的技术控制,如 InternalDriver.controlRouting
  • Outbound Port:调用外部依赖,如 deviceInventory.deviceExists
  • Domain:业务规则,即设备必须存在 if(!deviceExists)

我们需要对它进行拆分。

首先剥离出 Domain 逻辑。作为 Domain,它不应知道如何查询,也不应该清楚日志记录,不对外部有依赖且不了解技术栈,只知道 ” 如果不存在,就报错 “。按照这个思路,简化后的逻辑是:

package com.example.featuresystem.domain.service;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;

public class DeviceValidator {
    public void guardDeviceExists(final DeviceId deviceId, final boolean exists) {
        if (!exists){
            throw new DeviceNotFoundDomainException(deviceId);
        }
    }
}

以及这里需要的 Exception:

package com.example.featuresystem.domain.exception;

import com.example.foundation.core.DeviceId;

public class DeviceNotFoundDomainException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final DeviceId deviceId;
    public DeviceNotFoundDomainException(final DeviceId deviceId) {
        super("No device found for DeviceId: "+deviceId.toString());
        this.deviceId = deviceId;
    }

    public DeviceId getDeviceId() {
        return deviceId;
    }
}

应该专门为 Domain 创建一个特定的 Exception,之后在 Use Case 中捕获这个 Exception。这样做的目的是为了让 Domain 能够与外界解耦。通俗地理解就是,即便删除 Application 和 Infrastructure 部分,IDE 依旧不会报错,编译也能通过。这就是我们熟知的依赖管理——Domain 不应该依赖外部世界。

接下来我们向外拓展,创建 Use Case 需要的外部能力,即 DeviceInventory。它可以将 exists 的 boolean 值传递进内部系统,最后交给 Domain 做判断。为此,我们需要在 Application 中定义一个 Outbound Port。这个 Port 应该由内部的 Application 定义,而不是外部。

package com.example.featuresystem.application.port.outbound;

import com.example.foundation.core.DeviceId;

public interface DeviceInventoryPort {
    boolean deviceExists(DeviceId deviceId);
}

实现的 Adapter 位于最外层的 Infrastructure。它依赖内部定义的 Port 并引入外部库,两者共同实现内部所需要的功能。

package com.example.featuresystem.infrastructure.adapter.outbound;

import com.example.foundation.core.DeviceId;
import com.example.foundation.drivers.InternalDriver; // 模拟通用驱动
import com.example.external.inventory.DeviceInventory;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;

public class DeviceInventoryAdapter implements DeviceInventoryPort {

    private final DeviceInventory deviceInventory;

    public DeviceInventoryAdapter(final DeviceInventory deviceInventory) {
        this.deviceInventory = deviceInventory;
    }

    @Override
    public boolean deviceExists(final DeviceId deviceId) {
        // 模拟技术细节实现
        try (var rc = InternalDriver.controlRouting(
                InternalDriver.READ_ONLY, getClass().getName())) {
            return deviceInventory.deviceExists(deviceId);
        }
    }
}

回顾先前的代码,发现还有一个日志逻辑:log.warn("No device found for id '{}'", deviceId);。它应该被放在哪里?因为 Domain 只会表达核心的规则(即规则不满足就失败),不会关心具体的记录,所以日志可以交给 Use Case 或 Infrastructure。

这里我将其理解为记录外部系统的状态,所以在 Infrastructure 中加入 log.debug("Checked device existence for ID {}: {}", deviceId, exists); 以作为技术日志。

或者你也可以选择在 Use Case 中加入。

该步骤在 Use Case 中的完整代码是:

// Step 2 UseCase
final boolean exists = deviceInventoryPort.deviceExists(deviceId);
try{
	deviceValidator.guardDeviceExists(deviceId, exists);
} catch (final DeviceNotFoundDomainException ex) {
	log.warn("No device found for id '{}'", deviceId);
	throw FeatureStatusApiException.unknownDevice(deviceId);
}

步骤三

首先看原来的代码,它分为两个部分:先获取 CatalogFeature,然后调用 guardFeatureExists 与输入参数比较。

final List<CatalogFeature> catalogFeatures = lookupFeaturesForCatalog(deviceId, getContext());
guardFeatureExists(featureStatusModel, catalogFeatures);

要注意的是,我们引入了外部的模型 CatalogFeature。为了不引入与 Domain 无关的信息,或者说避免 ” 污染 ” 系统,首先要定义一个专属于 Domain 的数据类型。

package com.example.featuresystem.domain.model;

import com.example.foundation.core.FeatureId;
import lombok.NonNull;

public record PremiumFeatureStatus(@NonNull FeatureId featureId, @NonNull boolean isPremium) {
}

然后我们需要一个 Mapper,能将外部的 CatalogFeature 转化为内部数据模型。

我把它放进 infrastructure.outbound 中,因为它代表的是将外部数据转换至内部数据,该转换发生在 Infrastructure -> Application 的边缘位置。我理解的边缘,类似于 Return 位置或是方法入参位置。

package com.example.featuresystem.infrastructure.adapter.outbound.mapper;

import java.util.List;
import com.example.foundation.core.FeatureId;
import com.example.featuresystem.domain.constants.FeatureTag;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.external.catalog.CatalogFeature;

public class CatalogFeatureToPremiumFeatureStatusMapper {

    public static List<PremiumFeatureStatus> toDomain(final List<CatalogFeature> catalogFeatures) {
        return catalogFeatures
                .stream()
                .map(feature -> new PremiumFeatureStatus(
                        FeatureId.of(feature.getFeatureId()),
                        hasPremiumTag(feature)
                )).toList();
    }

    private static boolean hasPremiumTag(final CatalogFeature feature) {
        return feature.getTags().stream().anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_PREMIUM.value()));
    }
}

有了内部的数据类型和转换器,我们可以在 Application 的 Outbound 中创建一个获取 PremiumFeatureStatus(或说 CatalogFeature)的 Port,专门供 Use Case 使用。而这个 Port 的实现,因为不是 Application 关注的重点,可以一股脑地全部放进 Infrastructure 的 Adapter 中。

这也就是人们常说的依赖倒置(Dependency Inversion),即高层模块不依赖外部实现的 Infrastructure,而只依赖抽象 Port,但是 Infrastructure 必须依赖 Port。

我们首先定义一个 Port:

package com.example.featuresystem.application.port.outbound;

import java.util.List;
import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;

public interface CatalogFeaturePort {
    List<PremiumFeatureStatus> findByDeviceId(DeviceId deviceId);
}

然后实现该 Interface 的 Adapter,其中的大部分代码可以直接复制。你不必阅读完整的细节,但可以特地看看方法最后的转换部分,它将外部的 CatalogFeature 转换成了 PremiumFeatureStatus

package com.example.featuresystem.infrastructure.adapter.outbound;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import com.example.foundation.core.DeviceId;
import com.example.external.catalog.CatalogLookup;
import com.example.external.catalog.model.CatalogInfo;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.infrastructure.adapter.outbound.mapper.CatalogFeatureToPremiumFeatureStatusMapper;
import com.example.external.catalog.CatalogFeature;
import com.example.external.catalog.CatalogFeatures;
import com.example.featuresystem.services.CatalogFeatureService;
import com.example.featuresystem.services.NotificationService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CatalogFeatureAdapter implements CatalogFeaturePort {

    private final NotificationService notificationService;
    private final CatalogLookup catalogLookup;
    private final CatalogFeatureService catalogFeatureService;

    public CatalogFeatureAdapter(
            final NotificationService notificationService,
            final CatalogLookup catalogLookup,
            final CatalogFeatureService catalogFeatureService){
        this.notificationService = notificationService;
        this.catalogLookup = catalogLookup;
        this.catalogFeatureService = catalogFeatureService;
    }

    // 省略具体的上下文构建方法…

    @Override
    public List<PremiumFeatureStatus> findByDeviceId(final DeviceId deviceId) {
        // 模拟上下文
        final var context = new Object();

        log.debug("Fetching CatalogFeatures for DeviceId={}", deviceId);
        final List<CatalogInfo> catalogs = catalogLookup.getCatalogsFor(deviceId, null, context);
        if (catalogs.isEmpty()) {
            return Collections.emptyList();
        }

        final List<CatalogFeature> associatedFeatures = new ArrayList<>();

        for (final CatalogInfo catalog : catalogs) {
            final Optional<CatalogFeatures> featuresFromCache = Optional.ofNullable(
            catalogFeatureService.findUnfilteredByCatalogIdCached(catalog.getCatalogId()));
            final CatalogFeatures features = featuresFromCache.orElseGet(
                    () -> catalogFeatureService.findUnfilteredByCatalogId(catalog.getCatalogId()));
            if (features != null) {
                associatedFeatures.addAll(features.getAssociatedFeatures());
            } else {
                log.debug("No Features for catalog '{}' found", catalog.getCatalogId());
            }
        }

        return CatalogFeatureToPremiumFeatureStatusMapper.toDomain(associatedFeatures);
    }
}

有了 Port,也有了实际的 Adapter,终于可以开始搭建 Use Case 和 Domain 的逻辑了。核心逻辑是:我们需要确保输入特性和已经存在的特性相互匹配,如果特性根本不存在,则直接抛出错误。

package com.example.featuresystem.domain.service;

import java.util.Collection;
import java.util.List;

import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;

public class FeatureGuard {
    public void guardFeatureExists(
            final DeviceFeatureStatuses deviceFeatureStatuses,
            final Collection<PremiumFeatureStatus> premiumFeatureStatuses) {

        final List<String> featureIds = premiumFeatureStatuses.stream()
                .map(featureIsPremium -> featureIsPremium.featureId().toString())
                .toList();

        final List<String> missingIds = deviceFeatureStatuses.deviceFeatureStatus().stream().map(
                dfs -> dfs.featureId().toString().toLowerCase()
        ).filter(dfs -> !featureIds.contains(dfs)).toList();

        if(featureIds.isEmpty() || !missingIds.isEmpty()) {
            throw new FeatureNotFoundDomainException(deviceFeatureStatuses.deviceId(), missingIds);
        }
    }
}

最后 Use Case 使用这个逻辑:

final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
try {
	featureGuard.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
} catch (final FeatureNotFoundDomainException ex) {
	throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
}

同样,对于 Exception,只需要在它们所属的层定义:

  • FeatureNotFoundDomainException 位于 domain.exception 中。
  • FeatureStatusApiException 位于 application.exception 中。

简单总结一下步骤三的流程:

  1. 定义 Domain 模型和 Mapper,确保数据格式正确。
  2. 定义 Port 和 Adapter,确保外部数据可以进入。
  3. 准备 Domain 逻辑并加入 Use Case。

步骤四

这一步我们要判断特性的类别是否属于 Premium。可以看到原始代码严重依赖外部数据结构:

private static void guardFeatureIsPremium(
            final DeviceFeatureStatusesModel featureStatusModel,
            final Collection<CatalogFeature> catalogFeatures) {

        final List<CatalogFeature> premiumFeatures = catalogFeatures.stream()
                .filter(cf -> cf.getTags().stream()
                        .anyMatch(tag -> tag.equalsIgnoreCase(FeatureTag.IS_PREMIUM.value())))
                .toList();;

        // Filter Request for any non-Premium features
        final List<String> nonPremiumFeatures = featureStatusModel.featureStatusModel()
                .stream()
                .map(fs -> fs.featureId().toString().toLowerCase())
                .filter(fid -> premiumFeatures.stream()
                        .map(pf -> pf.getFeatureId().toLowerCase())
                        .noneMatch(fid::equals))
                .toList();

        if (!nonPremiumFeatures.isEmpty()) {
            final DeviceId deviceId = featureStatusModel.deviceId();
            throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, nonPremiumFeatures);
        }
    }

得益于我们在步骤三中已经提前构建好了 Domain 的数据模型 PremiumFeatureStatus,该步骤的逻辑可以大幅度简化,结构也十分清晰。

package com.example.featuresystem.domain.service;

import java.util.Collection;
import java.util.List;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.exception.PremiumFeatureNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;

public class PremiumFeatureGuard {

    public static void guardFeatureIsPremium(
            final DeviceFeatureStatuses deviceFeatureStatuses,
            final Collection<PremiumFeatureStatus> premiumFeatureStatuses) {

        final List<String> premiumFeatures = premiumFeatureStatuses
                .stream()
                .filter(PremiumFeatureStatus::isPremium)
                .map(feature -> feature.featureId().toString())
                .toList();

        final List<String> nonPremiumFeatures = deviceFeatureStatuses
                .deviceFeatureStatus()
                .stream()
                .map(dfs -> dfs.featureId().toString().toLowerCase())
                .filter(fid -> premiumFeatureStatuses.stream()
                        .map(pfs -> pfs.featureId().toString())
                        .noneMatch(fid::equals)).toList();

        if (!nonPremiumFeatures.isEmpty()) {
            final DeviceId deviceId = deviceFeatureStatuses.deviceId();
            throw new PremiumFeatureNotFoundDomainException(deviceId, nonPremiumFeatures);
        }
    }
}

步骤五

当输入的数据经过检查确认无误后,只需要经过简单的转换,就可以准备存储到系统的数据库中。由于是 Domain 内部的数据转换,我们直接将 Mapper 放在 Domain 内部来处理,新建一个 Mapper 即可,等待后续 Use Case 调用。

package com.example.featuresystem.domain.mapper;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.example.foundation.core.FeatureId;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;

public class PremiumFeatureToStatusMapper {

    public DeviceFeatureStatuses toDeviceFeatureStatuses(
            final DeviceFeatureStatuses deviceFeatureStatuses,
            final Collection<PremiumFeatureStatus> premiumFeatureStatuses
    ) {

        final Map<String, String> mappedFeatureIds = premiumFeatureStatuses
                .stream()
                .collect(Collectors.toMap(
                        feature -> feature.featureId().toString().toLowerCase(),
                        feature -> feature.featureId().toString(),
                        (existingValue, newValue) -> existingValue));

        final List<DeviceFeatureStatus> correctlyCapitalizedFeatureId =
                deviceFeatureStatuses.deviceFeatureStatus()
                        .stream()
                        .map(dfs -> {
                            final String featureId = mappedFeatureIds.get(dfs.featureId().toString().toLowerCase());
                            return new DeviceFeatureStatus(FeatureId.of(featureId), dfs.isActive());
                        }).toList();

        return new DeviceFeatureStatuses(deviceFeatureStatuses.deviceId(),
                correctlyCapitalizedFeatureId);
    }
}

在 Use Case 中,转换并存储。

final boolean statusHasChanged = featureStatusRepositoryPort.save(premiumFeatureToStatusMapper.toDeviceFeatureStatuses(deviceFeatureStatuses,premiumFeatureStatuses));

步骤六

最后一步很简单,触发外部系统的方法让 Cache 失效。类似步骤二,只需要创建 Port 和 Adapter。

package com.example.featuresystem.application.port.outbound;

import java.util.List;

public interface NotificationServicePort {
    void invalidateFeatureCacheForDevices(List<String> deviceIds);
}
package com.example.featuresystem.infrastructure.adapter.outbound;

import java.util.List;

import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.services.NotificationService;

public class NotificationServiceAdapter implements NotificationServicePort {

    private final NotificationService notificationService;

    public NotificationServiceAdapter(final NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    @Override
    public void invalidateFeatureCacheForDevices(final List<String> deviceIds) {
        notificationService.invalidateCacheForDevices(deviceIds);
    }
}

合并与整理

终于,将所有的步骤整合进来,我们得到了最终的 Use Case。

package com.example.featuresystem.application.usecase;

import java.util.List;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;
import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;
import com.example.featuresystem.domain.mapper.PremiumFeatureToStatusMapper;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.domain.service.PremiumFeatureGuard;
import com.example.featuresystem.domain.service.FeatureGuard;
import com.example.featuresystem.domain.service.DeviceValidator;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class UpdateFeatureStatus {

    // outbound port
    private final DeviceInventoryPort deviceInventoryPort;
    private final CatalogFeaturePort catalogFeaturePort;
    private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
    private final NotificationServicePort notificationServicePort;
    // domain service
    private final DeviceValidator deviceValidator;
    private final FeatureGuard featureGuard;
    private final PremiumFeatureGuard premiumFeatureGuard;
    private final PremiumFeatureToStatusMapper premiumFeatureToStatusMapper;

    public UpdateFeatureStatus(final DeviceInventoryPort deviceInventoryPort,
            final CatalogFeaturePort catalogFeaturePort,
            final FeatureStatusRepositoryPort featureStatusRepositoryPort,
            final NotificationServicePort notificationServicePort,
            final DeviceValidator deviceValidator,
            final FeatureGuard featureGuard,
            final PremiumFeatureGuard premiumFeatureGuard,
            final PremiumFeatureToStatusMapper premiumFeatureToStatusMapper) {
        this.deviceInventoryPort = deviceInventoryPort;
        this.catalogFeaturePort = catalogFeaturePort;
        this.featureStatusRepositoryPort = featureStatusRepositoryPort;
        this.notificationServicePort = notificationServicePort;

        this.deviceValidator = deviceValidator;
        this.featureGuard = featureGuard;
        this.premiumFeatureGuard = premiumFeatureGuard;
        this.premiumFeatureToStatusMapper = premiumFeatureToStatusMapper;
    }

    public void handle(final DeviceFeatureStatuses deviceFeatureStatuses) {
        final DeviceId deviceId = deviceFeatureStatuses.deviceId();

        // Step 2
        final boolean exists = deviceInventoryPort.deviceExists(deviceId);
        try {
            deviceValidator.guardDeviceExists(deviceId, exists);
        } catch (final DeviceNotFoundDomainException ex) {
            log.warn("No device found for id '{}'", deviceId);
            throw FeatureStatusApiException.unknownDevice(deviceId);
        }

        // Step 3
        final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
        try {
            featureGuard.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
        }

        // Step 4.
        try {
            premiumFeatureGuard.guardFeatureIsPremium(deviceFeatureStatuses, premiumFeatureStatuses);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, ex.getMissingFeatureIds());
        }

        // Step. 5
        final boolean statusHasChanged = featureStatusRepositoryPort.save(
                premiumFeatureToStatusMapper.toDeviceFeatureStatuses(deviceFeatureStatuses,premiumFeatureStatuses));

        if (statusHasChanged) {
            log.debug("Invalidating FeatureCache for DeviceId={}", deviceId);
notificationServicePort.invalidateFeatureCacheForDevices(List.of(deviceId.toString()));
        }
    }
}

当然,这还算不上最简洁的形式,我们还应对它进行进一步简化。

首先整合 Catch 块。其次,我们可以假定所有的 Domain Services 边界都在 FeatureStatusService 中。经过这样的整理,神奇的事情发生了——它竟然和我们最初看到的逻辑十分接近!并且在外形上,非常像经典的、依赖于大 Service 的 DDD 模型。

简化后的代码如下:

package com.example.featuresystem.application.usecase;

import java.util.List;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.port.outbound.FeatureStatusRepositoryPort;
import com.example.featuresystem.application.port.outbound.CatalogFeaturePort;
import com.example.featuresystem.application.port.outbound.NotificationServicePort;
import com.example.featuresystem.application.port.outbound.DeviceInventoryPort;
import com.example.featuresystem.domain.exception.PremiumFeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.FeatureNotFoundDomainException;
import com.example.featuresystem.domain.exception.DeviceNotFoundDomainException;
import com.example.featuresystem.domain.model.PremiumFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.domain.service.FeatureStatusService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class UpdateFeatureStatus {

    // outbound port
    private final DeviceInventoryPort deviceInventoryPort;
    private final CatalogFeaturePort catalogFeaturePort;
    private final FeatureStatusRepositoryPort featureStatusRepositoryPort;
    private final NotificationServicePort notificationServicePort;
    // domain service
    private final FeatureStatusService featureStatusService;

    public UpdateFeatureStatus(final DeviceInventoryPort deviceInventoryPort,
            final CatalogFeaturePort catalogFeaturePort,
            final FeatureStatusRepositoryPort featureStatusRepositoryPort,
            final NotificationServicePort notificationServicePort,
            final FeatureStatusService featureStatusService
    ) {
        this.deviceInventoryPort = deviceInventoryPort;
        this.catalogFeaturePort = catalogFeaturePort;
        this.featureStatusRepositoryPort = featureStatusRepositoryPort;
        this.notificationServicePort = notificationServicePort;
        this.featureStatusService = featureStatusService;
    }

    public void handle(final DeviceFeatureStatuses deviceFeatureStatuses) {
        final DeviceId deviceId = deviceFeatureStatuses.deviceId();
        final boolean exists = deviceInventoryPort.deviceExists(deviceId);

        try {
            featureStatusService.guardDeviceExists(deviceId, exists);
            final List<PremiumFeatureStatus> premiumFeatureStatuses = catalogFeaturePort.findByDeviceId(deviceId);
            featureStatusService.guardFeatureExists(deviceFeatureStatuses, premiumFeatureStatuses);
            featureStatusService.guardFeatureIsPremium(deviceFeatureStatuses, premiumFeatureStatuses);
            final boolean statusHasChanged = featureStatusRepositoryPort.save(
featureStatusService.toDeviceFeatureStatuses(deviceFeatureStatuses, premiumFeatureStatuses));
            if (statusHasChanged) {
                log.debug("Invalidating FeatureCache for DeviceId={}", deviceId);
                notificationServicePort.invalidateFeatureCacheForDevices(List.of(deviceId.toString()));
            }
        } catch (final DeviceNotFoundDomainException ex) {
            log.warn("No device found for id '{}'", deviceId);
            throw FeatureStatusApiException.unknownDevice(deviceId);
        } catch (final FeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.noFeatureForCatalogFound(deviceId, ex.getMissingFeatureIds().toString());
        } catch (final PremiumFeatureNotFoundDomainException ex) {
            throw FeatureStatusApiException.nonPremiumFeatureProvided(deviceId, ex.getMissingFeatureIds());
        }
    }
}

最后我还注意到,这个 Use Case 的流程与 Jira Ticket 中的验收标准(Acceptance Criteria)非常接近。从另一个角度理解,Ticket 可以是 Implementation 的抽象,后者依赖于前者。

调用用例

在上述 Use Case 中,我们假设有两种方式调用它们:一种是使用 Java Interface,另一种是使用 Resource(即 HTTP Request)。

Java Interface 最简单,只需要暴露 Use Case 类的接口即可,位置在 application.port.inbound

package com.example.featuresystem.application.port.inbound;

import com.example.foundation.core.DeviceId;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;

import lombok.NonNull;

public interface FeatureStatusQueryPort {
    DeviceFeatureStatuses apply(@NonNull final DeviceId deviceId);
}

第二种方式稍微复杂一些。HTTP 请求属于一种技术实现,因此完全可以将它放进 Infrastructure 的 Inbound Adapter 中。它可以直接调用 Use Case 中的实现,因为它的职责就是处理边界的数据转换,并连接内部系统。

代码如下:

package com.example.featuresystem.infrastructure.adapter.inbound.rest;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import com.example.foundation.core.FeatureId;
import com.example.foundation.core.DeviceId;
// 使用标准的 JaxRs 或 Spring 标注替代专有组件
import com.example.featuresystem.application.exception.FeatureStatusApiException;
import com.example.featuresystem.application.usecase.UpdateFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatus;
import com.example.featuresystem.domain.model.DeviceFeatureStatuses;
import com.example.featuresystem.infrastructure.adapter.inbound.rest.dto.DeviceFeatureStatusesRequest;

import org.springframework.stereotype.Component;

import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Path("/")
@Component
public class PremiumFeatureStatusResource {

    private final UpdateFeatureStatus updateFeatureStatus;

    public PremiumFeatureStatusResource(final UpdateFeatureStatus updateFeatureStatus) {
        this.updateFeatureStatus = updateFeatureStatus;
    }

    @PUT
    @Path("devices/{deviceId}/provisioned")
    @Consumes({MediaType.APPLICATION_JSON})
    public Response updateFeatureStatusForDevices(
            @PathParam("deviceId") final DeviceId deviceId,
            @Valid final DeviceFeatureStatusesRequest subscriptionStatusRequest) {
        log.info("Updating feature statuses for DeviceId: {}", deviceId);

        updateFeatureStatus.handle(mapToDomainModel(deviceId, subscriptionStatusRequest.features()));

        log.debug("Successfully updated feature statuses");
        return Response.ok().build();
    }

    private static DeviceFeatureStatuses mapToDomainModel(final DeviceId deviceId,
            final Map<String, Boolean> featureStatusList) {
        final List<DeviceFeatureStatus> deviceFeatureStatusModels = new ArrayList<>();
        featureStatusList.forEach((featureId, isActive) ->
                deviceFeatureStatusModels.add(new DeviceFeatureStatus(FeatureId.of(featureId),
                        isActive)));
        return new DeviceFeatureStatuses(deviceId, deviceFeatureStatusModels);
    }
}

这里的 Mapper 是可选拆分,可以放在 Resource 中,也可以单独创建 Mapper。

值得注意的是 DeviceFeatureStatusesRequest,作为与外部世界的协议(HTTP),它不应该放在 Application 或 Domain 中,因为 Adapter 采用的技术可能会发生变化(例如不再是 HTTP 而是 Kafka),而 Use Case 不应受到任何影响。

package com.example.featuresystem.infrastructure.adapter.inbound.rest.dto;

import java.util.Map;

public record DeviceFeatureStatusesRequest(Map<String, Boolean> features) {

}

这是相关的文件夹结构:

└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── PremiumFeatureStatusResource.java -> resource
        │       └── dto
        │           └── DeviceFeatureStatusesRequest.java -> DTO
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToPremiumFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── NotificationServiceAdapter.java
            └── DeviceInventoryAdapter.java

总结与尾声

再回顾一下我们搭建的地图:

.
├── application
│   ├── exception
│   │   └── FeatureStatusApiException.java
│   ├── port
│   │   ├── inbound
│   │   │  └── FeatureStatusQueryPort.java
│   │   └── outbound
│   │       ├── FeatureStatusRepositoryPort.java
│   │       ├── CatalogFeaturePort.java
│   │       ├── NotificationServicePort.java
│   │       └── DeviceInventoryPort.java
│   └── usecase
│       ├── FeatureStatusQueryUseCase.java
│       ├── ResetFeatureStatusUseCase.java
│       └── UpdateFeatureStatus.java
├── domain
│   ├── exception
│   │   ├── PremiumFeatureNotFoundDomainException.java
│   │   ├── FeatureNotFoundDomainException.java
│   │   └── DeviceNotFoundDomainException.java
│   ├── model
│   │   ├── PremiumFeatureStatus.java
│   │   ├── DeviceFeatureStatuses.java
│   │   └── DeviceFeatureStatus.java
│   └── service
│       └── FeatureStatusService.java
└── infrastructure
    └── adapter
        ├── inbound
        │   └── rest
        │       ├── PremiumFeatureStatusResource.java
        │       └── dto
        │           └── DeviceFeatureStatusesRequest.java
        └── outbound
            ├── mapper
            │   └── CatalogFeatureToPremiumFeatureStatusMapper.java
            ├── CatalogFeatureAdapter.java
            ├── NotificationServiceAdapter.java
            └── DeviceInventoryAdapter.java

我们从最核心的 Domain 数据结构设计出发,配合 Repository 的 Port 设计出了两个简易的 Use Case。然后深入探讨了如何处理最复杂的 Case,包括引入外部数据、数据在边界及 Domain 内部的转换、如何处理 Exception,以及如何使用不同的方式触发 Use Case。

当然,这仅仅是针对这个简单例子的复盘,实际开发中遇到的情况往往更加复杂。想对你们说,也同样是想对自己说的是:这绝对不是完美的解决方案,也不是适用所有情况的通用架构。其中繁杂的数据类型转换和过度工程化(Over-engineering)的问题依旧没有得到解决,反倒加剧了,这点看看上面的例子就能很快明白。

当前,我将 Hexagonal/Clean Architecture 理解为对经典 DDD 的细化,它保留了纯净 Domain 的核心,区别在于从大而全的 Service 中分出了一部分职能给 Use Case。(个人觉得这个趋势有点像是从以功能技术开发为核心,转变为以快速适配不同用户的不同需求为核心)。

利用 Interface 的特性,我们实现了依赖反转,依赖路径变成了 Infrastructure -> Application -> Domain。之前我只会关注 Interface 可以被 Implement,而没有关注它可以定义变量类型的特性。正是由于关注点的不同,控制权也悄然发生了转变。

至于具体的项目而言,它依旧会非常庞大且古老,同时也几乎很难改变原始代码的结构。老式的 Service 依旧长存,新结构迟迟不会出现,但它的存在却会帮助我在脑海中树立一种清晰的架构意识,帮助我梳理并归类旧有的逻辑。

感谢您的阅读!您的支持是我的动力。

如果您喜欢这篇文章,不妨请我喝杯咖啡。 ☕️