跳到正文

工程落地:分层、六边形、CQRS 与事件溯源 ​

一句话:落地的唯一硬规则是依赖方向——领域层不依赖基础设施,应用层编排用例,接口层和基础设施层做适配。它换来的是领域模型可以脱离框架测试,代价是每个外部能力都要先定义端口。

为什么需要 ​

最常见的「DDD 落地」是把包名改成 controller/service/domain/dao,依赖方向却没变:领域对象上有 @TableName,Service 直接 new 一个 Mapper,测试必须连数据库。这样做的后果很具体:

  • 换 ORM 或换存储,领域层跟着改;
  • 领域规则只能在 Spring 容器起来后测试,单测慢到没人跑;
  • 接口层的 DTO 一路传进领域层,外部字段名污染模型。

分层不是目的,让依赖指向稳定的领域模型才是。

核心机制 ​

正确的依赖方向 ​

编译期依赖只有三种:

  1. 接口层 → 应用层 → 领域层:入站适配器把 HTTP/消息转换成命令,调用应用服务。
  2. 基础设施层 → 领域层:出站适配器实现领域层定义的端口(仓储、事件发布、外部网关)。
  3. 领域层 → 无:不依赖 Spring、MyBatis、Jackson,也不依赖应用层和适配器。

应用层不 import 基础设施实现;运行时由组合根(Spring 配置)注入。领域层不依赖基础设施,不是「基础设施不能依赖领域层」——适配器当然要认识领域类型,箭头指向领域。

四层与六边形的对应 ​

四层六边形职责允许依赖
接口层入站适配器协议转换、参数校验、DTO 映射应用层
应用层用例/入站端口事务、权限、编排、幂等领域层
领域层领域核心实体、值对象、聚合、领域服务、端口定义无
基础设施层出站适配器仓储实现、消息发送、外部调用领域层(实现其端口)

六边形没有新增概念,只是把「上层/下层」换成「内/外」,强调技术实现可替换。四层画成「接口→应用→领域→基础设施」的竖直依赖是错的:基础设施不在领域下游,它在领域外面,依赖指向领域。

应用层可以用框架注解吗 ​

可以,但要有边界:

  • @Transactional、@PreAuthorize 放在应用服务上,表示用例的事务和权限边界;
  • @Service、@Component 也只在应用层和适配器层出现;
  • 领域层保持纯 Java,领域事件、仓储端口都是普通接口。

如果团队要求连应用层也不出现 Spring,可以定义 TransactionRunner 端口,由基础设施实现;代价是多一层间接。多数项目选择前者,关键是别让注解穿过领域层。

CQRS:读写分开建模 ​

写路径走聚合,保证不变量;读路径走为查询优化的模型(宽表、视图、搜索引擎),不经过聚合。适用条件:读写比例悬殊、复杂查询会拖垮聚合、需要独立扩缩容。

代价是两套模型和投影延迟。读模型由事件异步更新时,用户写完立刻查可能看不到;要么接受最终一致,要么在应用层做「写后读己」的补偿。

事件溯源:事件是唯一真相 ​

事件溯源(Event Sourcing)只追加事件,当前状态由事件重放得到。适合审计、账务、合规等「必须能解释每个状态怎么来的」场景。

必须配套三件事:

  • 快照:事件多了以后,定期存状态快照,重放从最近快照开始;
  • 事件版本化:老事件不能改,新增字段要给默认值或写升级器;
  • 删除与合规:事件不可变和「被遗忘权」冲突,敏感字段要加密或分离存储。

事件溯源不是「更高级的 CRUD」。没有审计和重放需求时,它只会增加投影、版本和运维成本。

实战场景 ​

一、领域层里出现了 OrderMapper:依赖方向倒转 ​

现象:Order 实体里为了“方便取明细”注入了 OrderLineMapper;RiskLevel 枚举里调了配置中心的 SDK。半年后想把该上下文的核心规则迁到新存储,改动波及 30 多个领域类;领域单元测试必须起 Spring 容器,跑一轮 4 分钟。

排查:依赖方向是唯一硬规则,一眼可验。

定位层取证据判定
领域层的 import在 domain/ 下 grep mapper、client、sdk、HttpServletRequest命中即方向倒转
仓储接口的定义位置OrderRepository 在 domain 还是 infra定义在 infra → 领域实现了基础设施
实体是否持有容器引用字段里是否有 ApplicationContext、@Autowired模型无法脱离框架构造
测试是否要起容器核心规则的测试是否 @SpringBootTest需要容器说明没有可独立运行的模型
用例编排位置事务、权限、事件发布是否在应用服务落在领域方法里会把基础设施语义带进模型

对症做法:

java
// 端口定义在领域层,只表达“需要什么能力”
public interface OrderRepository { Order find(OrderId id); void save(Order order); }
public interface FeatureFlags { boolean partialRescheduleEnabled(); }

// 适配器在基础设施层实现,领域对它的存在不知情
public final class MybatisOrderRepository implements OrderRepository { ... }

// 领域对象通过构造参数接收能力,不注入、不查库
public class Order {
    public void reschedule(List<LineChange> changes, FeatureFlags flags) {
        if (!flags.partialRescheduleEnabled()) throw new NotSupportedException();
        ...
    }
}
java
// 用一条架构测试把方向钉住,CI 拦截
@Test void domainMustNotDependOnInfrastructure() {
    Slingshot rule = noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAnyPackage("..infra..", "..mapper..", "..client..", "javax.servlet..");
    rule.check(importPackages("com.example.trade"));
}
text
应用层允许使用框架注解(@Transactional、@ApplicationService 这类),
但领域模型本身保持纯净:注解出现在编排处,不出现在实体与值对象里。

复盘要点:验收是两条可机械检查的指标:domain/ 下基础设施相关 import 数为 0;核心规则的测试不需要 Spring 容器(单条测试运行时间毫秒级)。迁移成本被推迟不等于被消除——依赖方向错了,换存储、换框架、做测试都要一并付费。

二、CQRS 的读模型滞后:提交成功但列表里没有 ​

现象:查询侧改用独立读模型(异步投影)。用户创建工单后立刻刷新列表,看不到刚创建的工单,重复提交 3 次产生 3 条记录;客服侧“数据丢了”的工单一周 20 余单(经验值,需校准)。投影延迟指标 P99 只有 300ms,看起来没问题。

排查:最终一致必须有“读己之写”的补偿设计。

定位层取证据判定
写后读的路径创建接口返回后,列表查询走的是读模型还是原库走读模型且无补偿 → 必然出现窗口
投影延迟分布看 P99 与最大,而非平均最大到秒级时用户一定能撞上
客户端行为是否有自动刷新/轮询在延迟窗口内触发刷新越早越容易看到旧数据
幂等是否存在重复提交是否被业务唯一键拦住无幂等 → 读滞后直接放大成重复数据
读写是否同库同库读写模型分离还是独立存储独立存储才有真正的滞后问题

对症做法:

java
// 1. 命令返回“刚写入的视图”,前端立即渲染,不依赖读模型
public record TicketView(long id, String title, Instant createdAt) {}
public TicketView create(CreateTicket cmd) {
    Ticket t = ticketRepo.save(new Ticket(cmd));
    projections.eventuallySync(t);            // 正常走投影
    return new TicketView(t.id(), t.title(), t.createdAt());   // 同步返回本次结果
}

// 2. 列表页对“自己刚创建的条目”做本地合并显示,读到读模型后再去掉
// 3. 强一致读路径直接查写库(低频、单条),列表与搜索继续走读模型
public Optional<Ticket> findByIdExact(long id) { return writeRepo.find(id); }

// 4. 幂等键防重复提交,读滞后不会变成重复数据
public void create(CreateTicket cmd) {           // cmd.clientRequestId 由前端生成
    if (processed.exists(cmd.clientRequestId())) return;
    ...
}
text
5. 读模型可以牺牲新鲜度,但不能牺牲“看起来没丢”:
   给用户可见的状态回执(受理号 + 处理中),把语义从“已发布”改成“已受理”。

复盘要点:判据是“写后 1 秒内读到自己数据的比例应达 100%”,靠命令返回值或写库直读保证,而不是靠把投影调快。拆分读写模型的收益是查询自由,代价是新鲜度与运维复杂度——这两项要在方案里写明,并配一条“读模型落后”的告警(投影位点与写位点差值)。

三、事件溯源用在了不需要它的地方 ​

现象:订单上下文整体改成事件溯源,事件表半年积累 4200 万条。三个症状同时出现:加载一个活跃订单要回放上千事件,详情接口 P99 1.2 秒;服务启动预热需重建热点对象,耗时 40 秒;OrderPlaced 事件从 v1 演进到 v3 后,历史事件反序列化失败,回放中断。

排查:事件是唯一真相这句话只在“需要完整历史”时成立。

定位层取证据判定
聚合事件数分布按聚合统计事件条数(P50/P99/最大)单聚合上千事件 → 必须有快照
加载耗时构成回放时间 vs 快照加载时间回放占大头 → 缺快照或事件过量
快照机制是否存在周期性快照及其频率无快照是性能问题的直接原因
事件版本演进事件里是否带 schemaVersion;有无 upcaster/迁移无版本字段 → 演进即破坏历史
谁真正需要历史审计/合规要求覆盖哪些上下文只有账务需要时,订单不需要 ES
存储与保留事件表体量、归档策略、查询方式只追加表无限增长要有分区与归档

对症做法:

java
// 1. 周期性快照:每 N 个事件落一次状态,加载从快照 + 增量事件开始
public class OrderState {
    long version;              // 已包含到第几个事件
    OrderSnapshot snapshot;    // 最近一次物化状态
}
public Order load(OrderId id) {
    SnapshotAt s = store.latestSnapshot(id);
    List<Event> tail = store.eventsAfter(id, s.version());
    return s.state().replay(tail);
}
// 快照频率按“事件数 × 平均回放成本”定,目标是加载事件数上限可控(例如 ≤ 200,经验值需校准)

// 2. 事件必须带版本,演进用 upcaster/迁移读,禁止修改历史事件的语义
public record Envelope(int schemaVersion, String type, JsonNode payload) {}
// v1→v3 的读侧适配:amount: string → Money 对象;缺字段按默认值补齐
text
3. 收缩使用范围:把事件溯源只留给真正需要完整历史与审计的上下文(账务、风控留痕),
   普通业务用“状态 + 变更日志/审计表”即可;
4. 事件表按聚合 ID + 版本建索引、按月分区、冷数据归档;
5. 启动预热改为按需加载 + 热点快照常驻,避免全量重建。

复盘要点:判据是“单聚合加载事件数”和“加载 P99”,两者都要有预算;事件 schema 演进要有回归用例(用 v1 样本事件在新代码上回放成功)。选型顺序和 05 篇一致:能用普通状态存储满足审计要求,就不要引入事件溯源——它的收益是可回放与完整历史,代价是查询自由度和演进成本,不要用 CQRS 来掩盖模型问题。

最小上手 ​

包结构(按限界上下文分包,再按层分):

text
com.example.order
├── interfaces/rest/OrderController.java
├── application/PayOrderUseCase.java
├── domain/
│   ├── Order.java
│   ├── OrderRepository.java      ← 端口
│   └── EventPublisher.java       ← 端口
└── infrastructure/
    ├── JdbcOrderRepository.java
    └── RocketMqEventPublisher.java

应用服务只依赖端口:

java
@Service
public class PayOrderUseCase {
    private final OrderRepository orders;
    private final EventPublisher events;
    private final Clock clock;

    @Transactional
    public void handle(PayOrderCommand cmd) {
        Order order = orders.findById(cmd.orderId()).orElseThrow();
        order.pay(clock.instant());
        orders.save(order);
        events.publish(order.pullEvents());
    }
}

用 ArchUnit 把规则变成测试(依赖 com.tngtech.archunit:archunit-junit5,版本以官方文档为准):

java
@AnalyzeClasses(packages = "com.example.order")
class ArchitectureTest {
    @ArchTest
    static final ArchRule domain_is_framework_free =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAnyPackage(
                "org.springframework..", "org.apache.ibatis..", "jakarta.persistence..",
                "..infrastructure..", "..interfaces..", "..application..");
}

运行:mvn -q test -Dtest=ArchitectureTest。规则失败时,报错会直接指出哪个领域类 import 了哪个框架类,比人工评审可靠。

生产动作 ​

  • 依赖检查进 CI:ArchUnit 规则和单测一起跑;新增违规直接失败,不靠口头约定。
  • 用例边界:一个应用服务方法一个事务;方法里只做编排,不写领域判断。rg -n "if \(.*status" src/main/java/**/application 出现状态判断,说明规则又漏回应用层了。
  • CQRS 投影延迟:监控读模型落后写模型的秒数(如 projection_lag_seconds);超过业务可接受窗口就告警。用户写后读的场景,要么读主库,要么在前端提示「处理中」。
  • 事件溯源重放:监控重放速率、快照命中率、事件版本分布;重放时间超过恢复目标(RTO)就补充分区或快照。
  • 排障:单测必须启动 Spring → 看测试依赖里是否有 @SpringBootTest → 领域测试应只 new 对象和 fake 端口;需要容器的是集成测试,分开跑。
  • 排障:换数据库改动传遍全项目 → rg -n "java.sql|org.apache.ibatis" src/main/java/**/domain → 领域层出现 SQL/ORM 类型,说明端口没有定义在领域层。

边界 ​

  • 模块化单体优先:一个进程内先按上下文分模块、按依赖规则约束,等团队和流量真的需要再拆服务。分层做对了,拆分才有基础。
  • CQRS 不是默认架构:简单 CRUD 用两套模型,维护成本翻倍而收益接近零。
  • 事件溯源只给核心域:审计、账务适合;配置、字典、报表不适合。
  • 六边形不是必选项:它只是依赖规则的另一种画法;没有端口和适配器的实际替换需求,画六边形不会让代码更好。
  • 「一个聚合一个事务」是默认值:跨聚合的长流程用 Saga 或流程引擎,不要用一个巨大的本地事务硬扛。

讲清楚 ​

工程落地只守一条:依赖指向领域。接口层把 HTTP 和消息转成命令,应用层编排用例、管事务和权限,领域层只放模型和端口,基础设施层实现仓储、消息、外部调用这些端口。领域层不依赖 Spring、MyBatis,所以它能用纯 JUnit 测试;应用层可以用 @Transactional,但规则不能漏回去。六边形是把这条规则画成内外两层,强调实现可替换。CQRS 把写模型和读模型分开,解决复杂查询拖垮聚合的问题,代价是投影延迟;事件溯源把事件当唯一真相,适合审计和账务,必须配快照、版本化和合规删除。先用 ArchUnit 把规则钉住,再谈架构风格。

初稿 2026-10 | 最后修订 2026-10

把一门技术讲透到既懂也会用