ArchUnit:用一个单元测试库,把架构纪律变成 AI 也绕不过的红绿灯

Posted on 日 07 6月 2026 in Tech

Abstract ArchUnit:用一个单元测试库,把架构纪律变成 AI 也绕不过的红绿灯
Authors Walter Fan
Category Tech
Status v0.1
Updated 2026-06-07
License CC-BY-NC-ND 4.0

ArchUnit:用一个单元测试库,把架构纪律变成 AI 也绕不过的红绿灯

短大纲

  • 架构图画得再漂亮,三个月后也会和代码对不上——架构腐化是常态,AI 时代更快
  • ArchUnit 的朴素思路:把架构约定写成会失败的测试(fitness function)
  • 它就是 JUnit:原理三步、一份能直接抄的规则手册
  • 老项目怎么落地:用 FreezingArchRule 冻结存量违规,只拦新增
  • 为什么它能大幅提升 harness:把"看不见的结构纪律"变成 AI 绕不过的硬约束

一、架构图骗了你,代码不会

几乎每个项目都有一张架构图,画在 wiki 上,分层清晰、箭头工整,看着特别像那么回事。

然后呢?三个月后,你打开代码,发现 Controller 里直接 @Autowired 了一个 Mapper,绕过了整个 Service 层;两个本该解耦的领域,因为一次"临时救火"互相 import 成了环;utils 包像垃圾场,谁都往里塞。那张架构图还挂在 wiki 上,岁月静好,只是它早就不是这套代码的真相了。

这事有个专门的名字,叫架构腐化(architecture erosion)。它不是某个人懒,而是系统的熵增——每一次"就这一次""先上线再说""我赶时间",都在往墙上凿一个小洞。墙不会一次塌,但洞够多,迟早漏风。

过去我们靠两样东西挡这股熵增:老员工的脑子code review 的自觉。老员工知道"这里不能这么写",review 时一眼看出"你这跨层了"。可这两样东西都很贵、很不稳定,还很容易在赶工期时被跳过。

到了 AI 写代码的时代,这道防线更不够用了。AI 是个失忆、看不到全局、不为线上故障负责的新同事,它编译能过、功能能跑,却会随手把 Controller 直连 Mapper、把两个领域连成环——而且它干这事的速度和规模,比任何一个赶工期的人都快。你 review 得过来吗?

我的观点很简单:

架构纪律不能靠口头约定和人肉自觉,得变成会自动失败的测试。 而在 Java 世界里,做这件事成本最低、最该第一个上的工具,就是 ArchUnit。


二、ArchUnit 就是 JUnit

很多人一听"架构守护工具",以为又是个重型框架,要装服务、配规则引擎、学一套 DSL。不是的。

一句话:ArchUnit 就是一个普通的 Java 测试库。 加一个测试依赖,你写的"架构规则"本质上就是测试用例,跟着 mvn test / gradle test 一起跑,违反了就变红——和你天天写的单元测试体验一模一样。区别只有一个:JUnit 断言的是"某个函数的返回值对不对",ArchUnit 断言的是"代码的结构、依赖、命名守没守规矩"。

没有额外的运行时,不进生产包,CI 也不用改造,就是多了一类测试而已。

先加依赖(JUnit 5 版):

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>

它的工作原理也很朴素,就三步:

  1. 把字节码读进来——ClassFileImporter 扫描你的包,得到一批 JavaClasses,也就是"所有类的元信息"(谁依赖谁、在哪个包、有什么注解、叫什么名)。
  2. 声明一条规则——用近乎大白话的链式 API 描述约束。
  3. 断言——rule.check(classes),违反就抛异常、测试失败。

最省事的写法是用 @AnalyzeClasses + @ArchTest,框架自动帮你导入和执行,你一行胶水代码都不用写:

@AnalyzeClasses(packages = "com.example.order")
class ArchitectureTest {

    @ArchTest
    static final ArchRule controller不许直连Mapper =
        noClasses().that().resideInAPackage("..api..")
            .should().dependOnClassesThat().resideInAPackage("..infra.mapper..");
}

跑一下 mvn test,如果真有 Controller 直连了 Mapper,你会看到一条非常友好的报错,直接点名是谁违反了哪条规则、违反在哪一行

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'no classes that reside in a package '..api..'
should depend on classes that reside in a package '..infra.mapper..'' was violated (1 times):
Method <com.example.order.api.RefundController.refund()>
calls method <com.example.order.infra.mapper.RefundMapper.update()>
in (RefundController.java:42)

这就是它的全部魔法:把架构约定,从 wiki 上一张会过期的图,变成一条会失败的测试。 业界给这种测试起了个名字,叫"架构适应度函数"(architecture fitness function),ArchUnit 是它在 Java 里最趁手的实现。


三、一份能直接抄的规则手册

ArchUnit 的 API 是流式的,读起来几乎是英文句子。下面这份手册覆盖了日常 90% 的需求,挑你项目最痛的几条抄走即可。

1. 分层依赖:守住调用方向

最经典的用法。声明各层,规定谁能被谁访问,反向调用直接红:

@ArchTest
static final ArchRule 分层架构 =
    layeredArchitecture().consideringOnlyDependenciesInLayers()
        .layer("Api").definedBy("..api..")
        .layer("Service").definedBy("..service..")
        .layer("Infra").definedBy("..infra..")
        .whereLayer("Api").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Api")
        .whereLayer("Infra").mayOnlyBeAccessedByLayers("Service");

2. 领域边界:防止模块互相缠绕

退款域不许碰履约域的实现,只能走对外接口:

@ArchTest
static final ArchRule 退款域不许直连履约实现 =
    noClasses().that().resideInAPackage("..refund..")
        .should().dependOnClassesThat().resideInAPackage("..fulfillment.infra..");

3. 无循环依赖:把环掐死在测试里

这是腐化最隐蔽的形式,人眼几乎看不出来,ArchUnit 用 slices 一句话搞定:

@ArchTest
static final ArchRule 模块之间不许循环依赖 =
    slices().matching("com.example.order.(*)..")
        .should().beFreeOfCycles();

4. 命名约定:让结构自解释

实现类必须叫 *ServiceImpl、Controller 必须叫 *Controller,AI 和新人就不会自创花名:

@ArchTest
static final ArchRule service实现类命名 =
    classes().that().resideInAPackage("..service..").and().areNotInterfaces()
        .should().haveSimpleNameEndingWith("ServiceImpl");

5. 注解约束:把"必须加 @Transactional"变成强制

比如规定所有 Service 的 public 写方法必须显式标注事务(这条要按项目情况裁剪):

@ArchTest
static final ArchRule 写服务必须标注事务 =
    methods().that().areDeclaredInClassesThat().resideInAPackage("..service..")
        .and().arePublic().and().haveNameMatching("(save|update|delete|refund).*")
        .should().beAnnotatedWith(Transactional.class);

6. 禁用项:把"不许这么写"写死

禁止 System.out、禁止 new Date()、禁止在领域层用框架注解……一类一条:

@ArchTest
static final ArchRule 禁止使用System_out =
    noClasses().should().accessClassesThat()
        .belongToAnyOf(System.class)
        .because("请用日志框架,并注意脱敏");

@ArchTest
static final ArchRule 禁止使用旧日期API =
    noClasses().should().dependOnClassesThat()
        .belongToAnyOf(java.util.Date.class, java.util.Calendar.class)
        .because("统一用 java.time");

注意那个 .because(...)——它会出现在报错里,等于在拦人的同时顺手解释了为什么。这对 AI 极其有用:它不仅知道"不能这么写",还知道"该怎么写"。


四、老项目怎么落地:先冻结,再止血

讲到这,做老项目的人心里在打鼓:"我这屎山里跨层调用几百处,规则一开全红,CI 直接瘫了,还怎么合并?"

这是 ArchUnit 最贴心的一个设计:FreezingArchRule,冻结存量违规,只拦新增。

把规则用 FreezingArchRule.freeze(...) 包一层,第一次运行时它会把当前所有违规记录到一个"违规清单"文件里当基线;以后这些存量违规不再报错,但任何新增的违规都会被拦下来

@ArchTest
static final ArchRule 分层架构_冻结存量 =
    FreezingArchRule.freeze(
        layeredArchitecture().consideringOnlyDependenciesInLayers()
            .layer("Api").definedBy("..api..")
            .layer("Service").definedBy("..service..")
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Api"));

这相当于给腐化按下了暂停键:老债慢慢还,新债一分不许欠。 你修好一处历史违规,基线就自动收紧一格,再也回不去——架构只会越来越干净。这对引入 AI 协作的老项目尤其关键,因为你最怕的就是 AI 在屎山上又快又稳地继续堆屎。

落地节奏建议:

  1. 先开 3~5 条最痛的规则(分层方向、领域边界、无循环依赖),全部 freeze
  2. 跑通 CI,确认存量被冻住、新增能被拦。
  3. 每次改到相关模块,顺手还几笔老债,基线自动收紧。

五、为什么它能"大幅"提升 harness

前面都是 how,这一节回答 why——为什么我说 ArchUnit 不是个小工具,而是能大幅提升 harness 水平的那一类。

harness 是你给 AI 准备的工作环境和约束系统。AI 的三个老毛病——失忆、看不到全局、不敢负责——恰好都能被 ArchUnit 对症下药:

AI 的毛病 没有 ArchUnit 有了 ArchUnit
失忆 AGENTS.md 里的约定它可能不读、读了也忘 约定变成测试,它绕不过去,一违反就红
看不到全局 它只看到你喂的几个文件,不懂整体结构 结构约束被显式断言,它不需要"看懂全局"也不会破坏全局
不敢负责 改坏了架构没人即时发现 CI 红灯即时拦截,责任由闸门兜底

再具体一点,它带来四个实打实的提升:

第一,把隐性知识变成硬约束。 团队里那些"大家都知道但没写下来"的规矩,是 AI 最大的盲区。ArchUnit 把它们变成代码,AI 和新人都不用靠悟性。

第二,控制 AI 的爆炸半径。 AI 改大功能最怕"牵一发动全身"。领域边界一旦被测试焊死,它即使犯错,也只能在一个房间里犯,炸不穿整栋楼。

第三,文档永不过期(living documentation)。 ArchUnit 规则本身就是最准确的架构文档——因为它一旦和代码不符,测试立刻就红。你再不用维护一张会骗人的 wiki 图。

第四,让"放手让 AI 干"变得可能。 harness 的终极目标,是你敢把活交出去。当架构纪律有自动闸门兜底,你才敢让 AI 大刀阔斧地重构老代码——绿灯还在,心里就有底。这一点,和我在《微服务之道:度量驱动开发》里反复讲的一个理是相通的:没有度量和约束的改进,都是自我感动;能自动失败的规则,才是真纪律。

一句话总结这一节:ArchUnit 的 ROI 之所以高,是因为它用一个测试库的成本,买到了一道AI 和人都绕不过去的架构防线。


六、几个坑,提前说

  • 别一上来写几十条。 规则越多越脆,先固化最常被破坏的那几条,剩下的按需加。
  • 规则要稳定。 规则本身别天天改,否则它就失去了"纪律"的意义,变成又一处需要维护的负担。
  • 包结构是基础。 ArchUnit 靠包来识别层和域,包乱了规则就难写。所以它和 DDD 的分包是一对好搭档——先把包理清,规则才好下笔。
  • 不是银弹。 它守的是结构和依赖,守不了业务逻辑对不对——那是 TDD/BDD 的活。几样配合才是完整的 harness。

总结

架构图会过期,老员工会离职,code review 会在赶工期时被跳过,AI 则会又快又稳地帮你把屎山堆得更高。靠人、靠自觉、靠记性来维持架构纪律,在 AI 时代已经撑不住了。

ArchUnit 给的答案朴素得近乎无聊:把架构约定写成会失败的测试。 但正是这份"无聊",让它成了提升 harness 的高 ROI 一招——它就是 JUnit,没有学习负担;它能冻结存量、只拦新增,对老项目友好;它把 AI 看不见的结构纪律,变成了 AI 也绕不过的红绿灯。

修 harness 这件事,本质是把"老员工脑子里的东西"搬到代码里。ArchUnit,就是专门搬"架构纪律"这一摞的那把铲子。

思维导图

@startmindmap
* ArchUnit 提升 Harness
** 问题
*** 架构腐化
*** 架构图会过期
*** 靠人/自觉/记性撑不住
*** AI 失忆/看不到全局/不负责
** 它是什么
*** 就是 JUnit
*** 纯测试库, 不进生产
*** 架构适应度函数
*** 原理: 导入->声明->断言
** 规则手册
*** 分层依赖方向
*** 领域边界
*** 无循环依赖 slices
*** 命名约定
*** 注解约束
*** 禁用项 because
** 老项目落地
*** FreezingArchRule
*** 冻结存量
*** 只拦新增
*** 老债慢慢还
** 为什么大幅提升
*** 隐性知识变硬约束
*** 控制爆炸半径
*** 文档永不过期
*** 敢放手让AI干
** 坑
*** 别写太多
*** 规则要稳定
*** 先理包结构
*** 不守业务逻辑
@endmindmap

ArchUnit 提升 Harness 思维导图

行动清单

  1. 今天加上 archunit-junit5 依赖,建一个 ArchitectureTest
  2. 先写 3 条最痛的规则:分层方向、领域边界、无循环依赖。
  3. 老项目就用 FreezingArchRule.freeze(...) 包起来,冻结存量、只拦新增。
  4. 给关键禁用规则补上 .because(...),让报错顺手教 AI 怎么改。
  5. mvn test(含 ArchUnit)接进 CI,任意一条红就不许合并。

扩展阅读


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。