主题
工程落地:分层、六边形、CQRS 与事件溯源
一句话:落地的唯一硬规则是依赖方向——领域层不依赖基础设施,应用层编排用例,接口层和基础设施层做适配。它换来的是领域模型可以脱离框架测试,代价是每个外部能力都要先定义端口。
为什么需要
最常见的「DDD 落地」是把包名改成 controller/service/domain/dao,依赖方向却没变:领域对象上有 @TableName,Service 直接 new 一个 Mapper,测试必须连数据库。这样做的后果很具体:
- 换 ORM 或换存储,领域层跟着改;
- 领域规则只能在 Spring 容器起来后测试,单测慢到没人跑;
- 接口层的 DTO 一路传进领域层,外部字段名污染模型。
分层不是目的,让依赖指向稳定的领域模型才是。
核心机制
正确的依赖方向
编译期依赖只有三种:
- 接口层 → 应用层 → 领域层:入站适配器把 HTTP/消息转换成命令,调用应用服务。
- 基础设施层 → 领域层:出站适配器实现领域层定义的端口(仓储、事件发布、外部网关)。
- 领域层 → 无:不依赖 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