在上一篇日志中,我们要处理的核心主题是 ModuleStatus(模块状态),并讨论了一个简单的例子。该例子包含两个用例(Use Case):一个用于更新数据,另一个用于重置数据。前者通过 HTTP 触发,后者通过 Events 触发。二者在逻辑上的主要区别在于:前者需要通过外部服务获取租户当前已存在的数据并进行存储;而后者则直接存储即可。
在上一篇发布后,我收到了同事的反馈,并与其进行了一番讨论。讨论之后,我意识到上一篇日志存在一些问题:不仅对 Use Case 的定义不够明确,而且对经典的 DDD 和 Clean Architecture 的概念也存在模糊与混淆。
因此,我打算对当前的实现(Implementation)进行一次较为完整的重构,以便让自己对它有更清晰的认知。这段旅程将从核心 Domain 出发,经历多个 Use Case。其中一个用例涉及与外部系统的交互,逻辑相对复杂,因此我会作为重点进行考察。最后,我将以如何调用这些 Use Case 作为结尾。
从文件目录结构上看,我将其分为三个部分:
- Application:主要负责应用逻辑,核心包含 Use Case 和 Port。
- Domain:核心逻辑所在,仅包含最基本的算法,不依赖任何外部技术实现。
- Infrastructure:主要负责 Port 的 Adapter 实现,即各种外部适配器,它们依赖于 Application 中的 Port。
这是一张完整的架构地图。不用担心,我们会从头开始搭建。
.
├── application
│ ├── exception
│ │ └── ModuleStatusApiException.java
│ ├── port
│ │ ├── inbound
│ │ │ └── ModuleStatusQueryPort.java
│ │ └── outbound
│ │ ├── ModuleStatusRepositoryPort.java
│ │ ├── RegistryModulePort.java
│ │ ├── EventServicePort.java
│ │ └── TenantDirectoryPort.java
│ └── usecase
│ ├── ModuleStatusQueryUseCase.java
│ ├── ResetModuleStatusUseCase.java
│ └── UpdateModuleStatus.java
├── domain
│ ├── exception
│ │ ├── EnterpriseModuleNotFoundDomainException.java
│ │ ├── ModuleNotFoundDomainException.java
│ │ └── TenantNotFoundDomainException.java
│ ├── model
│ │ ├── EnterpriseModuleStatus.java
│ │ ├── TenantModuleStatuses.java
│ │ └── TenantModuleStatus.java
│ └── service
│ └── ModuleStatusService.java
└── infrastructure
└── adapter
├── inbound
│ └── rest
│ ├── EnterpriseModuleStatusResource.java
│ └── dto
│ └── TenantModuleStatusesRequest.java
└── outbound
├── mapper
│ └── RegistryModuleToEnterpriseModuleStatusMapper.java
├── RegistryModuleAdapter.java
├── EventServiceAdapter.java
└── TenantDirectoryAdapter.java
创建 Domain 与简单的用例
混沌初开之时,数据结构是一切的起点。记得有人曾说过 ” 软件系统 = 数据结构 + 算法 “,最近浏览的一篇日志也提到,数据结构决定了产品的形态。
因此,我们要做的第一件事就是创建数据模型和结构。毫无疑问,它属于 Domain 中的 Model。以下是数据模型的代码:模块 ID 及其状态组成了最基本的 Status,而 Statuses 则包含了一个 Status 列表以及租户标识(TenantId)。
package com.example.modulesystem.domain.model;
public record TenantModuleStatus(@NonNull ModuleId moduleId, Boolean isActive) {
public TenantModuleStatus withStatusReset(){
return new TenantModuleStatus(this.moduleId(), false);
}
}
package com.example.modulesystem.domain.model;
public record TenantModuleStatuses(@NonNull TenantId tenantId, @NonNull List<TenantModuleStatus> tenantModuleStatus) {
public Optional<TenantModuleStatus> getModuleStatusFor(
@NonNull final ModuleId moduleId) {
return tenantModuleStatus.stream()
.filter(fs -> fs.moduleId().toString().equalsIgnoreCase(moduleId.toString()))
.findFirst();
}
}
对应数据库的 Entity 和 Table 均已提前创建。
接下来,我们需要定义 Repository 接口。虽然其结构简单,符合模版化的增删改查(CRUD),但关键在于:它定义了我在 Use Case 中需要使用什么接口。Interface 及其对应的实现通常包含大量样板代码,因此这里只列出 Port 的部分,忽略具体的实现细节。
package com.example.modulesystem.application.port.outbound;
public interface ModuleStatusRepositoryPort {
TenantModuleStatuses findByTenantId(@NonNull final TenantId tenantId);
boolean save(@NonNull final TenantModuleStatuses tenantModuleStatuses);
}
在 Clean/Hexagonal Architecture 中,Repository 的 Port 属于 Application Outbound,它的存在是为了服务于 Use Case。Application 作为业务的主导者,明确知道自己需要外部世界提供什么样的服务和工具来实现特定业务。它不关心具体的实现细节,也不关心数据的来源或获取方式。
准备好了与数据库交互的 Port 后,我们现在可以以产品经理的视角出发,在 Application 中创建最基础的 Use Case。以下是两个简单的例子:
第一个用例是直接从数据库获取数据,并假设不需要记录任何日志。
package com.example.modulesystem.application.usecase;
public class ModuleStatusQueryUseCase {
private final ModuleStatusRepositoryPort moduleStatusRepository;
public ModuleStatusQueryUseCase(final ModuleStatusRepositoryPort moduleStatusRepository) {
this.moduleStatusRepository = moduleStatusRepository;
}
public TenantModuleStatuses apply(@NonNull final TenantId tenantId) {
return moduleStatusRepository.findByTenantId(tenantId);
}
}
第二个用例稍微复杂一点,但仍旧只需要依赖 Repository 的 Port:从数据库读取数据,重置状态,并保存回数据库。
Use Case 通过 Port 控制数据,不需要、也不应该知晓任何底层实现的细节。
package com.example.modulesystem.application.usecase;
@Slf4j
public class ResetModuleStatusUseCase {
private final ModuleStatusRepositoryPort moduleStatusRepository;
public ResetModuleStatusUseCase(final ModuleStatusRepositoryPort moduleStatusRepository) {
this.moduleStatusRepository = moduleStatusRepository;
}
public void apply(@NonNull final TenantId tenantId) {
log.info("Invoking factory reset of module statuses for TenantId={}", tenantId);
final TenantModuleStatuses currentStatuses = moduleStatusRepository.findByTenantId(tenantId);
final TenantModuleStatuses resetStatuses = new TenantModuleStatuses(
currentStatuses.tenantId(),
currentStatuses.tenantModuleStatus()
.stream()
.map(TenantModuleStatus::withStatusReset)
.toList()
);
moduleStatusRepository.save(resetStatuses);
}
}
到这里我们会面临一个问题:试想如果我们有一个 Controller 或 Resource 需要查询数据库中的数据,是否可以直接通过 Controller 连接 Repository 的 Port,而不经过 Use Case 直接处理?答案是否定的。因为位于 Inbound Adapter 层的 Controller,其职责是翻译外部的调用。它就像前台员工,不能不经过中间经理(Use Case)的审批,就直接跑到数据中心调取数据。严格来讲,即便是非常简单的逻辑,也理应属于一种特定的 Case,不能因为它简单就让它 ” 钻后门 ”。
这是当前的目录树结构:
.
├── application
│ ├── port
│ │ └── outbound
│ │ └── ModuleStatusRepositoryPort.java
│ └── usecase
│ ├── ModuleStatusQueryUseCase.java
│ └── ResetModuleStatusUseCase.java
└── domain
└── model
├── TenantModuleStatus.java
└── TenantModuleStatuses.java
第三个用例最为复杂,但也最接近现实情况。我们需要依次完成下列步骤:
- 从输入参数中获取 TenantId。
- 通过外部 TenantDirectory 服务,确保 TenantId 存在。
- 从外部
RegistryLookup和RegistryModuleService中获取RegistryModule清单,判断 Modules 是否存在于该清单中。 - 判断 Modules 是否属于 Enterprise 类型。
- 转换数据类型,并存储。
- 调用外部服务 EventService,触发 Cache 失效。
原始的核心代码简要地描述了这个过程:
public void apply(final ModuleStatusModel moduleStatusModel) {
// Step 1
final TenantId tenantId = moduleStatusModel.tenantId();
// Step 2
guardTenantExists(tenantId);
// Step 3
final List<RegistryModule> registryModules = lookupModulesForRegistry(tenantId, getContext());
guardModuleExists(moduleStatusModel, registryModules);
// Step 4
guardModuleIsEnterprise(moduleStatusModel, registryModules);
// Step 5
final ModuleStatusModel mappedModuleStatusModel = mapModuleIdToMatchRegistry(moduleStatusModel, registryModules);
final boolean stateHasChanged = moduleStatusRepository.save(mappedModuleStatusModel);
// Step 6
if (stateHasChanged) {
log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
eventService.invalidateCacheForTenants(List.of(tenantId.toString()));
}
}
深入并拆解用例
步骤一是从输入参数中获取 TenantId,非常简单,我们可以直接跳到步骤二。其中 TenantId 类型默认为系统定义的核心类型,在此不多赘述。
final TenantId tenantId = moduleStatusModel.tenantId();
步骤二
步骤二需要通过外部系统 TenantDirectory 判断该 TenantId 是否存在。原始的方法如下:
private void guardTenantExists(final TenantId tenantId) {
final boolean tenantExists = tenantDirectory.tenantExists(tenantId);
if (!tenantExists) {
log.warn("No tenant found for id '{}'", tenantId);
throw ModuleStatusApiException.unknownTenant(tenantId);
}
}
这段代码看似简单,但却融合了三部分含义:
- Infrastructure:具体的技术控制,如
InternalDriver.controlRouting。 - Outbound Port:调用外部依赖,如
tenantDirectory.tenantExists。 - Domain:业务规则,即租户必须存在
if(!tenantExists)。
我们需要对它进行拆分。
首先剥离出 Domain 逻辑。作为 Domain,它不应知道如何查询,也不应该清楚日志记录,不对外部有依赖且不了解技术栈,只知道 ” 如果不存在,就报错 “。按照这个思路,简化后的逻辑是:
package com.example.modulesystem.domain.service;
public class TenantValidator {
public void guardTenantExists(final TenantId tenantId, final boolean exists) {
if (!exists){
throw new TenantNotFoundDomainException(tenantId);
}
}
}
以及这里需要的 Exception:
package com.example.modulesystem.domain.exception;
public class TenantNotFoundDomainException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final TenantId tenantId;
public TenantNotFoundDomainException(final TenantId tenantId) {
super("No tenant found for TenantId: "+tenantId.toString());
this.tenantId = tenantId;
}
public TenantId getTenantId() {
return tenantId;
}
}
应该专门为 Domain 创建一个特定的 Exception,之后在 Use Case 中捕获这个 Exception。这样做的目的是为了让 Domain 能够与外界解耦。通俗地理解就是,即便删除 Application 和 Infrastructure 部分,IDE 依旧不会报错,编译也能通过。这就是我们熟知的依赖管理——Domain 不应该依赖外部世界。
接下来我们向外拓展,创建 Use Case 需要的外部能力,即 TenantDirectory。它可以将 exists 的 boolean 值传递进内部系统,最后交给 Domain 做判断。为此,我们需要在 Application 中定义一个 Outbound Port。这个 Port 应该由内部的 Application 定义,而不是外部。
package com.example.modulesystem.application.port.outbound;
public interface TenantDirectoryPort {
boolean tenantExists(TenantId tenantId);
}
实现的 Adapter 位于最外层的 Infrastructure。它依赖内部定义的 Port 并引入外部库,两者共同实现内部所需要的功能。
package com.example.modulesystem.infrastructure.adapter.outbound;
public class TenantDirectoryAdapter implements TenantDirectoryPort {
private final TenantDirectory tenantDirectory;
public TenantDirectoryAdapter(final TenantDirectory tenantDirectory) {
this.tenantDirectory = tenantDirectory;
}
@Override
public boolean tenantExists(final TenantId tenantId) {
// 模拟技术细节实现
try (var rc = InternalDriver.controlRouting(
InternalDriver.READ_ONLY, getClass().getName())) {
return tenantDirectory.tenantExists(tenantId);
}
}
}
回顾先前的代码,发现还有一个日志逻辑:log.warn("No tenant found for id '{}'", tenantId);。它应该被放在哪里?因为 Domain 只会表达核心的规则(即规则不满足就失败),不会关心具体的记录,所以日志可以交给 Use Case 或 Infrastructure。
这里我将其理解为记录外部系统的状态,所以在 Infrastructure 中加入 log.debug("Checked tenant existence for ID {}: {}", tenantId, exists); 以作为技术日志。
或者你也可以选择在 Use Case 中加入。
该步骤在 Use Case 中的完整代码是:
// Step 2 UseCase
final boolean exists = tenantDirectoryPort.tenantExists(tenantId);
try{
tenantValidator.guardTenantExists(tenantId, exists);
} catch (final TenantNotFoundDomainException ex) {
log.warn("No tenant found for id '{}'", tenantId);
throw ModuleStatusApiException.unknownTenant(tenantId);
}
步骤三
首先看原来的代码,它分为两个部分:先获取 RegistryModule,然后调用 guardModuleExists 与输入参数比较。
final List<RegistryModule> registryModules = lookupModulesForRegistry(tenantId, getContext());
guardModuleExists(moduleStatusModel, registryModules);
要注意的是,我们引入了外部的模型 RegistryModule。为了不引入与 Domain 无关的信息,或者说避免 ” 污染 ” 系统,首先要定义一个专属于 Domain 的数据类型。
package com.example.modulesystem.domain.model;
public record EnterpriseModuleStatus(@NonNull ModuleId moduleId, @NonNull boolean isEnterprise) {
}
然后我们需要一个 Mapper,能将外部的 RegistryModule 转化为内部数据模型。
我把它放进 infrastructure.outbound 中,因为它代表的是将外部数据转换至内部数据,该转换发生在 Infrastructure -> Application 的边缘位置。我理解的边缘,类似于 Return 位置或是方法入参位置。
package com.example.modulesystem.infrastructure.adapter.outbound.mapper;
public class RegistryModuleToEnterpriseModuleStatusMapper {
public static List<EnterpriseModuleStatus> toDomain(final List<RegistryModule> registryModules) {
return registryModules
.stream()
.map(module -> new EnterpriseModuleStatus(
ModuleId.of(module.getModuleId()),
hasEnterpriseTag(module)
)).toList();
}
private static boolean hasEnterpriseTag(final RegistryModule module) {
return module.getTags().stream().anyMatch(tag -> tag.equalsIgnoreCase(ModuleTag.IS_ENTERPRISE.value()));
}
}
有了内部的数据类型和转换器,我们可以在 Application 的 Outbound 中创建一个获取 EnterpriseModuleStatus(或说 RegistryModule)的 Port,专门供 Use Case 使用。而这个 Port 的实现,因为不是 Application 关注的重点,可以一股脑地全部放进 Infrastructure 的 Adapter 中。
这也就是人们常说的依赖倒置(Dependency Inversion),即高层模块不依赖外部实现的 Infrastructure,而只依赖抽象 Port,但是 Infrastructure 必须依赖 Port。
我们首先定义一个 Port:
package com.example.modulesystem.application.port.outbound;
public interface RegistryModulePort {
List<EnterpriseModuleStatus> findByTenantId(TenantId tenantId);
}
然后实现该 Interface 的 Adapter,其中的大部分代码可以直接复制。你不必阅读完整的细节,但可以特地看看方法最后的转换部分,它将外部的 RegistryModule 转换成了 EnterpriseModuleStatus。
package com.example.modulesystem.infrastructure.adapter.outbound;
@Slf4j
public class RegistryModuleAdapter implements RegistryModulePort {
private final EventService eventService;
private final RegistryLookup registryLookup;
private final RegistryModuleService registryModuleService;
public RegistryModuleAdapter(
final EventService eventService,
final RegistryLookup registryLookup,
final RegistryModuleService registryModuleService){
this.eventService = eventService;
this.registryLookup = registryLookup;
this.registryModuleService = registryModuleService;
}
// 省略具体的上下文构建方法…
@Override
public List<EnterpriseModuleStatus> findByTenantId(final TenantId tenantId) {
// 模拟上下文
final var context = new Object();
log.debug("Fetching RegistryModules for TenantId={}", tenantId);
final List<RegistryInfo> registries = registryLookup.getRegistriesFor(tenantId, null, context);
if (registries.isEmpty()) {
return Collections.emptyList();
}
final List<RegistryModule> associatedModules = new ArrayList<>();
for (final RegistryInfo registry : registries) {
final Optional<RegistryModules> modulesFromCache = Optional.ofNullable(
registryModuleService.findUnfilteredByRegistryIdCached(registry.getRegistryId()));
final RegistryModules modules = modulesFromCache.orElseGet(
() -> registryModuleService.findUnfilteredByRegistryId(registry.getRegistryId()));
if (modules != null) {
associatedModules.addAll(modules.getAssociatedModules());
} else {
log.debug("No Modules for registry '{}' found", registry.getRegistryId());
}
}
return RegistryModuleToEnterpriseModuleStatusMapper.toDomain(associatedModules);
}
}
有了 Port,也有了实际的 Adapter,终于可以开始搭建 Use Case 和 Domain 的逻辑了。核心逻辑是:我们需要确保输入模块和已经存在的模块相互匹配,如果模块根本不存在,则直接抛出错误。
package com.example.modulesystem.domain.service;
public class ModuleGuard {
public void guardModuleExists(
final TenantModuleStatuses tenantModuleStatuses,
final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses) {
final List<String> moduleIds = enterpriseModuleStatuses.stream()
.map(moduleIsEnterprise -> moduleIsEnterprise.moduleId().toString())
.toList();
final List<String> missingIds = tenantModuleStatuses.tenantModuleStatus().stream().map(
dfs -> dfs.moduleId().toString().toLowerCase()
).filter(dfs -> !moduleIds.contains(dfs)).toList();
if(moduleIds.isEmpty() || !missingIds.isEmpty()) {
throw new ModuleNotFoundDomainException(tenantModuleStatuses.tenantId(), missingIds);
}
}
}
最后 Use Case 使用这个逻辑:
final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
try {
moduleGuard.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
} catch (final ModuleNotFoundDomainException ex) {
throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
}
同样,对于 Exception,只需要在它们所属的层定义:
ModuleNotFoundDomainException位于domain.exception中。ModuleStatusApiException位于application.exception中。
简单总结一下步骤三的流程:
- 定义 Domain 模型和 Mapper,确保数据格式正确。
- 定义 Port 和 Adapter,确保外部数据可以进入。
- 准备 Domain 逻辑并加入 Use Case。
步骤四
这一步我们要判断模块的类别是否属于 Enterprise。可以看到原始代码严重依赖外部数据结构:
private static void guardModuleIsEnterprise(
final TenantModuleStatusesModel moduleStatusModel,
final Collection<RegistryModule> registryModules) {
final List<RegistryModule> enterpriseModules = registryModules.stream()
.filter(cf -> cf.getTags().stream()
.anyMatch(tag -> tag.equalsIgnoreCase(ModuleTag.IS_ENTERPRISE.value())))
.toList();;
// Filter Request for any non-Enterprise modules
final List<String> nonEnterpriseModules = moduleStatusModel.moduleStatusModel()
.stream()
.map(fs -> fs.moduleId().toString().toLowerCase())
.filter(fid -> enterpriseModules.stream()
.map(pf -> pf.getModuleId().toLowerCase())
.noneMatch(fid::equals))
.toList();
if (!nonEnterpriseModules.isEmpty()) {
final TenantId tenantId = moduleStatusModel.tenantId();
throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, nonEnterpriseModules);
}
}
得益于我们在步骤三中已经提前构建好了 Domain 的数据模型 EnterpriseModuleStatus,该步骤的逻辑可以大幅度简化,结构也十分清晰。
package com.example.modulesystem.domain.service;
public class EnterpriseModuleGuard {
public static void guardModuleIsEnterprise(
final TenantModuleStatuses tenantModuleStatuses,
final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses) {
final List<String> enterpriseModules = enterpriseModuleStatuses
.stream()
.filter(EnterpriseModuleStatus::isEnterprise)
.map(module -> module.moduleId().toString())
.toList();
final List<String> nonEnterpriseModules = tenantModuleStatuses
.tenantModuleStatus()
.stream()
.map(dfs -> dfs.moduleId().toString().toLowerCase())
.filter(fid -> enterpriseModuleStatuses.stream()
.map(pfs -> pfs.moduleId().toString())
.noneMatch(fid::equals)).toList();
if (!nonEnterpriseModules.isEmpty()) {
final TenantId tenantId = tenantModuleStatuses.tenantId();
throw new EnterpriseModuleNotFoundDomainException(tenantId, nonEnterpriseModules);
}
}
}
步骤五
当输入的数据经过检查确认无误后,只需要经过简单的转换,就可以准备存储到系统的数据库中。由于是 Domain 内部的数据转换,我们直接将 Mapper 放在 Domain 内部来处理,新建一个 Mapper 即可,等待后续 Use Case 调用。
package com.example.modulesystem.domain.mapper;
public class EnterpriseModuleToStatusMapper {
public TenantModuleStatuses toTenantModuleStatuses(
final TenantModuleStatuses tenantModuleStatuses,
final Collection<EnterpriseModuleStatus> enterpriseModuleStatuses
) {
final Map<String, String> mappedModuleIds = enterpriseModuleStatuses
.stream()
.collect(Collectors.toMap(
module -> module.moduleId().toString().toLowerCase(),
module -> module.moduleId().toString(),
(existingValue, newValue) -> existingValue));
final List<TenantModuleStatus> correctlyCapitalizedModuleId =
tenantModuleStatuses.tenantModuleStatus()
.stream()
.map(dfs -> {
final String moduleId = mappedModuleIds.get(dfs.moduleId().toString().toLowerCase());
return new TenantModuleStatus(ModuleId.of(moduleId), dfs.isActive());
}).toList();
return new TenantModuleStatuses(tenantModuleStatuses.tenantId(),
correctlyCapitalizedModuleId);
}
}
在 Use Case 中,转换并存储。
final boolean statusHasChanged = moduleStatusRepositoryPort.save(enterpriseModuleToStatusMapper.toTenantModuleStatuses(tenantModuleStatuses,enterpriseModuleStatuses));
步骤六
最后一步很简单,触发外部系统的方法让 Cache 失效。类似步骤二,只需要创建 Port 和 Adapter。
package com.example.modulesystem.application.port.outbound;
public interface EventServicePort {
void invalidateModuleCacheForTenants(List<String> tenantIds);
}
package com.example.modulesystem.infrastructure.adapter.outbound;
public class EventServiceAdapter implements EventServicePort {
private final EventService eventService;
public EventServiceAdapter(final EventService eventService) {
this.eventService = eventService;
}
@Override
public void invalidateModuleCacheForTenants(final List<String> tenantIds) {
eventService.invalidateCacheForTenants(tenantIds);
}
}
合并与整理
终于,将所有的步骤整合进来,我们得到了最终的 Use Case。
package com.example.modulesystem.application.usecase;
@Slf4j
public class UpdateModuleStatus {
// outbound port
private final TenantDirectoryPort tenantDirectoryPort;
private final RegistryModulePort registryModulePort;
private final ModuleStatusRepositoryPort moduleStatusRepositoryPort;
private final EventServicePort eventServicePort;
// domain service
private final TenantValidator tenantValidator;
private final ModuleGuard moduleGuard;
private final EnterpriseModuleGuard enterpriseModuleGuard;
private final EnterpriseModuleToStatusMapper enterpriseModuleToStatusMapper;
public UpdateModuleStatus(final TenantDirectoryPort tenantDirectoryPort,
final RegistryModulePort registryModulePort,
final ModuleStatusRepositoryPort moduleStatusRepositoryPort,
final EventServicePort eventServicePort,
final TenantValidator tenantValidator,
final ModuleGuard moduleGuard,
final EnterpriseModuleGuard enterpriseModuleGuard,
final EnterpriseModuleToStatusMapper enterpriseModuleToStatusMapper) {
this.tenantDirectoryPort = tenantDirectoryPort;
this.registryModulePort = registryModulePort;
this.moduleStatusRepositoryPort = moduleStatusRepositoryPort;
this.eventServicePort = eventServicePort;
this.tenantValidator = tenantValidator;
this.moduleGuard = moduleGuard;
this.enterpriseModuleGuard = enterpriseModuleGuard;
this.enterpriseModuleToStatusMapper = enterpriseModuleToStatusMapper;
}
public void handle(final TenantModuleStatuses tenantModuleStatuses) {
final TenantId tenantId = tenantModuleStatuses.tenantId();
// Step 2
final boolean exists = tenantDirectoryPort.tenantExists(tenantId);
try {
tenantValidator.guardTenantExists(tenantId, exists);
} catch (final TenantNotFoundDomainException ex) {
log.warn("No tenant found for id '{}'", tenantId);
throw ModuleStatusApiException.unknownTenant(tenantId);
}
// Step 3
final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
try {
moduleGuard.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
} catch (final ModuleNotFoundDomainException ex) {
throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
}
// Step 4.
try {
enterpriseModuleGuard.guardModuleIsEnterprise(tenantModuleStatuses, enterpriseModuleStatuses);
} catch (final ModuleNotFoundDomainException ex) {
throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, ex.getMissingModuleIds());
}
// Step. 5
final boolean statusHasChanged = moduleStatusRepositoryPort.save(
enterpriseModuleToStatusMapper.toTenantModuleStatuses(tenantModuleStatuses,enterpriseModuleStatuses));
if (statusHasChanged) {
log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
eventServicePort.invalidateModuleCacheForTenants(List.of(tenantId.toString()));
}
}
}
当然,这还算不上最简洁的形式,我们还应对它进行进一步简化。
首先整合 Catch 块。其次,我们可以假定所有的 Domain Services 边界都在 ModuleStatusService 中。经过这样的整理,神奇的事情发生了——它竟然和我们最初看到的逻辑十分接近!并且在外形上,非常像经典的、依赖于大 Service 的 DDD 模型。
简化后的代码如下:
package com.example.modulesystem.application.usecase;
@Slf4j
public class UpdateModuleStatus {
// outbound port
private final TenantDirectoryPort tenantDirectoryPort;
private final RegistryModulePort registryModulePort;
private final ModuleStatusRepositoryPort moduleStatusRepositoryPort;
private final EventServicePort eventServicePort;
// domain service
private final ModuleStatusService moduleStatusService;
public UpdateModuleStatus(final TenantDirectoryPort tenantDirectoryPort,
final RegistryModulePort registryModulePort,
final ModuleStatusRepositoryPort moduleStatusRepositoryPort,
final EventServicePort eventServicePort,
final ModuleStatusService moduleStatusService
) {
this.tenantDirectoryPort = tenantDirectoryPort;
this.registryModulePort = registryModulePort;
this.moduleStatusRepositoryPort = moduleStatusRepositoryPort;
this.eventServicePort = eventServicePort;
this.moduleStatusService = moduleStatusService;
}
public void handle(final TenantModuleStatuses tenantModuleStatuses) {
final TenantId tenantId = tenantModuleStatuses.tenantId();
final boolean exists = tenantDirectoryPort.tenantExists(tenantId);
try {
moduleStatusService.guardTenantExists(tenantId, exists);
final List<EnterpriseModuleStatus> enterpriseModuleStatuses = registryModulePort.findByTenantId(tenantId);
moduleStatusService.guardModuleExists(tenantModuleStatuses, enterpriseModuleStatuses);
moduleStatusService.guardModuleIsEnterprise(tenantModuleStatuses, enterpriseModuleStatuses);
final boolean statusHasChanged = moduleStatusRepositoryPort.save(
moduleStatusService.toTenantModuleStatuses(tenantModuleStatuses, enterpriseModuleStatuses));
if (statusHasChanged) {
log.debug("Invalidating ModuleCache for TenantId={}", tenantId);
eventServicePort.invalidateModuleCacheForTenants(List.of(tenantId.toString()));
}
} catch (final TenantNotFoundDomainException ex) {
log.warn("No tenant found for id '{}'", tenantId);
throw ModuleStatusApiException.unknownTenant(tenantId);
} catch (final ModuleNotFoundDomainException ex) {
throw ModuleStatusApiException.noModuleForRegistryFound(tenantId, ex.getMissingModuleIds().toString());
} catch (final EnterpriseModuleNotFoundDomainException ex) {
throw ModuleStatusApiException.nonEnterpriseModuleProvided(tenantId, ex.getMissingModuleIds());
}
}
}
最后我还注意到,这个 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.modulesystem.application.port.inbound;
public interface ModuleStatusQueryPort {
TenantModuleStatuses apply(@NonNull final TenantId tenantId);
}
第二种方式稍微复杂一些。HTTP 请求属于一种技术实现,因此完全可以将它放进 Infrastructure 的 Inbound Adapter 中。它可以直接调用 Use Case 中的实现,因为它的职责就是处理边界的数据转换,并连接内部系统。
代码如下:
package com.example.modulesystem.infrastructure.adapter.inbound.rest;
@Slf4j
@Path("/")
@Component
public class EnterpriseModuleStatusResource {
private final UpdateModuleStatus updateModuleStatus;
public EnterpriseModuleStatusResource(final UpdateModuleStatus updateModuleStatus) {
this.updateModuleStatus = updateModuleStatus;
}
@PUT
@Path("tenants/{tenantId}/provisioned")
@Consumes({MediaType.APPLICATION_JSON})
public Response updateModuleStatusForTenants(
@PathParam("tenantId") final TenantId tenantId,
@Valid final TenantModuleStatusesRequest subscriptionStatusRequest) {
log.info("Updating module statuses for TenantId: {}", tenantId);
updateModuleStatus.handle(mapToDomainModel(tenantId, subscriptionStatusRequest.modules()));
log.debug("Successfully updated module statuses");
return Response.ok().build();
}
private static TenantModuleStatuses mapToDomainModel(final TenantId tenantId,
final Map<String, Boolean> moduleStatusList) {
final List<TenantModuleStatus> tenantModuleStatusModels = new ArrayList<>();
moduleStatusList.forEach((moduleId, isActive) ->
tenantModuleStatusModels.add(new TenantModuleStatus(ModuleId.of(moduleId),
isActive)));
return new TenantModuleStatuses(tenantId, tenantModuleStatusModels);
}
}
这里的 Mapper 是可选拆分,可以放在 Resource 中,也可以单独创建 Mapper。
值得注意的是 TenantModuleStatusesRequest,作为与外部世界的协议(HTTP),它不应该放在 Application 或 Domain 中,因为 Adapter 采用的技术可能会发生变化(例如不再是 HTTP 而是 Kafka),而 Use Case 不应受到任何影响。
package com.example.modulesystem.infrastructure.adapter.inbound.rest.dto;
public record TenantModuleStatusesRequest(Map<String, Boolean> modules) {
}
这是相关的文件夹结构:
└── infrastructure
└── adapter
├── inbound
│ └── rest
│ ├── EnterpriseModuleStatusResource.java -> resource
│ └── dto
│ └── TenantModuleStatusesRequest.java -> DTO
└── outbound
├── mapper
│ └── RegistryModuleToEnterpriseModuleStatusMapper.java
├── RegistryModuleAdapter.java
├── EventServiceAdapter.java
└── TenantDirectoryAdapter.java
总结与尾声
再回顾一下我们搭建的地图:
.
├── application
│ ├── exception
│ │ └── ModuleStatusApiException.java
│ ├── port
│ │ ├── inbound
│ │ │ └── ModuleStatusQueryPort.java
│ │ └── outbound
│ │ ├── ModuleStatusRepositoryPort.java
│ │ ├── RegistryModulePort.java
│ │ ├── EventServicePort.java
│ │ └── TenantDirectoryPort.java
│ └── usecase
│ ├── ModuleStatusQueryUseCase.java
│ ├── ResetModuleStatusUseCase.java
│ └── UpdateModuleStatus.java
├── domain
│ ├── exception
│ │ ├── EnterpriseModuleNotFoundDomainException.java
│ │ ├── ModuleNotFoundDomainException.java
│ │ └── TenantNotFoundDomainException.java
│ ├── model
│ │ ├── EnterpriseModuleStatus.java
│ │ ├── TenantModuleStatuses.java
│ │ └── TenantModuleStatus.java
│ └── service
│ └── ModuleStatusService.java
└── infrastructure
└── adapter
├── inbound
│ └── rest
│ ├── EnterpriseModuleStatusResource.java
│ └── dto
│ └── TenantModuleStatusesRequest.java
└── outbound
├── mapper
│ └── RegistryModuleToEnterpriseModuleStatusMapper.java
├── RegistryModuleAdapter.java
├── EventServiceAdapter.java
└── TenantDirectoryAdapter.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 依旧长存,新结构迟迟不会出现,但它的存在却会帮助我在脑海中树立一种清晰的架构意识,帮助我梳理并归类旧有的逻辑。
免责声明: 代码示例已简化并通用化,仅用于教学目的,侧重于架构模式而非具体项目实现。