跳到内容

第 6 卷 / 第 12 章 / 第 1 课

12.1 六边形架构:用端口保护业务核心 ​

数据库、HTTP 和消息队列都不是业务本身。六边形架构用端口表达核心能够做什么、需要什么,再由适配器负责具体协议,让外部技术依赖指向核心。

“六边形”只是示意图,边数没有含义。真正重要的是应用内部与外部世界之间存在可测试的边界。

两类端口 ​

端口通常分成两类:

  • 驱动端口(inbound / primary):外部可以要求系统执行的用例;
  • 被驱动端口(outbound / secondary):用例为了完成工作而需要的能力。
text
HTTP Controller ─┐
CLI Command ─────┼─> [驱动端口 | 应用与领域 | 被驱动端口] ─> PostgreSQL
Message Handler ─┘                                  ├─> Kafka
                                                   └─> Payment API

图中箭头表示运行时请求流,不是源码依赖:例如 JDBC 适配器实现核心的仓储接口,源码依赖方向仍指向内。端口不一定是 Java interface,也可以是明确约定的消息协议或函数签名。

端口按业务语言命名。ForRegisteringEntry 比 EntryService 更能表达入口;TournamentRepository 比 DatabaseClient 更能表达核心需要的能力。

核心先定义契约 ​

下面是 Java 21 项目片段,命令、ID、赛事与事件等领域类型由项目提供,各公开类型单独存放。本课命令使用 TournamentId,不同于上一课的 UUID 版本。驱动端口的契约还要求输入已通过认证/授权与字段校验;换成消息入口也要履行该契约。

java
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);
}

应用服务实现驱动端口,并依赖被驱动端口:

java
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 类型。是否用注解并非唯一判断标准,但核心若必须启动框架才能运行一个业务测试,边界通常已经变薄。

适配器负责翻译 ​

驱动适配器把外部请求翻译成端口输入,并把结果翻译回协议:

java
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:

java
@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,无需通过注解代理启动事务:

java
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 适配器及其表结构由项目提供:

java
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 放在一起比较,并讨论如何让依赖规则不只停留在架构图上。

参考资料 ​

Built with VitePress | Software Systems Atlas