DDD 这套东西,讲过的人多,真正落到代码里的人少。过去几年我给不同团队做过不少次代码走查,几乎每次都会遇到同一个场面:设计文档里画着漂亮的聚合边界、领域事件流向,翻到代码里却是一堆只有 getter/setter 的 POJO,加上一个八百行的 Service 类,业务规则散落在四五个地方。问起来,对方说“我们也是按 DDD 分的包”。分个包当然不算 DDD,问题在于那些 DDD 的语义——谁是聚合根、谁是值对象、哪个类不能被外部直接 new、哪个仓储只允许聚合根持有——全部只存在于人的记忆和文档里,编译器完全不知道,新来的人也无从判断。jMolecules这个库解决的正是这件事:它用一组轻量的注解和标记接口,把这些只存在于口头约定中的概念变成代码里看得见、能被工具校验的契约,再配合架构注解和 ArchUnit、ByteBuddy 这类校验手段,让“架构腐化”从口头批评变成能挂 CI 的硬失败。这篇东西适合两类人看:一类是正在做 DDD 落地、卡在“模型写不出来”的团队;另一类是已经分好了包、但半年后发现边界全糊了、想找工具兜底的人。下面我按从概念到代码、从代码到校验、最后到踩坑的顺序,把这条链路完整走一遍。
1. DDD 落地的真正难点:语义写在文档里,代码里看不见
1.1 贫血模型不是技术问题,是语义无处安放
很多人把贫血模型当成一个编码习惯问题,觉得“我们团队就是懒得写行为方法”。实际情况比这复杂。一个刚接手项目的开发者,打开OrderService看到order.setStatus(CONFIRMED),他没有任何依据判断这行调用是否合法——状态机在哪?谁允许从PLACED直接跳到CONFIRMED?这个方法是不是应该只在某个特定场景下调用?没有约束,他就只能照抄前一行的写法,于是三个月后setStatus在十七个地方被调用,状态机彻底失效。
值对象是另一个重灾区。一个Money类,如果它只是个普通的可变 POJO,那么它被传进方法之后随时可能被改动,调用方拿到的引用可能在函数返回时已经面目全非。而如果它被定义成不可变类型,语义就清楚了:任何“修改”都是产生新实例,任何持有者都不必担心被下游篡改。这两种写法的差别,代码量差不多,但对阅读者的心智负担差了一个数量级。
问题的本质是:DDD 的一整套词汇——实体、值对象、聚合、聚合根、领域事件、仓储、工厂、领域服务——都是语义层面的约定,而 Java 这类语言没有原生的语法来表达它们。你能做的只有:写注释(没人看)、写文档(会过期)、靠 Code Review(靠人肉,会疲劳)。这三种手段的共同缺陷是不可自动验证,一旦团队规模上来或者人员流动,约束就必然退化。这也解释了为什么大量号称 DDD 的项目,最后退化成“按 DDD 分包的三层架构”。
所以真正要解决的问题不是“怎么写出聚合根”,而是“怎么让聚合根这个身份在代码里可识别、可校验、可传播”。识别是给人看的,校验是给工具看的,传播是给下一个接手的人看的。三个需求指向同一个答案:需要一个成本极低、几乎零侵入的标记机制。
1.2 jMolecules 的思路:把 DDD 词汇变成编译期可见的契约
jMolecules 的设计哲学非常克制,它不提供基类、不接管容器、不改变你的运行时行为,只做两件事:提供一组语义注解(如@AggregateRoot、@ValueObject、@DomainEvent)和一组可选的标记接口(如AggregateRoot<T, ID>、Identifier),再用架构注解声明包结构意图,最后用校验工具检查“声明”和“实际依赖”是否一致。
这个“不接管”非常关键。我见过不少团队为了表达 DDD 引入一个重量级框架,结果整个领域层被迫继承某个BaseAggregate,从此单元测试要先起容器,领域层和框架彻底绑死。jMolecules 走的是相反路线:注解本身没有运行时语义,加不加都不影响程序跑起来,它的价值全部集中在静态可分析性上——IDE 能高亮,ArchUnit 能读取,ByteBuddy 能拦截,新人打开类文件第一眼就看到@AggregateRoot,不需要去翻文档。
注意:正因为注解不带运行时语义,“加了注解”和“遵守了约束”是两件事。只加注解不接校验,得到的只是一堆装饰品,这一点在第四节会重点讲。
它的另一个隐性价值是沟通成本的降低。架构评审时争论“这个类到底算不算聚合根”,以前要靠画图争论半小时,现在可以指着代码说“它标了@AggregateRoot,那它必须自己维护不变量,外部不能直接改它的内部集合”。争议从主观判断变成了对规则的核对,这在跨团队协作里省下的时间远超引入成本。
1.3 与其他约束手段的横向对比
为了说明选型逻辑,我把常见的几种“约束架构”的手段摆在一起对比。这张表是我自己在几个项目里实际用过的组合,不是理论推演。
| 手段 | 能表达什么 | 能否自动校验 | 侵入性 | 主要短板 |
|---|---|---|---|---|
| 注释 + 设计文档 | 任意语义 | 否 | 无 | 必然过期,无人维护 |
| 只靠 Code Review | 任意语义 | 靠人 | 无 | 疲劳后失效,标准漂移 |
| 自定义注解 + 自研校验 | 自定义语义 | 可以 | 中 | 造轮子成本高,规则难复用 |
| 纯 ArchUnit 规则 | 包依赖、命名、注解 | 可以 | 低 | 领域语义要靠命名约定猜 |
| jMolecules + ArchUnit | DDD 语义 + 架构风格 | 可以 | 极低 | 需要团队理解注解含义 |
| 重量级 DDD 框架 | 语义 + 运行时支撑 | 部分 | 高 | 领域层与框架耦合 |
选 jMolecules 的核心理由是第四行和第五行的差距。纯 ArchUnit 当然也能写规则,比如“domain 包下的类不允许被 infrastructure 包依赖”,但这种规则是基于包名的,一旦有人把类挪到别的包,规则就失效了。而 jMolecules 的规则是基于注解语义的,比如“被@AggregateRoot标注的类,其字段类型如果也是聚合根,则必须是Association而不是直接引用”——这条规则和包结构无关,跟着类走,迁移重构时不会失效。
反过来说,如果你的团队连“哪些是聚合根”都还没达成共识,那加注解只会把混乱显性化。这种情况下正确的顺序是:先靠讨论和画图把边界定下来,再用注解固化,而不是指望注解帮你做设计决策。
2. 注解体系全景与依赖管理
2.1 DDD 语义注解:从标识符到聚合根
jMolecules 的 DDD 模块分两块:org.jmolecules.ddd.annotation包下是注解,org.jmolecules.ddd.types包下是接口。两者语义重叠,实践中建议二选一,不要混用,原因后面会讲(同名类冲突是个真实的坑)。
注解侧常用的有这几个:@Entity表示有生命周期和标识的领域对象;@ValueObject表示无标识、不可变、靠属性相等判断的对象;@AggregateRoot表示聚合的入口,是外部访问聚合内部状态的唯一通道;@Repository标注仓储接口;@Service标注领域服务。接口侧对应Entity<T, ID>、ValueObject、AggregateRoot<T, ID>、Identifier。
Identifier这个接口值得单独说。它是个空标记接口,作用是把“这个类型是某个聚合的标识”这件事显式化。实现它的通常是 record,因为标识天然不可变、按值相等。有了这个标记,你就能写出这样的规则:“所有@Repository接口的方法签名里,参数类型必须是实现了Identifier的类型或其集合”,从而杜绝findById(Long id)这种裸基本类型满天飞的情况。裸Long做 ID 最典型的坑是两个不同聚合的 ID 传反了,编译器一声不吭,上线后数据串了。强类型 ID 能在编译期挡掉这类错误,代价只是多写十几行 record。
关于@Identity注解:较新版本里提供了它,用来标注聚合根内部持有标识的那个字段,方便工具定位。如果你的版本里没有,用接口方式实现getId()效果一样。这一点不用纠结,检查一下依赖版本里有没有这个类就行。
2.2 领域事件与仓储的标注方式
领域事件在 jMolecules 里有独立的模块,核心注解是@DomainEvent,另外还有@ExternalEvent用来区分“只在限界上下文内部流转”和“要发布到外部中间件”的事件。这个区分很有用:内部事件可以走简单的进程内发布器,外部事件才需要 outbox、幂等、schema 演进那一整套。把两者混为一谈,最后往往是内部事件也被迫走消息队列,白背一份复杂度。
仓储的标注要注意一点:@Repository在 Spring 生态里已经有同名注解(org.springframework.stereotype.Repository),两个注解同时出现在一个类上会让人困惑。我的做法是:领域层的仓储接口用 jMolecules 的@Repository,基础设施层的实现类用 Spring 的@Repository。这样语义上刚好对得上——接口是领域概念,实现是技术组件。这个约定要在团队里说清楚,否则新人看到两个@Repository会以为写重了。
聚合之间的引用是另一个高频问题。聚合 A 需要引用聚合 B 时,正确做法是只持有 B 的 ID,而不是持有 B 的对象引用。jMolecules 提供了Association<T, ID>接口来表达这件事,它内部只存 ID,通过静态工厂方法从聚合实例创建。好处是显而易见的:持有对象引用会让你不知不觉在一个事务里跨聚合修改,事务边界失控,锁范围扩大,并发问题随之而来。用Association会在代码层面提醒你“这里只是张凭证,要用的时候得去仓储里捞”。
2.3 架构风格注解:分层、洋葱、六边形
架构注解放在包级别,写在package-info.java里。常用的三个:@LayeredArchitecture对应经典三层,@OnionArchitecture对应洋葱架构,@HexagonalArchitecture对应六边形(端口适配器)。它们的作用是告诉校验工具“这个包及其子包遵循哪种依赖方向”,从而让 ArchUnit 能自动推导出该有的依赖规则,不用你一条条手写。
以洋葱架构为例,依赖方向必须是“外层依赖内层”,领域模型在圆心,应用服务在外面一层,基础设施和接口层在最外。手写 ArchUnit 规则的话,你得枚举“application 不能依赖 infrastructure”“domain 不能依赖 application”等等十几条;用@OnionArchitecture标注根包之后,调一行JMoleculesArchitectureRules.ensureOnionArchitecture()就把这一整套推导出来了。
提示:架构注解要标在根包上,而且必须标在
package-info.java里,不是标在某个类上。我第一次用的时候标在了启动类上,校验规则一条都没生效,排查了半小时才发现是位置问题。
这三种风格选哪个,取决于团队习惯。我的经验是:新项目、领域逻辑复杂、需要长期演进,选洋葱或六边形;存量项目改造,选分层更平滑,因为大多数存量项目本来就是三层,只是依赖方向反了,用分层注解把现状描述出来,再逐步收紧规则,阻力最小。硬把一个三层项目改成六边形,通常是架构师的自我感动,业务方看不到任何收益。
2.4 依赖坐标与版本策略
jMolecules 按模块拆分,用 BOM 统一版本是最省事的方式。大致需要引这几类:DDD 注解与类型、事件模块、架构风格模块、校验模块(ArchUnit 或 ByteBuddy)、以及可选的框架集成模块。BOM 导入之后,具体模块不用写版本号。
<dependencyManagement> <dependencies> <dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-bom</artifactId> <version>${jmolecules.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>版本选择上有个务实的建议:不要追最新。这类“语义标注型”库的 API 相对稳定,但注解所在的包名在不同大版本之间调整过(比如事件注解从 DDD 模块挪到独立模块),如果你的项目已经在用,跨大版本升级时先跑一遍编译,把所有 import 报错的地方统一扫一遍,比读 changelog 快。生产环境建议锁死小版本,用 BOM 管理,升级当成一次独立的任务做,别混在业务迭代里。
另外提醒一句:校验相关的模块(ArchUnit、ByteBuddy)请放testscope。它们的职责是守住 CI,不该被打进产物体积里。
3. 从零落地一个订单域:可复现的完整实操
3.1 工程骨架与依赖配置
假设做的是一个下单场景,包结构按洋葱架构组织:domain放聚合、值对象、领域事件、仓储接口;application放应用服务,负责编排和事务;infrastructure放持久化实现;interfaces放对外的 HTTP 入口。这个结构不新鲜,但配合架构注解之后,它会从“约定”变成“规则”。
根包上声明架构风格:
// src/main/java/com/example/ordering/package-info.java @OnionArchitecture package com.example.ordering; import org.jmolecules.architecture.onion.simplified.OnionArchitecture;领域层再加一道 DDD 语义上的自我约束,声明这个包属于领域模型:
// src/main/java/com/example/ordering/domain/package-info.java @DomainLayer?这里要说明一下:不同版本提供的层注解命名不完全一致,有的用嵌套枚举,有的提供独立的层注解。稳妥的做法是先只标根包,跑通校验后再逐步细化到子包,这样出问题时排查范围小。我一般只在根包标架构注解,子包的约束交给 DDD 语义规则去管,两套规则各司其职,反而更清晰。
依赖部分除了 jMolecules 本身,还要加上 ArchUnit(校验用)和 JUnit 5。持久化按实际技术栈选,示例里用 JPA 的接口做演示,换成别的实现方式思路完全一样。
3.2 值对象与强类型标识符
先写标识符。用 record 实现Identifier,把校验放在紧凑构造器里:
package com.example.ordering.domain.model; import org.jmolecules.ddd.types.Identifier; import java.util.UUID; public record OrderId(UUID value) implements Identifier { public OrderId { if (value == null) { throw new IllegalArgumentException("订单标识不能为空"); } } public static OrderId newId() { return new OrderId(UUID.randomUUID()); } }再写金额值对象。这里有几个细节值得展开。第一,金额必须和币种绑定,BigDecimal单独存在是没有意义的,两个不同币种的金额相加是业务事故。第二,构造时就把精度收口,不要让3.14159这种东西流进账务逻辑。第三,所有运算返回新实例,绝不修改自身。
package com.example.ordering.domain.model; import org.jmolecules.ddd.types.ValueObject; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Currency; import java.util.Objects; public record Money(BigDecimal amount, Currency currency) implements ValueObject { public Money { Objects.requireNonNull(amount, "金额不能为空"); Objects.requireNonNull(currency, "币种不能为空"); if (amount.signum() < 0) { throw new IllegalArgumentException("金额不能为负"); } amount = amount.setScale(2, RoundingMode.UNNECESSARY); } public static Money of(String amount, String currencyCode) { return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode)); } public Money add(Money other) { requireSameCurrency(other); return new Money(this.amount.add(other.amount), this.currency); } public Money multiply(int quantity) { return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency); } private void requireSameCurrency(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("币种不一致,无法运算"); } } }注意:
setScale(2, RoundingMode.UNNECESSARY)在精度超出时会直接抛异常。这是刻意的——宁可让越界数据在构造时就炸掉,也不要在结算时静默四舍五入。如果业务上确实需要舍入,那应该在调用方明确指定舍入策略,而不是藏在构造器里。
用接口方式和用注解方式在这里有个取舍:实现了ValueObject接口的 record,校验工具能通过类型系统识别它;只用@ValueObject注解的类,工具通过注解识别。前者更强,因为类型信息编译期就有;后者更灵活,不要求类型系统配合。我的习惯是值对象和标识符用接口(它们本来就该是不可变类型,record 天然适配),聚合根用注解(因为聚合根通常需要继承某个基类或者实现多个接口,用注解更省事)。
3.3 聚合根与领域事件的编码细节
订单聚合是核心,这里要处理不变量、状态机、领域事件三件事。
package com.example.ordering.domain.model; import org.jmolecules.ddd.annotation.AggregateRoot; import java.util.ArrayList; import java.util.Collections; import java.util.List; @AggregateRoot public class Order { private final OrderId id; private final List<OrderLine> lines; private OrderStatus status; private final List<Object> events = new ArrayList<>(); private Order(OrderId id, List<OrderLine> lines) { if (lines == null || lines.isEmpty()) { throw new IllegalArgumentException("订单不能没有明细"); } this.id = id; this.lines = new ArrayList<>(lines); this.status = OrderStatus.PLACED; } public static Order place(OrderId id, List<OrderLine> lines) { Order order = new Order(id, lines); order.register(new OrderPlaced(id, order.total())); return order; } public void confirm() { if (status != OrderStatus.PLACED) { throw new IllegalStateException("只有待确认订单可以确认,当前状态:" + status); } this.status = OrderStatus.CONFIRMED; register(new OrderConfirmed(id, total())); } public Money total() { return lines.stream() .map(OrderLine::subtotal) .reduce(Money.of("0.00", "CNY"), Money::add); } public OrderId getId() { return id; } public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } public OrderStatus getStatus() { return status; } public List<Object> pullEvents() { List<Object> copy = List.copyOf(events); events.clear(); return copy; } private void register(Object event) { events.add(event); } }几个设计点解释一下。构造器私有,只能通过静态工厂place创建,这是为了保证“创建即合法”——不存在一个没有明细的订单实例在外面飘。confirm方法自己检查状态机,外部无法绕过,这就是把不变量放在聚合内部的意义。getLines返回不可变视图,外部拿到之后没法直接add篡改集合,想加明细必须走聚合暴露的方法(示例里省略了addLine,实际项目应该有,并且同样要校验状态)。
事件用List<Object>收集然后由应用服务拉取,这是最朴素的做法。有人会问为什么不在聚合里直接注入事件发布器。答案是聚合应该对框架零依赖,一旦注入发布器,聚合的单元测试就得 mock 一个发布器,而且事件发布时机和事务提交时机的耦合会变得很难控制。收集 + 拉取的模式把“什么时候发”的决定权交给了应用层,而应用层正好是管事务的地方。
事件本身的定义很简单,用 record 加注解:
package com.example.ordering.domain.event; import org.jmolecules.event.annotation.DomainEvent; @DomainEvent public record OrderConfirmed(OrderId orderId, Money total) {}提示:事件里不要放聚合对象本身,只放必要的值。事件可能被序列化、被异步处理、被持久化到 outbox 表,携带整个聚合对象会导致序列化失败或者数据快照过大。这是我在一个项目里踩过的坑——事件里塞了聚合引用,接消息队列的时候直接序列化栈溢出。
3.4 仓储接口与持久化适配
仓储接口定义在领域层,只暴露聚合需要的能力:
package com.example.ordering.domain.repository; import org.jmolecules.ddd.annotation.Repository; import java.util.Optional; @Repository public interface Orders { Optional<Order> findById(OrderId id); void save(Order order); void delete(Order order); }接口里刻意没有findAll、没有分页查询、没有复杂条件查询。这些属于查询职责,应该走独立的查询服务或者 CQRS 的读模型,塞进仓储接口只会让仓储变成一个万能 DAO,最后谁都能从里面捞出半个数据库。这个边界一旦破掉,聚合的加载范围就不可控了,典型的症状是“查一个订单顺手把用户、商品、库存全加载进来”,性能问题随之而来。
基础设施层的实现负责把领域模型转换成持久化结构。这里有个高频问题:JPA 要求实体有无参构造器、要求字段可写、要求集合是特定类型,这些要求和“聚合根构造器私有、字段 final、集合不可变”直接冲突。常见的解法是领域模型和 JPA 实体分成两套类,中间做映射,缺点是代码量翻倍;另一种解法是让领域模型自己兼任 JPA 实体,用protected无参构造器、字段不用 final 之类的手段妥协,缺点是领域模型的纯粹性受损。
我的选择是分两套,但只在聚合复杂的时候分。简单的聚合(字段少、无集合)直接兼任,用protected无参构造器满足 JPA,用@Access(AccessType.FIELD)避免属性访问器被滥用。复杂的聚合(有集合、有不变量需要保护)坚决分离,用映射器转换。判断标准很简单:如果为了迎合 JPA 需要在领域模型里加一堆“仅供框架使用”的空方法,那就该分离了。
3.5 应用服务与事务边界
应用服务是编排层,负责加载聚合、调用领域方法、保存、发布事件。它不应该包含业务规则。
package com.example.ordering.application; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class ConfirmOrderService { private final Orders orders; private final DomainEventPublisher publisher; public ConfirmOrderService(Orders orders, DomainEventPublisher publisher) { this.orders = orders; this.publisher = publisher; } @Transactional public void confirm(OrderId orderId) { Order order = orders.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); order.confirm(); orders.save(order); order.pullEvents().forEach(publisher::publish); } }这段代码里有几个容易出问题的地方。第一,事件在事务内发布,如果发布失败会回滚整个事务,这在同库同事务的场景下是合理的;但如果事件要发到外部中间件,就必须改成 outbox 模式,先把事件写进本地表,事务提交后再由后台任务投递。第二,@Transactional加在应用服务上,不要加到聚合或仓储上,事务边界应该由用例决定。第三,pullEvents要在保存之后调用,否则事件里的数据可能和持久化后的状态不一致。
再补一个细节:如果同一个用例需要跨多个聚合操作,比如下单同时扣库存,那就涉及分布式一致性问题了。jMolecules 的Association只能帮你把引用关系理清楚,一致性方案还是得自己选(Saga、TCC、本地消息表)。别指望注解能解决一致性问题。
3.6 接口层的最小暴露
接口层(Controller)只做参数转换和调用,不写业务判断:
@RestController @RequestMapping("/orders") public class OrderController { private final ConfirmOrderService confirmOrderService; public OrderController(ConfirmOrderService confirmOrderService) { this.confirmOrderService = confirmOrderService; } @PostMapping("/{id}/confirm") public ResponseEntity<Void> confirm(@PathVariable String id) { confirmOrderService.confirm(new OrderId(UUID.fromString(id))); return ResponseEntity.noContent().build(); } }注意这里直接把String转成了OrderId,没有在 Controller 里做“订单是否存在”“状态是否允许”这类判断。这些判断全部在领域层,Controller 只负责协议转换。这条纪律守住之后,接口层的代码会薄得惊人,而领域层会厚起来——这正是 DDD 想要的效果。
4. 让约束真正生效:校验、CI 与团队协作
4.1 ArchUnit 路线:把规则写进单元测试
Annontation 加上去了,但如果没人检查,三个月后必然有人写出一个infrastructure包直接依赖domain内部实现类的代码。ArchUnit 路线是把规则变成单元测试,跑不通过就构建失败。
package com.example.ordering; import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.jmolecules.archunit.JMoleculesArchitectureRules; import org.jmolecules.archunit.JMoleculesDddRules; import java.util.List; @AnalyzeClasses( packages = "com.example.ordering", importOptions = {ImportOption.DoNotIncludeTests.class} ) class ArchitectureTest { @ArchTest static final ArchRule onion = JMoleculesArchitectureRules.ensureOnionArchitecture(); @ArchTest static final List<ArchRule> ddd = JMoleculesDddRules.all(); }ensureOnionArchitecture()会根据@OnionArchitecture标注的包推导出依赖方向规则;JMoleculesDddRules.all()提供的是一组 DDD 语义规则,比如聚合根不应该被外部直接实例化、值对象应该不可变、仓储接口应该只依赖领域类型等等。实际用下来,这组规则会在第一次跑的时候给你一堆红色,别慌,这正是它的价值——它把你们团队积累了半年的架构债一次性列了出来。
处理这些红线的策略很重要。不要一次性全改,那不现实。我的做法是按包分批:先让domain包全绿(这通常是最容易的,因为领域层本来依赖就少),再处理application,最后处理infrastructure。每批修完就把对应的类加入白名单并写清楚原因,白名单控制在十行以内,超出就说明这批改动太大,该拆分任务了。
4.2 ByteBuddy 路线:类加载时直接拦截
ArchUnit 的缺点是它只在测试阶段跑,如果有人本地不跑测试直接提交,问题就漏过去了。ByteBuddy 路线更狠一些,它在类加载时校验,违规直接抛异常,相当于给 JVM 装了个守门员。
使用方式是通过 JVM agent 把校验插件挂上去,在 surefire 或 failsafe 的argLine里加上 agent 参数。这样一来,任何加载domain包的测试都会先过一遍校验,跑不过测试直接失败,根本走不到断言那一步。
这条路线和 ArchUnit 不是二选一,我是两条都用:ArchUnit 负责覆盖更广的规则(它能检查方法签名、注解组合、继承关系),ByteBuddy 负责最硬的那几条红线(依赖方向、聚合边界)。原因是 ByteBuddy 的拦截更实时但规则表达力弱一些,ArchUnit 反过来。两条腿走路,成本是多维护一份配置,收益是防线更密。
注意:ByteBuddy agent 会影响类加载性能,不要在生产环境挂。只在测试阶段的 surefire 配置里加,这一点务必在 pom 里写清楚注释,否则下一个人复制配置的时候很容易复制到正式部署脚本里。
4.3 CI 门禁与增量迁移策略
规则写好了,但真正决定成败的是它有没有被挂到 CI 上。我的经验是:架构测试必须和单元测试同级,跑在同一个 job 里,不允许单独跳过。如果 CI 允许-DskipTests合并代码,那这套东西等于没装。
存量项目迁移要用增量策略。具体做法是给已有的违规代码建一个基线文件(ArchUnit 支持冻结违规),新代码不允许产生新违规,老代码逐步还债。这个机制的关键是基线只减不增,每次迭代要求基线条目数下降,否则过两个月基线会长得比代码还快。
还有一个组织层面的技巧:把架构测试的失败信息翻译成人话。ArchUnit 的默认报错会告诉你“类 A 依赖了类 B”,但新人看不懂为什么这不允许。在测试类里加一段注释,写清楚“domain 不能依赖 infrastructure,因为领域模型要保持对持久化技术的无知”,比任何文档都有效。这件事花十分钟,能省掉后面无数次会议。
5. 踩坑记录:常见问题与排查速查
5.1 注解与接口同名、与 JPA 打架
第一个坑是类名冲突。org.jmolecules.ddd.annotation.AggregateRoot和org.jmolecules.ddd.types.AggregateRoot同名,如果你既想加注解又想实现接口,import 会直接冲突。解决办法只有两个:只选一种方式,或者用全限定名。我建议只选一种,混用会让代码风格分裂,而且校验规则里对两种方式的处理路径不完全一样,排查问题时容易绕晕。
第二个坑是和 JPA 的@Entity冲突。JPA 的@Entity要求类有无参构造器、不能是 final、字段可写;而领域模型希望构造器私有、字段 final。这两个诉求在同一个类上无法同时满足,只能分离模型或者妥协。前面 3.4 节讲过取舍标准,这里补一个实操建议:如果选择分离,映射代码尽量用 MapStruct 这类工具生成,手写映射器在字段增加时极容易漏字段,而且漏了不会报错,只会静默丢数据。
第三个坑是@Repository双注解问题。前面提过,领域接口用 jMolecules 的,实现类用 Spring 的。如果你的项目里两个注解都加在接口上,Spring 会把接口也当作组件扫描候选,某些配置下会报“找不到实现”的错误,排查起来很费时间。
5.2 事件发布时机与事务一致性
事件发布时机是个经典陷阱。在事务内发布,好处是简单,坏处是如果事件处理涉及外部系统,事务回滚时外部系统已经收到消息了,产生不一致。在事务提交后发布(用TransactionSynchronization或者@TransactionalEventListener),好处是一致性更好,坏处是发布失败无法回滚业务。
我的经验规则是:进程内监听用事务内发布(简单直接);跨进程通信用 outbox 表(可靠);对实时性要求极高且能容忍偶尔不一致的用提交后发布。这三条不是教条,具体选哪个要看业务对一致性的要求,但一定要在团队里明确写下来,否则每个人按自己理解写,最后系统里三种模式并存。
还有一个细节:聚合里收集的事件列表,在保存失败重试的时候要清空,否则重试会把事件重复发布。这个 bug 很隐蔽,通常在生产环境的高并发场景才暴露,测试环境几乎复现不了。防御方式是在pullEvents之后立刻清理,并且保证调用它的代码只有一处。
5.3 常见报错速查表
下面这张表是我和几个团队实际遇到过的问题汇总,按“现象—原因—处理”组织:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 架构测试全绿但依赖仍然混乱 | 架构注解没标在根包的 package-info 上 | 检查注解位置,确认包扫描路径包含根包 |
| 加了 @AggregateRoot 但校验不生效 | 只引了注解模块没引校验模块 | 补 ArchUnit 或 ByteBuddy 依赖,确认 test scope |
| 编译报 AggregateRoot 冲突 | 注解和接口同名类同时 import | 只保留一种方式,或用全限定名 |
| 领域层编译依赖了 Spring | 聚合里注入了容器组件 | 改成方法参数传入或事件收集模式 |
| 事件重复发布 | pullEvents 后未清理或被多次调用 | 保证单点调用,发布后立即清空 |
| JPA 保存后集合字段丢失 | 领域模型与持久化模型映射漏字段 | 用映射工具生成,加字段级测试 |
| 强类型 ID 序列化失败 | record 缺少反序列化支持 | 配置对应序列化模块或加自定义转换器 |
| 校验通过但运行时报错 | 注解无运行时语义,规则只管静态结构 | 业务不变量必须在代码里显式校验 |
最后一行值得多说一句。有些人误以为加了@ValueObject就自动不可变了,其实注解什么也不做,你的类该可变还是可变。注解的价值在于可分析性,不可变性得靠final字段、record、私有构造器这些语言特性来保证。这两者要配合使用,不能互相替代。
6. 我在几个项目里跑下来的一些取舍
关于这套东西值不值得引入,我的判断标准很朴素:团队超过五个人,或者项目预期维护超过两年,就值得。人少、短平快的项目,加这层约束的收益覆盖不了学习成本。这不是技术优劣问题,是投入产出问题。
实际推进的时候,最容易失败的方式是一上来就全量铺开——所有包都加注解、所有规则都打开、CI 直接卡死。结果是一堆红色报错没人敢动,最后整个机制被注释掉,从此再也没人提。有效的做法是找一个小而完整的限界上下文做试点,把它做成标杆,让别人看到“这样写确实更清楚”,然后再推广。推广的时候不要开会讲理论,直接给人看代码对比:同一个功能,改造前和改造后的类结构、依赖关系、测试数量,摆在一起,说服力比任何 PPT 都强。
还有一个我自己的习惯:把架构测试的失败信息当成新人培训材料。每次有新人提交代码被架构测试拦下来,我不直接告诉他怎么改,而是让他自己去读测试类里的注释,理解为什么这条规则存在。这个过程比任何文档都记得牢。半年下来,团队里对“为什么 domain 不能依赖 infrastructure”的理解深度,明显好于纯靠宣讲的团队。
至于后续能扩展的方向,比较实际的有两个:一是把领域事件和 outbox 表结合起来,用注解标记哪些事件需要持久化,工具自动生成对应的表结构和投递逻辑;二是把校验规则和 API 文档生成串起来,从注解推导出限界上下文的交互图。这两件事都不难,难的是先把手头这套注解用扎实,别在基础还没稳的时候又去叠新东西。