Spring Boot实战DDD:用ArchUnit enforce分层与上下文边界

发布时间:2026/10/30 11:53:52
Spring Boot实战DDD:用ArchUnit enforce分层与上下文边界

简介:本资源是一份面向软件架构师、后端开发工程师及技术团队的领域驱动设计(DDD)入门与实践指南,聚焦业务建模与技术实现的深度对齐,解决复杂业务系统中模型失真、沟通断层与架构腐化等典型问题。内容源自InfoQ中文站免费发布的《领域驱动设计精简版》PDF教程,由Eric Evans思想凝练而来,系统讲解通用语言、聚合根、限界上下文、事件风暴等核心概念,并结合模型驱动设计、持续对话机制与团队协作要点,提供可落地的DDD实施路径。资源为单文件PDF格式,共1个文件,大小1.27MB,轻量易读,适合作为DDD学习第一手材料或团队内部知识共享载体。目前已有483人下载学习,内容涵盖译序反思、核心理念拆解、实践模式详解及挑战应对策略,特别适合希望从理论快速过渡到建模实践的中初级开发者。

1. DDD不是画图游戏:为什么你照着教程写了聚合、值对象、领域事件,上线后还是被业务方骂“改个字段要三天”?

你肯定见过这样的场景:团队花两周时间啃完《实现领域驱动设计》,画出漂亮的限界上下文图,建好聚合根、实体、值对象三层结构,连领域事件总线都搭好了——结果第一个需求来了:“订单页加个‘预计送达时间’字段,前端要显示,后台要参与计算”。开发一查代码,发现这个字段横跨订单聚合、物流服务、库存服务三个上下文,改一处要动八处,数据库要加字段、API要改契约、DTO要重序列化……最后上线拖了五天,业务方在站会上直接问:“你们DDD到底解决了什么问题?”

这不是DDD错了,而是绝大多数“很好的教程”只教你怎么画模型,不教你怎么守边界;只讲Eric Evans书里的概念定义,不讲在Spring Boot里怎么用@Aggregate注解真正锁住聚合内一致性;不告诉你为什么“用户”在会员上下文是聚合根,在订单上下文只能是ID引用;更不会提醒你:当产品经理说“我要一个能查所有订单+所有售后+所有评价的统一搜索页”时,你该立刻拉他开个上下文映射会议,而不是默默去写一个跨三库的MyBatis联查SQL。

这篇笔记不是概念复读机。它基于我在3个中型电商系统落地DDD的真实路径:从用ddd-spring-boot-starter快速搭建骨架,到用Eventuate Tram做最终一致性补偿,再到用ArchUnit写测试断言强制校验“仓储接口不能返回DTO”——每一步都踩过坑、回滚过、重写过。你会看到:

  • 怎么用六边形架构分层把Spring MVC Controller钉死在适配器层,让Application Service真正只依赖Domain Model;
  • 为什么“搞不懂DDD”的人,90%卡在上下文映射模式选错——不是所有上下游都该用Published Language,有些必须用Customer/Supplier关系倒逼对方改接口;
  • 以及最关键的:DDD不是设计阶段的仪式感,而是上线后每次PR合并前,用ArchUnit跑一次的硬性门禁。

如果你正被“模型很美、代码很乱、改需很慢”折磨,这篇就是给你写的。我们不聊“什么是防腐层”,我们直接看AntiCorruptionLayerImpl.java里那行if (legacyStatus.equals("SUC")) return OrderStatus.SUCCESS;是怎么救了你第三次线上事故。


2. 用Spring Boot + ArchUnit搭出DDD骨架:从空项目到能跑通的最小可验证单元

DDD落地最致命的陷阱,是先花两周画UML图,再花三个月写代码,最后发现模型和代码根本对不上。真实项目里,模型必须长在代码里,而不是PPT里。所以第一步不是建包结构,而是用工具把DDD约束变成编译期/测试期的硬规则。我用的是Spring Boot 3.2 +ddd-spring-boot-starter(v2.4.0)+archunit-junit5(v1.2.1),这套组合能让你在写第一行业务代码前,就建立不可绕过的分层纪律。

2.1 创建符合DDD分层规范的Maven模块结构

不要在一个Spring Boot工程里用package模拟分层!真实项目必须物理隔离。我坚持用多模块Maven结构:

<!-- pom.xml --> <modules> <module>domain</module> <!-- 纯Java,无Spring依赖 --> <module>application</module> <!-- @Service,调用domain,不依赖infra --> <module>infrastructure</module> <!-- JPA, Redis, MQ等具体实现 --> <module>interface</module> <!-- Controller, DTO, OpenAPI --> </modules>

关键点:domain模块的pom.xml里绝对不能出现spring-boot-starter。如果IDE提示“找不到@Aggregate”,说明你已经违规——DDD的聚合根定义必须是纯Java类,靠ddd-spring-boot-starter在运行时注入行为,而不是编译期依赖Spring。

提示:domain模块的src/main/java下只允许存在com.example.order.domain.*包,且禁止出现任何import org.springframework.*。这是ArchUnit后续校验的基础。

2.2 用ArchUnit写第一条防线:禁止基础设施层反向调用领域层

很多人以为“分层”就是包名写对就行。但实际开发中,一个手滑的new OrderRepositoryImpl()就能让整个分层崩塌。ArchUnit的作用,就是把这种手滑变成编译失败。

在infrastructure模块的src/test/java下新建测试:

// InfrastructureLayerTest.java @Test public void infrastructure_should_not_depend_on_domain() { JavaClasses classes = new ClassFileImporter() .importPackages("com.example"); ArchRuleDefinition.noClasses() .that().resideInAPackage("..infrastructure..") .should().accessClassesThat().resideInAPackage("..domain..") .because("Infrastructure must depend only on domain interfaces, not implementations") .check(classes); }

这段代码会在mvn test时扫描所有类,一旦发现infrastructure包下的类直接new了domain包里的实体类(比如new Order()),或调用了domain包里的静态方法,测试立刻失败。注意:它允许infrastructure调用domain包里的接口(如OrderRepository),因为接口在domain模块,实现类在infrastructure模块——这才是真正的依赖倒置。

逻辑说明:

  • resideInAPackage("..infrastructure..")匹配所有infrastructure子包下的类;
  • accessClassesThat().resideInAPackage("..domain..")检查这些类是否访问了domain包下的类;
  • noClasses()表示“不允许存在这样的类”,即禁止反向依赖。

参数说明:

  • because()是失败时的提示语,写给团队看的,不是给机器的;
  • check(classes)是执行校验,必须调用,否则测试不生效;
  • 这个测试要放在infrastructure模块,因为它是被约束的对象。

2.3 在domain模块定义聚合根:用@Aggregate注解而非继承

很多教程教你让Order继承AggregateRoot<OrderId>,这会导致领域模型污染——Order本应只关心业务规则,不该知道“我是聚合根”这种框架概念。ddd-spring-boot-starter的正确用法是注解驱动:

// domain/src/main/java/com/example/order/domain/Order.java @Aggregate public class Order { private OrderId id; private List<OrderItem> items; private OrderStatus status; // 私有构造函数,强制通过工厂方法创建 private Order() {} public static Order create(OrderId id, List<OrderItem> items) { Order order = new Order(); order.id = id; order.items = new ArrayList<>(items); order.status = OrderStatus.CREATED; // 聚合内一致性规则:金额必须大于0 if (order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Total amount must be positive"); } return order; } public void confirm() { if (this.status != OrderStatus.CREATED) { throw new IllegalStateException("Only CREATED order can be confirmed"); } this.status = OrderStatus.CONFIRMED; // 发布领域事件,由Application Service处理 DomainEvents.publish(new OrderConfirmedEvent(this.id)); } }

关键点:

  • @Aggregate注解告诉框架:这是聚合根,它的生命周期由仓储管理;
  • 所有业务方法(如confirm())必须保证聚合内一致性,比如状态流转规则;
  • DomainEvents.publish()不是发MQ消息,而是内存事件,由框架在事务提交后异步触发——这是DDD里“最终一致性”的起点,不是“马上发Kafka”。

为什么不用继承?因为继承会把技术细节(如getId())强加给业务模型。而注解方式下,Order类完全干净,OrderId可以是String、UUID或自定义类型,框架通过反射获取id字段。


3. 六边形架构落地:把Controller钉死在适配器层,让Application Service真正无框架

“六边形架构”常被误读为“画六个边的图”。其实它就干一件事:把业务逻辑(核心六边形)和所有外部依赖(数据库、HTTP、MQ)隔开,让核心逻辑能脱离Spring、脱离MySQL独立测试。很多团队说“DDD搞不懂”,本质是没把六边形架构的“端口与适配器”思想落到代码里——Controller还在调Service,Service还在new Repository,整个链路全是Spring Bean,哪来的可替换性?

3.1 定义端口(Port):Application Service接口必须在application模块

application模块是六边形的“应用层”,它只定义接口,不写实现。比如下单用例:

// application/src/main/java/com/example/order/application/PlaceOrderUseCase.java public interface PlaceOrderUseCase { OrderId execute(PlaceOrderCommand command); } // application/src/main/java/com/example/order/application/PlaceOrderCommand.java public record PlaceOrderCommand( String customerId, List<PlaceOrderItemCommand> items, String shippingAddress ) {}

注意:

  • PlaceOrderUseCase是接口,不是@Service类;
  • PlaceOrderCommand是纯数据载体,不含任何Spring注解;
  • 所有入参、出参必须是record或final class,禁止public字段,这是保证不变性的基础。

为什么放这里?因为application模块不依赖infrastructure,所以它的接口天然可被任何适配器实现——Web适配器(Controller)、CLI适配器(命令行)、甚至测试适配器(JUnit)。

3.2 实现适配器(Adapter):Controller只负责HTTP协议转换

interface模块的Controller,唯一职责是把HTTP请求转成PlaceOrderCommand,再调用PlaceOrderUseCase,最后把OrderId转成JSON响应。它不能碰任何领域对象:

// interface/src/main/java/com/example/order/interface/web/OrderController.java @RestController @RequestMapping("/api/orders") public class OrderController { private final PlaceOrderUseCase placeOrderUseCase; public OrderController(PlaceOrderUseCase placeOrderUseCase) { this.placeOrderUseCase = placeOrderUseCase; } @PostMapping public ResponseEntity<OrderIdResponse> placeOrder(@RequestBody PlaceOrderRequest request) { // 1. 协议转换:HTTP Request -> Command var command = new PlaceOrderCommand( request.customerId(), request.items().stream() .map(i -> new PlaceOrderItemCommand(i.productId(), i.quantity())) .toList(), request.shippingAddress() ); // 2. 调用端口:不关心实现,只认接口 OrderId orderId = placeOrderUseCase.execute(command); // 3. 协议转换:Domain ID -> HTTP Response return ResponseEntity.ok(new OrderIdResponse(orderId.value())); } }

关键点:

  • PlaceOrderRequest是DTO,字段名可与前端约定(如customerId),但绝不等于Customer实体;
  • OrderIdResponse是纯响应体,orderId.value()是把领域ID转成字符串,避免暴露OrderId内部结构;
  • Controller里没有new Order()、没有orderRepository.save()、没有@Transactional——这些都交给Application Service和Infrastructure。

逻辑说明:Controller是“胶水”,粘合HTTP协议和应用层接口。如果明天要改成gRPC,只需新增OrderGrpcService,复用同一个PlaceOrderUseCase实现。

3.3 Application Service实现:用领域对象编排,不碰技术细节

application模块的实现类,才是真正的业务编排中枢。它依赖domain模块的实体,但不依赖任何基础设施:

// application/src/main/java/com/example/order/application/PlaceOrderUseCaseImpl.java @Service public class PlaceOrderUseCaseImpl implements PlaceOrderUseCase { private final OrderRepository orderRepository; private final CustomerService customerService; // 领域服务,非基础设施 public PlaceOrderUseCaseImpl(OrderRepository orderRepository, CustomerService customerService) { this.orderRepository = orderRepository; this.customerService = customerService; } @Override @Transactional // 此处@Transactional是业务事务,非技术事务 public OrderId execute(PlaceOrderCommand command) { // 1. 用领域服务校验客户有效性(可能调用外部API,但封装在CustomerService里) customerService.validateCustomer(command.customerId()); // 2. 创建聚合根:业务规则在此执行 var order = Order.create( OrderId.generate(), command.items().stream() .map(i -> OrderItem.create(i.productId(), i.quantity())) .toList() ); // 3. 保存聚合:调用仓储接口,具体实现由infrastructure提供 orderRepository.save(order); // 4. 返回领域ID,不暴露内部结构 return order.getId(); } }

参数说明:

  • OrderRepository是domain模块定义的接口,infrastructure模块提供JpaOrderRepository实现;
  • CustomerService是领域服务,可能封装了调用会员中心HTTP API的逻辑,但它对PlaceOrderUseCaseImpl来说只是个接口;
  • @Transactional标记此处需要ACID事务,框架会自动开启数据库事务——但注意,这是业务层面的事务声明,不是技术细节。

为什么这样分?因为当你要把订单保存从MySQL换成MongoDB时,只需重写infrastructure模块的JpaOrderRepository为MongoOrderRepository,PlaceOrderUseCaseImpl一行代码都不用改。


4. 上下文映射实战:当订单要查会员等级时,别写跨库JOIN,用Customer/Supplier关系

DDD最难的部分,从来不是建模,而是处理多个限界上下文之间的协作。教程里总说“用防腐层”,但没人告诉你:什么时候该用Published Language,什么时候必须用Customer/Supplier,什么时候干脆该合并上下文。我在电商项目里踩过最深的坑,就是让订单服务直接调用会员服务的HTTP API查等级——结果会员服务一抖动,订单创建超时,整个下单链路雪崩。

4.1 识别上下文边界:用“变更频率”和“归属权”双维度判断

先别急着画图。打开你的需求文档,找三个关键词:

  • 谁提的需求?(业务方A提“订单页加会员等级图标”,B提“会员等级升级规则调整”)
  • 谁改的最多?(过去半年,订单表字段改了7次,会员表改了2次)
  • 谁拥有数据主权?(会员等级计算规则由会员中心制定,订单中心只消费结果)

如果答案分别是A、A、B,那就清晰了:订单是Customer,会员是Supplier。这意味着:

  • 订单上下文不能强依赖会员API,必须接受“最终一致性”;
  • 会员上下文要主动发布等级变更事件,订单上下文订阅并更新本地缓存;
  • 双方约定的数据格式(如MemberLevelUpdatedEvent)就是Published Language,必须版本化管理。

注意:不要用“订单需要会员数据”就认为该合并上下文。合并的代价是:会员中心每次发版都要同步通知订单中心,双方耦合度飙升。Customer/Supplier关系下,订单中心可以独立发版,只要保证事件订阅逻辑健壮。

4.2 实现Customer/Supplier:用事件驱动的最终一致性

在infrastructure模块,用Spring Kafka实现事件订阅。先定义Published Language:

// domain/src/main/java/com/example/member/domain/event/MemberLevelUpdatedEvent.java @Value @Builder public class MemberLevelUpdatedEvent { String memberId; int level; Instant updatedAt; String version; // "1.0" }

会员上下文(Supplier)发布事件:

// member-infrastructure/src/main/java/com/example/member/infrastructure/kafka/MemberEventPublisher.java @Service public class MemberEventPublisher { private final KafkaTemplate<String, String> kafkaTemplate; public void publish(MemberLevelUpdatedEvent event) { // 序列化为JSON,发送到topic: member-level-updated kafkaTemplate.send("member-level-updated", event.getMemberId(), new ObjectMapper().writeValueAsString(event)); } }

订单上下文(Customer)订阅并更新本地缓存:

// order-infrastructure/src/main/java/com/example/order/infrastructure/kafka/MemberLevelEventListener.java @Component public class MemberLevelEventListener { private final MemberLevelCache memberLevelCache; // 本地Caffeine缓存 @KafkaListener(topics = "member-level-updated", groupId = "order-group") public void listen(String payload) throws JsonProcessingException { MemberLevelUpdatedEvent event = new ObjectMapper() .readValue(payload, MemberLevelUpdatedEvent.class); // 更新本地缓存,不操作订单数据库 memberLevelCache.put(event.getMemberId(), event.getLevel()); } }

关键点:

  • 订单服务不调用会员HTTP API,只消费事件;
  • 本地缓存有过期策略(如10分钟),即使事件丢失,也能兜底;
  • version字段用于兼容性控制,当会员中心发version="2.0"事件时,订单服务可选择忽略或升级处理器。

逻辑说明:这就是Customer/Supplier的落地形态——Supplier单向发布,Customer被动接收。没有循环依赖,没有网络超时风险,故障隔离彻底。

4.3 防腐层(ACL)编码实践:把外部API响应转成领域对象

当必须调用外部系统(如物流查询)时,ACL不是“写个代理类”,而是严格的数据翻译层。以物流上下文为例:

// order-infrastructure/src/main/java/com/example/order/infrastructure/anti_corruption/LogisticsApiClient.java @Component public class LogisticsApiClient { private final RestTemplate restTemplate; // 外部API返回的原始DTO(含各种奇怪字段) public record ExternalTrackResponse( String trackingNo, String status, String updateTime, List<ExternalTrackStep> steps ) {} public LogisticsTrackingResult queryTracking(String trackingNo) { ExternalTrackResponse response = restTemplate.getForObject( "https://logistics-api.com/track?no={no}", ExternalTrackResponse.class, trackingNo ); // 防腐层核心:把外部DTO转成领域对象,过滤无关字段 return LogisticsTrackingResult.builder() .trackingNo(trackingNo) .status(mapStatus(response.status())) // "DELIVERED" -> DeliveryStatus.DELIVERED .updateTime(Instant.parse(response.updateTime)) .steps(response.steps().stream() .map(this::mapStep) // 外部step转领域step .toList()) .build(); } private DeliveryStatus mapStatus(String externalStatus) { return switch (externalStatus) { case "DELIVERED" -> DeliveryStatus.DELIVERED; case "IN_TRANSIT" -> DeliveryStatus.IN_TRANSIT; default -> DeliveryStatus.UNKNOWN; }; } }

参数说明:

  • ExternalTrackResponse是对外部API的精确映射,只存在于infrastructure模块;
  • LogisticsTrackingResult是领域对象,定义在domain模块,字段名、类型、约束都符合业务语义;
  • mapStatus()是防腐逻辑,把外部字符串转成本地枚举,屏蔽外部变更影响。

为什么叫“防腐”?因为当物流API把"IN_TRANSIT"改成"TRANSITING"时,你只需改mapStatus()这一行,domain模块的DeliveryStatus枚举和所有业务逻辑完全不受影响。


5. 避坑指南:那些让DDD项目上线即翻车的5个血泪经验

DDD落地不是技术问题,而是认知问题。很多团队按教程走完流程,上线后却陷入“模型很美、代码很乱、改需很慢”的怪圈。以下是我亲身踩过的5个坑,每一条都附带现象、原因和可立即执行的解决方案。它们不是理论推演,而是从三次线上事故、四次重构、七次Code Review中熬出来的。

5.1 现象:聚合根里塞了太多业务方法,导致类膨胀到2000行,新人不敢改

原因:混淆了“聚合内一致性”和“业务用例编排”。聚合根只应保护自身不变性(如“订单状态只能从CREATED→CONFIRMED”),而不该承担跨聚合的协调逻辑(如“确认订单后扣减库存”)。后者是Application Service的职责。
解决:用ArchUnit强制校验聚合根方法数。在domain模块加测试:

@Test public void aggregate_root_should_have_less_than_10_methods() { JavaClasses classes = new ClassFileImporter().importPackages("com.example.order.domain"); ArchRuleDefinition.classes() .that().areAnnotatedWith(Aggregate.class) .should().haveNumberOfMethodsLessThan(10) .check(classes); }

提示:超过10个方法,说明你把Application Service逻辑塞进了聚合根。拆出来,用领域事件解耦。

5.2 现象:用DTO当领域对象传参,Controller里new OrderDto()再转Order,改字段要改6处

原因:把“传输对象”和“领域对象”混为一谈。DTO是协议契约,领域对象是业务规则载体,二者生命周期、变更频率、约束条件完全不同。
解决:用MapStruct做零反射转换,并禁止在领域层使用DTO。在application模块定义转换器接口:

@Mapper public interface OrderMapper { OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class); @Mapping(target = "id", ignore = true) // 领域ID由工厂生成 Order toDomain(PlaceOrderCommand command); @Mapping(target = "customerId", source = "order.customerId.value") OrderDto toDto(Order order); }

然后在PlaceOrderUseCaseImpl里直接调OrderMapper.INSTANCE.toDomain(command)。DTO只在interface模块存在,application和domain模块永远看不到OrderDto。

5.3 现象:所有仓储接口都叫XxxRepository,但有的查数据库,有的查Redis,有的调HTTP,调用方无法感知差异

原因:违反了“接口隔离原则”。仓储接口应该表达业务意图(如findActiveOrdersByCustomerId),而不是技术实现(如findByStatusAndCustomerId)。
解决:按业务场景命名方法,并用ArchUnit校验。在domain模块定义:

public interface OrderRepository { // ✅ 表达业务意图 List<Order> findActiveOrdersByCustomerId(CustomerId customerId); // ❌ 技术导向,删除 // List<Order> findByStatusAndCustomerId(String status, String customerId); }

再加ArchUnit测试:

@Test public void repository_method_name_should_contain_business_intent() { JavaClasses classes = new ClassFileImporter().importPackages("com.example.order.domain"); ArchRuleDefinition.methods() .that().areDeclaredInClassesThat().implement(OrderRepository.class) .should().haveNameMatching("find.*By.*|count.*By.*|exists.*By.*") .check(classes); }

5.4 现象:领域事件用String发Kafka,消费者收到后JSON.parse()报错,排查3小时才发现字段名大小写不一致

原因:Published Language没有契约管理。事件结构随心所欲,发布方和订阅方各自解析,毫无版本兼容性。
解决:用Avro Schema定义事件,并用Confluent Schema Registry管理。在domain模块定义Avro Schema文件member-level-updated.avsc:

{ "type": "record", "name": "MemberLevelUpdatedEvent", "namespace": "com.example.member.domain.event", "fields": [ {"name": "memberId", "type": "string"}, {"name": "level", "type": "int"}, {"name": "updatedAt", "type": "long", "logicalType": "timestamp-millis"}, {"name": "version", "type": "string"} ] }

然后用avro-maven-plugin生成Java类。所有事件生产者和消费者都用同一份Schema,字段名、类型、默认值全部强制一致。

5.5 现象:团队画出完美的限界上下文图,但代码里order包下有member类,member包下有order类,边界形同虚设

原因:没有把上下文边界变成可执行的代码约束。画图是起点,不是终点。
解决:用ArchUnit定义上下文边界规则。在order-infrastructure模块加测试:

@Test public void order_context_should_not_access_member_domain_classes() { JavaClasses classes = new ClassFileImporter().importPackages("com.example"); ArchRuleDefinition.noClasses() .that().resideInAPackage("..order..") .should().accessClassesThat().resideInAPackage("..member.domain..") .because("Order context must not depend on Member domain model") .check(classes); }

这条规则会拦截所有import com.example.member.domain.Member;语句。如果真需要会员数据,必须通过MemberService接口——而MemberService定义在application模块,实现类在member-infrastructure模块,完美符合Customer/Supplier。


6. 终极验证:用ArchUnit写“DDD健康度报告”,让每次PR都自动打分

DDD落地最大的幻觉,是以为“模型画完了、代码写完了、功能上线了”就结束了。真实情况是:新同学加入后随手在Controller里加了个@Autowired OrderRepository,三个月后没人记得当初为什么要把仓储抽成接口。DDD不是一次性的设计活动,而是持续的代码纪律维护。我最后压箱底的技巧,就是用ArchUnit生成一份可量化的“DDD健康度报告”,让它成为CI流水线的硬性门禁。

6.1 定义6项核心指标,每项对应一个ArchUnit规则

我把DDD落地质量拆解为6个可测量维度,每个维度用一条ArchUnit规则实现。这些规则全部放在infrastructure模块的src/test/java下,作为mvn verify的一部分:

指标规则描述代码片段合格线
分层纯净度infrastructure不能importdomain实现类noClasses().that().resideInAPackage("..infrastructure..").should().accessClassesThat().resideInAPackage("..domain..").andDoNot().resideInAnyPackage("..domain..repository..")100%通过
聚合内聚度@Aggregate类的方法数≤8classes().that().areAnnotatedWith(Aggregate.class).should().haveNumberOfMethodsLessThan(9)≥95%通过率
上下文隔离度order包不引用member包的领域类noClasses().that().resideInAPackage("..order..").should().accessClassesThat().resideInAPackage("..member.domain..")100%通过
DTO纯洁度domain模块不出现Dto、VO、Response字样`noClasses().that().resideInAPackage("..domain..").should().haveSimpleNameContaining("DtoVO
事件契约度所有领域事件类必须在domain.event包下classes().that().haveSimpleNameEndingWith("Event").should().resideInAPackage("..domain.event..")100%通过
仓储意图度Repository接口方法名必须含find/count/existsmethods().that().areDeclaredInClassesThat().implement(Repository.class).should().haveNameMatching("find.*By.*|count.*By.*|exists.*By.*")≥90%通过率

6.2 生成HTML报告:让健康度可视化

ArchUnit本身不支持HTML报告,但我们可以用archunit-junit5的ArchRules结合JUnit Platform的TestExecutionSummary生成。在pom.xml中添加插件:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <systemPropertyVariables> <archunit.report.dir>${project.build.directory}/archunit-report</archunit.report.dir> </systemPropertyVariables> </configuration> </plugin>

然后写一个汇总测试类:

// DDDHealthCheckTest.java @SpringBootTest public class DDDHealthCheckTest { private static final List<ArchRule> RULES = Arrays.asList( LAYER_PURITY_RULE, AGGREGATE_COHESION_RULE, CONTEXT_ISOLATION_RULE, DTO_PURITY_RULE, EVENT_CONTRACT_RULE, REPOSITORY_INTENT_RULE ); @Test public void run_ddd_health_check() { JavaClasses classes = new ClassFileImporter() .importPackages("com.example"); RuleResults results = new RuleResults(); for (ArchRule rule : RULES) { results.add(rule.evaluate(classes)); } // 输出Markdown格式报告到target/archunit-report/health.md generateReport(results); } private void generateReport(RuleResults results) { try (PrintWriter writer = new PrintWriter( Paths.get("target", "archunit-report", "health.md").toFile())) { writer.println("# DDD Health Report"); writer.println("Generated on " + LocalDateTime.now()); writer.println(); int passed = 0, total = results.size(); for (RuleResult result : results) { String status = result.isViolated() ? "❌ FAIL" : "✅ PASS"; if (!result.isViolated()) passed++; writer.printf("- %s: %s (%d violations)%n", result.getRule().getDescription(), status, result.getErrorCount()); } writer.println(); writer.printf("## Overall: %d/%d (%.1f%%)%n", passed, total, (double) passed / total * 100); } catch (IOException e) { throw new RuntimeException(e); } } }

运行mvn verify后,target/archunit-report/health.md会生成类似这样的报告:

# DDD Health Report Generated on 2024-06-15T14:22:33.123 - Infrastructure layer purity: ✅ PASS (0 violations) - Aggregate cohesion: ✅ PASS (0 violations) - Context isolation: ❌ FAIL (3 violations) - DTO purity: ✅ PASS (0 violations) - Event contract: ✅ PASS (0 violations) - Repository intent: ✅ PASS (0 violations) ## Overall: 5/6 (83.3%)

6.3 CI流水线集成:健康度低于90%时阻断发布

在Jenkins或GitLab CI中,把健康度报告作为门禁:

# .gitlab-ci.yml ddd-health-check: stage: test script: - mvn verify -DskipTests=false artifacts: paths: - target/archunit-report/health.md rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: always publish-release: stage: deploy script: - | HEALTH_SCORE=$(grep "Overall:" target/archunit-report/health.md | awk '{print $3}' | sed 's/%//') if (( $(echo "$HEALTH_SCORE < 90" | bc -l) )); then echo "DDD health score $HEALTH_SCORE% < 90%. Blocking release." exit 1 fi echo "DDD health OK. Proceeding..." needs: ["ddd-health-check"]

这就是我坚持了三年的习惯:DDD不是设计文档里的漂亮图形,而是每次PR合并前,CI流水线里那个红色的❌和绿色的✅。当新同学想在Controller里直接new一个领域对象时,ArchUnit会立刻报错,他必须停下来思考:“这个逻辑,真的属于这一层吗?”——那一刻,DDD才真正活在了代码里。

希望帮到你。

本文还有配套的精品资源,点击获取