12.1 六边形架构:用端口保护业务核心
数据库、HTTP 和消息队列都不是业务本身。六边形架构用端口表达核心能够做什么、需要什么,再由适配器负责具体协议,让外部技术依赖指向核心。
“六边形”只是示意图,边数没有含义。真正重要的是应用内部与外部世界之间存在可测试的边界。
两类端口
端口通常分成两类:
- 驱动端口(inbound / primary):外部可以要求系统执行的用例;
- 被驱动端口(outbound / secondary):用例为了完成工作而需要的能力。
HTTP Controller ─┐
CLI Command ─────┼─> [驱动端口 | 应用与领域 | 被驱动端口] ─> PostgreSQL
Message Handler ─┘ ├─> Kafka
└─> Payment API图中箭头表示运行时请求流,不是源码依赖:例如 JDBC 适配器实现核心的仓储接口,源码依赖方向仍指向内。端口不一定是 Java interface,也可以是明确约定的消息协议或函数签名。
端口按业务语言命名。ForRegisteringEntry 比 EntryService 更能表达入口;TournamentRepository 比 DatabaseClient 更能表达核心需要的能力。
核心先定义契约
下面是 Java 21 项目片段,命令、ID、赛事与事件等领域类型由项目提供,各公开类型单独存放。本课命令使用 TournamentId,不同于上一课的 UUID 版本。驱动端口的契约还要求输入已通过认证/授权与字段校验;换成消息入口也要履行该契约。
public interface ForRegisteringEntry {
RegistrationResult register(RegisterEntryCommand command);
}
public interface TournamentRepository {
java.util.Optional<Tournament> findById(TournamentId id);
void save(Tournament tournament);
}
public interface EventOutbox {
void append(DomainEvent event);
}应用服务实现驱动端口,并依赖被驱动端口:
public final class RegisterEntryService implements ForRegisteringEntry {
private final TournamentRepository tournaments;
private final EventOutbox outbox;
public RegisterEntryService(TournamentRepository tournaments, EventOutbox outbox) {
this.tournaments = java.util.Objects.requireNonNull(tournaments);
this.outbox = java.util.Objects.requireNonNull(outbox);
}
@Override
public RegistrationResult register(RegisterEntryCommand command) {
Tournament tournament = tournaments.findById(command.tournamentId())
.orElseThrow(TournamentNotFound::new);
DomainEvent event = tournament.register(command.playerId());
tournaments.save(tournament);
outbox.append(event);
return RegistrationResult.accepted(tournament.remainingSlots());
}
}这段核心方法本身没有事务保证:save 成功而 append 失败时,若适配器各自自动提交,就会只留下报名记录。稍后的组合根会用外层事务装饰器解决同库原子性;这里的顺序还没有完成这件事。
核心没有导入 Spring、JPA、Kafka 或 HTTP 类型。是否用注解并非唯一判断标准,但核心若必须启动框架才能运行一个业务测试,边界通常已经变薄。
适配器负责翻译
驱动适配器把外部请求翻译成端口输入,并把结果翻译回协议:
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
final class EntryHttpAdapter {
private final ForRegisteringEntry registerEntry;
EntryHttpAdapter(ForRegisteringEntry registerEntry) {
this.registerEntry = java.util.Objects.requireNonNull(registerEntry);
}
@PostMapping("/tournaments/{id}/entries")
ResponseEntity<EntryResponse> register(
@PathVariable("id") UUID id,
@Valid @RequestBody EntryRequest request
) {
var result = registerEntry.register(
new RegisterEntryCommand(new TournamentId(id), request.playerId())
);
return ResponseEntity.ok(EntryResponse.from(result));
}
}HTTP 片段假设项目已有带约束的 EntryRequest、EntryResponse 映射、认证授权过滤/装饰器和异常处理器,Spring MVC 与 Bean Validation 也已配置。@Valid 不会自动验证请求没有声明的约束,更不能替代对象级权限。
被驱动适配器把核心契约翻译成技术操作。下面只展示 Spring JDBC 6.2 适配器的查询方法体,属于项目完整的 JdbcTournamentRepository;它仍需构造器注入 JdbcClient 并实现 save:
@Override
public java.util.Optional<Tournament> findById(TournamentId id) {
return jdbc.sql("select id, capacity, status from tournament where id = :id")
.param("id", id.value())
.query(TournamentRow.class)
.optional()
.map(TournamentMapper::toDomain);
}示例列名只用于说明映射,TournamentRow 与映射器由项目提供。真实聚合若需要已报名玩家、剩余名额或版本,必须一并加载;不能拿三列半成品冒充能维护全部不变式的赛事。optional() 约定零行返回空、多行报错,不是随便挑第一行。
映射代码阻止表结构、第三方字段和框架代理进入核心模型;同样要在这里把数据库异常翻译为端口约定的失败。
组合根负责把系统接起来
事务装饰器位于核心之外,直接使用 TransactionTemplate,无需通过注解代理启动事务:
public final class TransactionalRegistration implements ForRegisteringEntry {
private final ForRegisteringEntry target;
private final org.springframework.transaction.support.TransactionTemplate tx;
public TransactionalRegistration(ForRegisteringEntry target,
org.springframework.transaction.support.TransactionTemplate tx) {
this.target = java.util.Objects.requireNonNull(target);
this.tx = java.util.Objects.requireNonNull(tx);
}
@Override
public RegistrationResult register(RegisterEntryCommand command) {
return tx.execute(status -> java.util.Objects.requireNonNull(
target.register(command), "result"));
}
}下面是装配片段,dataSource、两个完整 JDBC 适配器及其表结构由项目提供:
var jdbc = org.springframework.jdbc.core.simple.JdbcClient.create(dataSource);
TournamentRepository repository = new JdbcTournamentRepository(jdbc);
EventOutbox outbox = new JdbcEventOutbox(jdbc);
ForRegisteringEntry core = new RegisterEntryService(repository, outbox);
var tx = new org.springframework.transaction.support.TransactionTemplate(
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
ForRegisteringEntry useCase = new TransactionalRegistration(core, tx);仓储和 Outbox 必须使用同一数据源下参与 Spring 事务的连接访问;仅传入同一个对象名并不约束适配器自行新开连接、开新事务或提前提交。同步 register() 正常完成后提交,运行时异常时回滚。多数据源或异步切线程不在这个本地事务承诺内。集成测试应故意让 Outbox 写入失败,确认报名写入也没有留下。
核心只知道接口,组合根知道全部具体实现。为了“纯净”而在业务代码里使用服务定位器,反而会隐藏依赖。
端口契约也要测试
替换适配器不等于适配器天然等价。仓储端口需要定义:
- 找不到对象时返回什么;
- 并发版本冲突如何表示;
- 保存后 ID 和版本如何变化;
- 查询是否包含已删除对象;
- 事务由调用者还是适配器控制。
同一套契约测试可以分别运行在内存适配器和真实数据库适配器上。内存实现若没有模拟唯一约束、版本冲突等重要语义,会让核心测试产生错误信心。
不要把所有对象都做成端口
端口应该对应稳定的系统边界或业务能力。给日志、时钟、随机数建立接口有时能提高可测试性;给每个类机械地创建一个接口,只会增加跳转和命名成本。
采用六边形架构前先问:
- 这个外部依赖是否会显著拖慢或阻碍核心测试?
- 它的协议或供应商是否可能变化?
- 核心是否需要定义自己的失败语义?
- 翻译层能否阻止外部模型污染业务模型?
下一课把六边形与洋葱、Clean Architecture 放在一起比较,并讨论如何让依赖规则不只停留在架构图上。
参考资料
Alistair Cockburn, Hexagonal Architecture
Martin Fowler, Dependency Inversion Principle