☰
用 jmolecules 落地 DDD:注解建模与 ArchUnit 架构约束
2026/11/4 14:34:41 网站建设 项目流程

1. 先把问题说清楚:DDD 落地难的根子在哪里

用 jmolecules 快速实现 DDD,这件事听起来像是“加几个注解就完事”,但真在项目里推过一轮的人都知道,难点从来不在注解本身,而在于领域模型的概念没有落到代码这个可执行的载体上。我们团队早年做订单、库存、结算这类业务时,N 份设计文档写得漂亮,聚合根、值对象、领域事件一应俱全,代码提交上去却是另一回事:聚合根成了贫血的 DTO 加一堆 setter,一个OrderService里塞了 800 行逻辑,值对象退化成String,跨聚合直接对象引用然后顺手改了对方状态。三个月后新人接手,只能靠猜。

jmolecules 要解决的正是这个断层。它把 DDD 里的术语翻译成一组与框架无关的 Java 注解和接口,让你在类型系统层面把“这是一个聚合根”“这是一个值对象”“这个字段是对另一个聚合的引用”写成代码事实,而不是口头约定。这些注解本身不改变运行时行为,它们是元数据,但元数据可以被 Spring Data 识别、被 ArchUnit 校验、被 IDE 展示、被后来的人读到。这一层价值,比它带来的任何自动化都重要。

这篇内容适合三类人看:一是正在把单体里一锅乱炖的服务层拆成领域模型的 Java 后端;二是已经用了六边形或洋葱架构的目录结构,但里面装的还是传统三层代码的团队;三是想给代码评审找一个客观标准、不想每次靠“我觉得这样不 DDD”吵架的技术负责人。全文基于 jmolecules 的常见用法展开,版本细节以你实际引入的为准,我尽量把判断逻辑讲透,让你换版本也不至于抓瞎。

2. 核心思路拆解:为什么用“注解”而不是“框架”

2.1 领域概念失语的三个典型现场

先看三个我在代码评审里高频遇到的现场,你能对上号,说明这套东西对你有用。

第一个现场:聚合边界靠注释维持。Order和OrderLine放在一个包里,OrderLine外面也有人在 new、在改、在单独查询,因为“反正它只是个实体”。问题在于没人说得清OrderLine的生命周期归谁管,级联删除写漏一次就是脏数据。

第二个现场:值对象全是裸类型。金额用BigDecimal,货币用String,订单号用String。于是BigDecimal的scale在不同服务里不一致,String类型的订单号和客户号在方法签名里互换位置编译器毫无反应,一个参数顺序写错就是线上事故。

第三个现场:跨聚合强引用。Order里直接private Customer customer;,JPA 配个@ManyToOne,看着很自然。等到要给客户表加个字段、做个分库、或者只是想在单测里构造一个 Order,你就得连带造出一个完整的 Customer 对象图,测试代码比业务代码还长。

这三个现场的共同点是:领域规则存在于人的脑子里,而不是编译器能检查的地方。jmolecules 的价值就在这儿,它提供了一套标注语言,让“聚合根”“值对象”“标识引用”从概念变成可以被打架、被工具读到的类型信息。

2.2 jmolecules 的分层结构:注解、类型接口、集成模块

很多人第一次看 jmolecules 的文档会有点懵,因为它不是一个东西,而是三块拼图。

第一块是注解包,比如org.jmolecules.ddd.annotation下的@AggregateRoot、@Entity、@ValueObject、@Repository、@Service、@Identity、@Association。这一块是纯标记,运行时零开销,加在类上就是给人和工具看的标签。

第二块是类型接口,比如org.jmolecules.ddd.types下的AggregateRoot、Entity、ValueObject、Identifier。接口比注解强的地方在于,它能约束方法签名,比如聚合根接口要求你提供getId(),你的 ID 类型就必须是确定的,不能含糊成Object或Long。

第三块是集成模块,这是让它真正“能用起来”的关键。比如和 Spring Data JPA 的集成,会让标注了@Identity的字段在新建实体时允许为 null、在持久化后被自动回填;和 ArchUnit 的集成,提供了现成的架构规则;还有针对字节码的模块,让第三方框架能读到这些注解。三块拼图的分工要记牢:注解负责表达,接口负责约束,集成负责让框架认账。

2.3 三种表达方式的选择与取舍

为什么不是直接上某个全栈 DDD 框架?我做了一张对比表,这是我们内部选型时真实用过的判断依据。

方案表达力框架绑定迁移成本适用场景
纯文档和口头约定弱,靠人记无低三人以下小团队、短周期项目
框架私有注解(某 ORM 或某架构框架自带)中,但与框架强绑强高,换框架等于重写已深度绑定某框架、不打算换
jmolecules 注解加接口中高,语义明确弱,纯元数据低,删注解即回退中大型业务系统、需要长期维护

选 jmolecules 的核心理由是可回退。它不接管你的对象生命周期,不生成代理,不要求你继承某个基类。最坏的情况是团队觉得这套东西没价值,把依赖和注解删掉,代码照样编译照样跑。这个特性在真实项目里极其重要,因为你不可能为了一个“表达规范”去赌上整个技术栈。

反过来说,如果你期待它像脚手架一样帮你生成一堆代码、自动装配仓储、自动发布事件,那你会失望。它更像是一套贴在代码上的标签体系加验证工具,剩下的活还是得你自己写。这一点想清楚,后面所有操作都不会跑偏。

2.4 落地顺序建议:先标注,再验证,最后集成

新人最容易犯的错是上来就把所有集成模块都引进来,结果版本冲突、代理失效、事件不触发,一堆问题混在一起排查不动。

我建议的顺序是:第一步只引jmolecules-ddd这一个依赖,把领域层现有的类打上注解,纯手工,不改任何逻辑。跑一遍编译和测试,确认没有副作用。第二步引 ArchUnit,把“聚合之间不能直接引用”这类规则写成测试,让它先红一批,你才知道现状有多乱。第三步再按需引入和 Spring Data、事件相关的集成模块,一个一个来,每引一个跑一遍全量测试。这个顺序看着慢,实际上比一次性全上快得多,因为每一步的失败原因都是单一的。

3. 核心注解逐个拆解:把术语翻译成 Java 类型

3.1 聚合根与实体:@AggregateRoot、@Entity、@Identity 的分工

这三个注解经常被混用,我按“谁管谁”讲清楚。

@AggregateRoot标在聚合根类上,语义是“这个对象的生命周期由自己负责,外部只能通过它访问聚合内部”。@Entity标在聚合内部的实体上,语义是“你有自己的标识,但你的生死由聚合根决定”。@Identity标在标识字段上,作用是告诉集成模块“这个字段是主键,允许在对象新建时为空,持久化时回填”。

代码上长这样:

import org.jmolecules.ddd.annotation.AggregateRoot; import org.jmolecules.ddd.annotation.Identity; import org.jmolecules.ddd.annotation.Entity; @AggregateRoot public class Order { @Identity private OrderId id; private CustomerId customerId; private OrderStatus status; private final List<OrderLine> lines = new ArrayList<>(); // 只暴露业务意图明确的方法,不暴露 setter public void addLine(ProductId productId, int quantity, Money unitPrice) { if (this.status != OrderStatus.DRAFT) { throw new IllegalStateException("只有草稿状态的订单可以改明细"); } this.lines.add(new OrderLine(productId, quantity, unitPrice)); } public Money totalAmount() { return lines.stream() .map(OrderLine::subtotal) .reduce(Money.zero("CNY"), Money::add); } public OrderId getId() { return id; } }

这里有个关键判断:聚合根不要提供 public 构造器让外部随便 new 一个半成品。用静态工厂方法Order.place(...)表达业务意图,构造器设为 package-private 或 private。这是 DDD 的常规做法,但很多团队做不到,因为 JPA 需要一个无参构造器。折中方案是无参构造器保留为protected,业务入口用静态工厂,这样既满足 ORM,又不至于让外部绕过不变量。

提示:@Identity的收益主要体现在和 Spring Data 的集成上。如果你不用那套集成,它就是个纯文档标记,写不写都不影响运行,但写了能让后来的人一眼看出哪里是主键。

3.2 值对象:@ValueObject 与不可变性的硬约束

值对象是 DDD 里投入产出比最高的东西,也是 jmolecules 里约束最强的东西。@ValueObject标上去,语义是“没有标识、不可变、相等性由属性决定”。这三个词里,最容易被违反的是“不可变”。

在 Java 17 之后,我强烈建议值对象一律用record:

import org.jmolecules.ddd.annotation.ValueObject; import java.math.BigDecimal; import java.util.Currency; import java.util.Objects; @ValueObject public record Money(BigDecimal amount, Currency currency) { public Money { Objects.requireNonNull(amount, "金额不能为空"); Objects.requireNonNull(currency, "币种不能为空"); if (amount.scale() > 2) { throw new IllegalArgumentException("金额最多两位小数"); } amount = amount.setScale(2, java.math.RoundingMode.UNNECESSARY); } public static Money zero(String currencyCode) { return new Money(BigDecimal.ZERO, Currency.getInstance(currencyCode)); } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("不同币种不能直接相加"); } return new Money(this.amount.add(other.amount), this.currency); } }

压缩构造器里做校验,这是 record 最有价值的特性。注意RoundingMode.UNNECESSARY这个选择是有讲究的:金额精度要么在源头截断,要么直接抛错,绝不能在中间环节静默四舍五入。我见过因为divide默认舍入模式导致的账目差错,排查了两天。

@ValueObject的另一个用途是配合架构测试。规则可以写成“标注了@ValueObject的类必须所有字段是 final”,或者“必须没有 setter 方法”。这类规则一旦挂进构建,值对象被后人改坏的概率会大幅下降。

3.3 引用关系:@Association 与“只存标识”的落地

这是我认为 jmolecules 最有实用价值的一个点。聚合之间的引用,规范做法是只存对方的标识,不持有对象引用。道理很简单:聚合是一个事务边界和一致性边界,跨边界持有对象引用,等于把两个聚合的一致性绑在一起,后面的分库、缓存、并发都会出问题。

@Association就是用来标记这类字段的:

import org.jmolecules.ddd.annotation.Association; @AggregateRoot public class Order { @Identity private OrderId id; @Association private CustomerId customerId; // 需要客户信息时,通过仓储或查询服务显式获取 }

@Association的语义是“这是一个对另一个聚合的引用”,它不改变存储方式,但会给工具提供判断依据。比如你可以写一条架构规则:标注了@Association的字段,类型必须是实现了Identifier的类型,不能是实体类型。这条规则能拦住 90% 的“顺手把对方实体塞进来”的提交。

实践中会有一个现实问题:老代码里大量@ManyToOne的对象引用,一时半会改不完。我的建议是分两步走,先给新写的代码立规矩,老代码用 ArchUnit 的允许清单(allowEmptyShould加上包级别的豁免)先放着,然后按模块逐步改造。指望一次性全改完,项目基本会死在半路上。

3.4 仓储、服务与事件:三个容易被误用的注解

@Repository、@Service、@DomainEvent这三个标注,我按“该放哪儿”讲。

@Repository标在仓储接口上,注意是接口,不是实现类。DDD 里仓储接口属于领域层,实现属于基础设施层,这个分层如果反了,领域层就绑死在某个 ORM 上了。标注之后,你可以写规则约束“仓储接口只能被应用层调用,不能被领域层实体调用”。

@Service标在领域服务上,用于表达“这个操作涉及多个聚合,放在任何一个聚合里都不合适”。这个注解最容易被滥用,我看到过把事务脚本全部标成@Service的代码。判断标准其实很硬:如果一个操作只涉及单个聚合,它应该是聚合根上的方法,而不是领域服务。

@DomainEvent标在事件类上,表达“这是一个已经发生的业务事实”。这里必须说清楚一个常见误解:jmolecules 的@DomainEvent不会帮你发布事件,它只是个标记。真正的发布还是要靠 Spring 的ApplicationEventPublisher,或者用 Spring Data 提供的@DomainEvents加@AfterDomainEventPublication那套机制在聚合根上收集事件、在仓储保存后统一发出。jmolecules 在这里的作用是让事件类型可被识别,从而支持“事件类必须是不可变的”“事件类必须放在 domain 包的 events 子包下”这类规则。

注解标在哪里是否影响运行时主要价值
@AggregateRoot聚合根类否表达事务边界,支持架构规则
@Entity聚合内实体类否区分生命周期归属
@ValueObject不可变值类型否支撑“必须 final/无 setter”规则
@Identity标识字段配合集成时有允许新建时为 null,持久化回填
@Association跨聚合引用字段否支撑“只能引用标识”规则
@Repository仓储接口否支撑分层依赖规则
@Service领域服务类否标记跨聚合操作
@DomainEvent事件类否支撑事件类不可变等规则

这张表建议直接贴在团队文档里,新人第一天就能看懂每个注解的边界在哪,不会以为加了注解就自动获得框架能力。

4. 动手搭一个最小可运行示例:订单域

4.1 依赖引入与版本选择

先解决版本问题。jmolecules 的发布线不是单一的,历史上存在语义化版本和按年份滚动两套,选版本时以中央仓库的当前稳定版为准,别抄博客里的老坐标。下面用属性占位,你自己替换。

<properties> <jmolecules.version>替换为中央仓库当前稳定版</jmolecules.version> </properties> <dependencies> <!-- 核心语义:注解 + 类型接口,领域层唯一必需的依赖 --> <dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-ddd</artifactId> <version>${jmolecules.version}</version> </dependency> <!-- 领域事件标记 --> <dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-events</artifactId> <version>${jmolecules.version}</version> </dependency> <!-- 架构规则,仅测试期需要 --> <dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-archunit</artifactId> <version>${jmolecules.version}</version> <scope>test</scope> </dependency> </dependencies>

用 Gradle 的话写法对应:

dependencies { implementation "org.jmolecules:jmolecules-ddd:${jmoleculesVersion}" implementation "org.jmolecules:jmolecules-events:${jmoleculesVersion}" testImplementation "org.jmolecules:jmolecules-archunit:${jmoleculesVersion}" }

这里有一个我踩过的坑:集成模块的坐标和核心模块不在同一个 groupId 下。核心注解在org.jmolecules下,而 Spring Data、字节码这类集成在org.jmolecules.integrations下。第一次引入时我按 groupId 猜坐标,猜了半天都对不上,最后还是去中央仓库搜的。如果你只要注解和架构规则,其实不需要碰集成模块,这是最省事的起步方式。

4.2 包结构:先划边界再写代码

包结构这件事,我坚持一个原则:包边界要能直接映射到架构边界,因为工具和人都靠包来理解系统。下面是我在业务系统里用得最顺的一种布局。

com.example.order ├── domain │ ├── model // 聚合根、实体、值对象 │ │ ├── Order.java │ │ ├── OrderId.java │ │ ├── OrderLine.java │ │ └── Money.java │ ├── event // 领域事件 │ │ └── OrderPlaced.java │ └── repository // 仓储接口 │ └── OrderRepository.java ├── application // 用例编排、事务边界 │ ├── OrderApplicationService.java │ └── dto └── infrastructure // 仓储实现、外部适配 ├── OrderRepositoryImpl.java └── jpa

为什么把仓储接口放在 domain 而不是 infrastructure?因为领域层要能声明“我需要一个能按 ID 取订单的东西”,但它不关心这东西怎么实现。接口在领域侧、实现在基础设施侧,依赖方向就对了,领域层不依赖任何技术细节。这个决定看着小,但它决定了你的领域层能不能脱离数据库做单测。我做过对比,同样一套业务逻辑,仓储接口放对位置的版本,单测跑一次 3 秒;放错的版本,每次要起 Spring 上下文和内存数据库,跑一次 40 秒,改代码的意愿都被磨没了。

4.3 聚合根与值对象的代码落地

先写标识类型。这里有个细节值得强调:每个聚合的 ID 用独立的类型,不要都用Long或UUID。理由是类型安全,OrderId和CustomerId是不同的类型,编译器会拦住参数顺序写反这种低级错误。

package com.example.order.domain.model; import java.util.UUID; public record OrderId(UUID value) { public static OrderId newId() { return new OrderId(UUID.randomUUID()); } public static OrderId of(String raw) { return new OrderId(UUID.fromString(raw)); } }

如果你想让标识类型更贴合 jmolecules 的约定,可以让它实现org.jmolecules.ddd.types.Identifier接口,这样架构规则里可以直接按接口判断类型。不过要注意,不同版本的这个接口泛型参数有差异,引入前先在 IDE 里看一眼签名,别照抄老文档。

接着写聚合内的实体:

package com.example.order.domain.model; import org.jmolecules.ddd.annotation.Entity; import java.util.Objects; @Entity public class OrderLine { private final ProductId productId; private final int quantity; private final Money unitPrice; OrderLine(ProductId productId, int quantity, Money unitPrice) { if (quantity <= 0) { throw new IllegalArgumentException("数量必须大于 0"); } this.productId = Objects.requireNonNull(productId); this.quantity = quantity; this.unitPrice = Objects.requireNonNull(unitPrice); } public Money subtotal() { return unitPrice.multiply(quantity); } public ProductId getProductId() { return productId; } }

注意构造器是包级可见的,只有同包的Order能创建它。这个限制加上@Entity标注,双重保证了“外部不能绕过聚合根直接改明细”。

然后写领域事件:

package com.example.order.domain.event; import org.jmolecules.event.annotation.DomainEvent; import java.time.Instant; @DomainEvent public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) { }

事件用 record 写,天然不可变。事件的字段要“够用且自洽”,不要塞整个聚合进去,也不要只塞一个 ID 让消费者自己查——这两种极端我都见过,前者导致消费方拿到过期快照,后者导致消费方疯狂回查数据库。

4.4 仓储接口与基础设施实现的接线

仓储接口保持极简,只声明领域层真正需要的操作:

package com.example.order.domain.repository; import org.jmolecules.ddd.annotation.Repository; import com.example.order.domain.model.Order; import com.example.order.domain.model.OrderId; import java.util.Optional; @Repository public interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order); }

实现层有两种写法。如果你用 Spring Data,可以定义两个接口:一个面向领域的OrderRepository,一个面向框架的JpaOrderRepository extends JpaRepository<OrderEntity, UUID>,然后在适配器里做转换。这种做法最干净,代价是要写一层映射。

另一种是直接在领域接口上让 Spring Data 识别,靠集成模块让它认@Repository标注的接口。这条路省代码,但对聚合的 JPA 映射有额外要求,比如 ID 字段的可见性和回填时机。我的经验是:领域模型干净优先,宁可写映射层。因为 JPA 的实体模型和领域模型天生有冲突(懒加载代理、无参构造器、双向关联),强行合二为一,最后往往是领域模型被 JPA 牵着走,越写越不像 DDD。

4.5 领域事件的收集与发布时机

这块是实操里最容易出问题的地方。事件应该在聚合状态变更成功之后产生,并且在事务提交之后发布,两个条件缺一个就会出事。

聚合内收集事件的写法:

@AggregateRoot public class Order { @Identity private OrderId id; private final List<Object> domainEvents = new ArrayList<>(); public static Order place(CustomerId customerId, List<LineDraft> drafts) { Order order = new Order(); order.id = OrderId.newId(); order.customerId = customerId; order.status = OrderStatus.PLACED; drafts.forEach(d -> order.lines.add(new OrderLine(d.productId(), d.quantity(), d.unitPrice()))); order.domainEvents.add(new OrderPlaced(order.id, customerId, order.totalAmount(), Instant.now())); return order; } public List<Object> pullDomainEvents() { List<Object> events = List.copyOf(domainEvents); domainEvents.clear(); return events; } }

然后在应用服务里,事务提交后统一发布。这里有个顺序陷阱我必须点出来:如果事件发布放在事务内,消费者读到的可能是未提交的数据,或者下游系统收到了事件但这边事务回滚了,就成了“幽灵事件”。如果放在事务外但没做持久化重试,发布失败事件就丢了。生产环境里的稳妥做法是配合本地消息表或者事务性发件箱,但那是另一个话题了,起步阶段至少要做到“提交后发布”。

4.6 跑一次验证:确认标注真的生效

代码写完,先别急着写业务。跑一个最小测试,确认三件事:领域对象能构造、聚合不变量会拦错、事件能拿到。

@Test void 下单后能拿到领域事件() { Order order = Order.place(new CustomerId(UUID.randomUUID()), List.of( new LineDraft(new ProductId(UUID.randomUUID()), 2, new Money(new BigDecimal("19.90"), Currency.getInstance("CNY"))) )); List<Object> events = order.pullDomainEvents(); assertThat(events).hasSize(1); assertThat(events.get(0)).isInstanceOf(OrderPlaced.class); } @Test void 数量非法时构造明细会失败() { assertThatThrownBy(() -> new LineDraft(new ProductId(UUID.randomUUID()), 0, Money.zero("CNY"))) .isInstanceOf(IllegalArgumentException.class); }

这一步的意义在于把“注解有没有被工具认到”和“业务逻辑对不对”分开验证。我见过太多人一次性把注解、集成、业务逻辑全写完再跑测试,一报错就完全不知道是哪层的问题。

5. 让规则具备强制力:ArchUnit 架构测试

5.1 只加注解没有持久价值,规则才能真正发挥作用

注解最大的问题是可以被忽略。你标了@AggregateRoot,新人照样在里面塞一个跨聚合的对象引用,编译器管不了,代码评审靠人情。唯一的解法是把架构约定写成测试,让它进 CI,红了就不许合并。

这一步是整套方案里投入产出比最高的环节。写三条规则的成本大概半天,换来的是后面两年里所有提交都被自动检查一遍。我们内部的经验是,规则上线后第一个月会红十几次,基本都是老代码,加豁免清单处理;第二个月开始新提交基本不会再犯同类问题。

5.2 三条最值得写的规则与实现

第一条,聚合根之间只能通过标识引用。这是防“跨聚合强引用”的。

import com.tngtech.archunit.core.domain.JavaField; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.jmolecules.ddd.annotation.AggregateRoot; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; public class DddArchitectureTest { @ArchTest static final ArchRule 聚合之间只能通过标识引用 = fields() .that().areDeclaredInClassesThat().areAnnotatedWith(AggregateRoot.class) .and().areNotStatic() .should(new ArchCondition<JavaField>("不得直接持有另一个聚合根") { @Override public void check(JavaField field, ConditionEvents events) { if (field.getRawType().isAnnotatedWith(AggregateRoot.class)) { events.add(SimpleConditionEvent.violated(field, field.getFullName() + " 直接引用了另一个聚合根,应改为持有标识")); } } }); }

第二条,值对象必须不可变。检查标注了@ValueObject的类,字段全为 final。

@ArchTest static final ArchRule 值对象字段必须不可变 = fields() .that().areDeclaredInClassesThat().areAnnotatedWith(ValueObject.class) .and().areNotStatic() .should().beFinal();

record 类型的字段在字节码层面本身就是 final 的,这条规则主要拦“用普通 class 冒充值对象”的情况,非常有效。

第三条,领域层不能依赖基础设施层。这条守的是分层。

@ArchTest static final ArchRule 领域层不依赖基础设施 = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAnyPackage( "..infrastructure..", "org.springframework.data..", "jakarta.persistence.." );

第三条规则一开始大概率会失败,因为你可能已经把 JPA 注解写在领域类上了。别急着全改,先把规则范围缩到新模块,或者用豁免清单圈出存量类,逐步收敛。

5.3 规则太严格的副作用与调优办法

规则写得太严会把团队逼到反面:为了过 CI,大家开始加豁免、加@SuppressWarnings、加空实现,规则形同虚设。我见过最夸张的一个项目,架构测试目录下有个上百行的豁免清单,比规则本身还长。

调优的思路是按模块分级。核心域(订单、资金、库存)用最严的规则,不允许豁免;支撑域用中等规则,允许存量豁免;通用域和技术组件基本不设领域规则。这跟我们做代码分层的逻辑是一致的,最需要保护的地方才值得投入维护成本。

另一个经验是规则要能给出人话提示。violated方法里的消息不要写“违反规则 R12”,要写“Order 直接引用了 Customer 聚合根,请改为持有 CustomerId”。报错信息写得越具体,修复的人越不需要来问你。

5.4 把规则接入构建:本地和流水线两级

本地跑靠 IDE 的测试集成,改了领域代码顺手跑一下架构测试类,秒级反馈。流水线上把它挂在verify阶段,和普通单测一起跑,不要单独开一个慢任务,否则大家会习惯性跳过。

顺带提一个细节:ArchUnit 会扫描 classpath 下的类,项目大了以后扫描本身要几秒到十几秒。如果你的架构测试类分散在多个模块,建议把规则类集中在arch-test模块里,用@AnalyzeClasses(packages = "com.example")指定扫描范围,避免每个模块各扫一遍。这个优化我们做过,全量架构测试从 25 秒降到 6 秒。

6. 常见问题与排查技巧实录

6.1 注解加了,但工具没认

最常见的现象是架构测试完全不报错,也不通过,像是什么都没发生。八成原因是规则类的扫描范围没覆盖到目标包,或者规则类没有被测试框架发现。

排查顺序:先在测试里打印importedClasses的数量,看是不是 0 或者个位数;再看@AnalyzeClasses的packages值是不是写成了具体的类名而不是包前缀;最后确认测试框架的扩展机制有没有生效,有些项目用的是自定义测试基类,扩展没继承上。这三点排查完,基本能定位。

6.2 标识为 null 引发的连锁问题

@Identity允许字段在新建时为 null,这个便利会带来一个隐蔽问题:你在聚合内部用id.equals(...)或者把 ID 放进HashMap的时候,NPE 就来了。更隐蔽的是,hashCode基于 ID 实现的对象在 ID 回填前后哈希值变了,放进Set之后再取就取不出来。

处理办法有两个:一是聚合内部的相等性判断永远用业务键,不用 ID;二是如果必须用 ID 做相等性,就在持久化之后再放入集合。这个坑我在一个批量导入功能里踩过,症状是“明明插进去了却查不到”,查了一下午。

6.3 值对象映射到数据库时的取舍

把Money这样的值对象映射成两个字段,需要在 JPA 里用@Embeddable加@AttributeOverrides。record 作为@Embeddable在老版本 Hibernate 上是不支持的,需要升级到支持 record 的版本,或者退一步写成普通不可变类。

这里的原则是:不要因为持久化的麻烦就放弃值对象。折中方案是在领域模型里保留值对象,在持久化层单独定义一个扁平的数据类做映射,转换逻辑放在适配器里。多写一个类,换来的是领域模型不被 ORM 细节污染,这笔账怎么算都划算。

6.4 常见问题速查表

现象可能原因排查动作
架构测试没反应扫描包路径不对、扩展没生效打印导入类数量,检查@AnalyzeClasses
标识字段为 null 导致 NPE新建对象尚未持久化改用业务键做相等性判断
领域事件没被消费发布时机在事务内、监听器没注册改到提交后发布,检查监听注解
值对象映射报错ORM 版本不支持 record升级版本或改用普通不可变类加映射层
规则误报太多规则过严、存量代码未豁免按模块分级,建豁免清单
依赖冲突起不来集成模块版本不匹配逐个引入模块,隔离验证

6.5 几个不太容易想到的坑

第一个:不要在领域层直接使用 Spring 的@Transactional。事务边界属于应用层的职责,标在领域服务上会让领域层依赖 Spring。如果你确实需要在应用服务里控制事务,标在应用服务的方法上,领域层保持干净。

第二个:聚合根的方法不要返回内部集合的可变视图。return this.lines;这行代码会让外部绕过聚合根直接改明细,加注解也没用。要么返回List.copyOf(lines),要么返回只读视图,并且明确文档写清楚。

第三个:领域事件不要用继承做分组。我曾经为了统一处理,让所有事件继承一个基类,结果反序列化和规则校验都变得很别扭。事件的共性应该是接口或者元数据,不是继承树。

7. 几条用下来才明白的经验

jmolecules 这套东西最大的价值不在注解本身,而在于它给了团队一个共同的词汇表。以前评审代码,“这里应该是个值对象”是一句主观判断,容易被反驳“我觉得没必要”。现在你可以说“这个类标了@ValueObject但字段不是 final,架构测试过不了”,讨论就从审美变成了事实。这个转变对团队效率的提升,比任何自动化都明显。

第二条经验:不要一次全量改造。挑一个边界清晰、依赖最少的模块作为试点,把注解、规则、集成全套走一遍,跑两周看看团队反馈。试点成功之后再推广,失败也只是损失一个小模块。我见过上来就全量推的项目,三个月后没人再提这事,代码里残留一堆半吊子注解,比不推还糟。

第三条:规则宁可少而稳,不要多而虚。三条能一直跑下去、没人抱怨的规则,价值远大于二十条需要不断豁免的规则。规则的本质是团队共识的固化,共识没达成的时候加规则,只会制造对立。

第四条:注解补不上设计的窟窿。如果你的聚合边界本来就没想清楚,标上@AggregateRoot也不会让它变清楚,只是把混乱标了个记。这套工具的作用是把已经想清楚的设计表达出来并守住,它不负责帮你想。真正花时间的永远是领域建模本身——哪些概念是实体、哪些是值对象、一次业务操作的事务边界画在哪,这些想明白了,注解十分钟就能加完。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询