📦 本文基于的完整项目源码:meirongdev/shop

背景:在 code agent 时代重新谈 ArchUnit#

这两年很多团队都在引入 code agent。它们写代码的速度很快,能补样板、改依赖、重构包结构、批量修复低级问题,但也因此更容易把"本来只存在于文档和 review 习惯里的约束"绕过去。

下面这些问题,人在赶时间时会犯,agent 在缺少硬约束时也会犯:

  • 为了省事直接加 @Autowired 字段注入
  • 在 BFF 里偷偷落一个 @Entity
  • Controller 直接访问 Repository
  • Kafka consumer 没有幂等保护
  • 契约模块慢慢混入 Spring Web / JPA 依赖

这些都不是"功能对不对"的问题,而是"代码还在不在团队允许的边界里"的问题。在我看来,ArchUnit 的价值就在这里:它把架构约束从口头约定变成可执行测试。

这个视角并不是孤立的观察。Birgitta Böckeler 在 2026 年 2 月的 Harness Engineering, First Thoughts 里把这种"给 agent 搭结构化反馈 harness"的工程学命名了出来,并明确问到:“Have you experimented with structural testing frameworks like ArchUnit?” 同一时期,Sasha Podlesniuk 在 2026 年 1 月的 From Prompts to Invariants: Re-architecting Systems with ArchUnit and LLMs 里具体写了怎么把 ArchUnit 的失败信息当作 agent 的反馈信号。更早的概念根源是 Neal Ford 在《Building Evolutionary Architectures》里提出的 fitness function——结构化测试就是最典型的、可执行的 fitness function。

本文不是在提出一个新概念,而是把当前 shop 项目在 Spring Boot + 微服务 monorepo 场景下的具体实现记录下来。在 code agent 时代,ArchUnit 在我的实践中扮演的角色是:

  • 对人来说,它是自动化的架构 review
  • 对 agent 来说,它是持续可执行的 guardrail
  • 对 CI 来说,它是可以阻断错误演化的质量门禁

ArchUnit 解决什么问题#

ArchUnit 最适合处理那些"稳定、结构化、可自动判断"的规则。它不替代设计讨论,也不替代业务测试,但非常适合守住工程边界。

问题 只靠文档和 review 的结果 ArchUnit 能做什么
编码规范漂移 同一团队写出多种风格,review 成本越来越高 统一禁止某些 API、注解和依赖方式
分层被破坏 Controller 越权、Service 感知 HTTP、包循环悄悄出现 检查包依赖、访问方向和循环依赖
服务边界失守 BFF 逐渐变成半个领域服务,contract 模块变重 检查模块职责和依赖污染
异步消费不安全 Kafka listener 重复消费、事务边界错误 对 listener、事务、幂等守护做硬约束
历史债务长期悬空 规则写不起来,因为"一上就全红" 通过渐进引入策略先止血,再逐步收敛

在我的经验里,ArchUnit 主要解决的是"架构约束不可执行"这个问题

需要澄清两件事:

  • ArchUnit 不管语法和格式:那是 Spotless / google-java-format 的活;也不管常见 bug 模式,那是 SpotBugs / Sonar 的活。ArchUnit 管的是结构
  • ArchUnit 和 Spring Modulith 不是替代关系:Spring Modulith 的模块验证(ApplicationModules.verify())提供了"Spring 应用内部模块边界"的开箱即用规则,并且支持与 jMolecules-ArchUnit 集成来做额外的结构断言(官方文档);ArchUnit 留给你定义那些 Modulith 不包含的自定义约束。两者互补。

5 分钟跑通一个 ArchUnit 示例#

在讨论"怎么组织"之前,先让它跑起来。下面这套可以直接放进任何现有 Maven + JUnit 5 项目。

1. 引入依赖#

截止 2026 年 4 月,最新稳定版是 1.4.2(2026-04-18 发布,支持 Java 26,新增了 anyParameterThat / allParameters 等预设 predicate)。

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

2. 写一个完整的测试类#

放在 src/test/java/com/example/architecture/ArchitectureRulesTest.java

package com.example.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields;

@AnalyzeClasses(
        packages = "com.example",
        importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureRulesTest {

    @ArchTest
    static final ArchRule no_field_injection = noFields()
            .should().beAnnotatedWith(
                    "org.springframework.beans.factory.annotation.Autowired")
            .because("prefer constructor injection");

    @ArchTest
    static final ArchRule no_standard_streams = noClasses()
            .should().accessField(System.class, "out")
            .orShould().accessField(System.class, "err")
            .because("use a logger, not System.out/err");
}

两个注解就是全部入口:

  • @AnalyzeClasses 控制扫描哪些包、要不要包含测试代码。生产规则通常更适合用 DoNotIncludeTests,因为 harness 守护的是运行时代码。
  • @ArchTest 标记一条规则。static final ArchRule 字段会被 ArchUnit 自动注册为一个 JUnit 测试用例,失败时每条规则单独报错。

3. 跑起来#

./mvnw test

违规时的输出大致是这样:

Architecture Violation [Priority: MEDIUM]
- Rule 'no fields should be annotated with @Autowired,
   because prefer constructor injection' was violated (1 times):
  Field <com.example.order.OrderService.repository> is annotated with
  @Autowired in (OrderService.java:23)

它会精确告诉你哪一行违规。后面所有规则失败时都长这样,不需要额外学习。

应该写哪些规则:按价值分四层#

会写一条之后,问题就变成"写什么"。我的经验是按价值分四层,由易到难,先从第 1 层开始,项目成熟了再往下推。

第 1 层:跨服务共性规则(低争议、长期稳定)#

这一层规则"跨服务、低争议、长期稳定"。无论 gateway、BFF 还是 domain service 都该遵守。

@ArchTest
static final ArchRule no_field_injection = noFields()
        .should().beAnnotatedWith(
                "org.springframework.beans.factory.annotation.Autowired")
        .because("prefer constructor injection");

@ArchTest
static final ArchRule no_rest_template = noClasses()
        .should().dependOnClassesThat()
        .haveFullyQualifiedName("org.springframework.web.client.RestTemplate")
        .because("RestTemplate is in maintenance mode; "
                + "use RestClient or @HttpExchange clients instead");

@ArchTest
static final ArchRule no_gson = noClasses()
        .should().dependOnClassesThat()
        .resideInAPackage("com.google.gson..")
        .because("use Jackson as the only serializer");

第 2 层:分层与依赖方向(守住服务内部)#

微服务里的很多腐化不是发生在"服务之间",而是先发生在"服务内部"。分层规则是性价比最高的第二步。

@ArchTest
static final ArchRule controller_should_not_access_repository = noClasses()
        .that().resideInAPackage("..controller..")
        .should().accessClassesThat().resideInAPackage("..repository..")
        .because("Controller must go through Service");

@ArchTest
static final ArchRule service_should_not_depend_on_controller = noClasses()
        .that().resideInAnyPackage("..service..", "..engine..")
        .should().dependOnClassesThat().resideInAPackage("..controller..")
        .because("domain layer must not know about HTTP layer");

⚠️ 顺带点一个坑:ArchUnit 内置的 layeredArchitecture() DSL 只认包,不认 Maven / Gradle 模块——也就是说,“domain 模块不能依赖 web 模块"这类基于模块的分层不能直接用它表达(Issue #487)。要用 noClasses().that().resideInAPackage(...) 手写,像上面这样。

循环依赖用 Slices 一句话搞定:

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@ArchTest
static final ArchRule no_cycles = slices()
        .matching("dev.meirong.shop.(*)..")
        .should().beFreeOfCycles();

第 3 层:模块职责边界(microservice / monorepo 特有)#

这一步是微服务项目和普通 Web 项目最大的差别。你可以直接把团队对"某类服务应该是什么"的理解写进规则。

// BFF 聚合和编排即可,不应该持有数据库实体
@ArchTest
static final ArchRule bff_should_not_hold_entities = noClasses()
        .that().resideInAnyPackage(
                "dev.meirong.shop.buyerbff..",
                "dev.meirong.shop.sellerbff..")
        .should().beAnnotatedWith("jakarta.persistence.Entity");

// contract 模块最好保持轻量,不应该带上完整 Spring 运行时
@ArchTest
static final ArchRule contracts_should_stay_lightweight = noClasses()
        .that().resideInAPackage("dev.meirong.shop.contracts..")
        .should().dependOnClassesThat().resideInAnyPackage(
                "org.springframework.web..",
                "org.springframework.data..",
                "org.springframework.kafka..",
                "jakarta.persistence..")
        .because("contracts must not ship a full Spring runtime to consumers");

这类规则价值最高,因为它们保护的是仓库的系统分工,而不是单个类的写法。

第 4 层:异步与一致性(业务语义规则)#

如果系统用了 Kafka / Outbox / 异步消费 / 补偿任务,这部分规则往往最值钱——业务后果很重,但判断条件又足够结构化。

当前 shop 项目的规则是:@KafkaListener 所在类需要注入 IdempotencyGuard,或者明确标注 @IdempotencyExempt;调用 IdempotencyGuard.executeOnce() 的方法需要标注 @Transactional

@ArchTest
static final ArchRule idempotency_guard_callers_must_be_transactional =
        methods()
                .that(callIdempotencyGuardExecuteOnce())
                .should().beAnnotatedWith(
                        "org.springframework.transaction.annotation.Transactional")
                .because("IdempotencyGuard callers must run inside a @Transactional");

callIdempotencyGuardExecuteOnce() 是一个自定义 predicate,下一节展开。

把业务规则写成 ArchUnit:自定义 predicate#

内置规则够用到第 3 层,但"调用了某个业务方法的所有位置必须具备某个标注"这种规则,就需要自己写 predicate。结构如下:

import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaMethod;

private static DescribedPredicate<JavaMethod> callIdempotencyGuardExecuteOnce() {
    return new DescribedPredicate<>("call IdempotencyGuard.executeOnce()") {
        @Override
        public boolean test(JavaMethod method) {
            return method.getMethodCallsFromSelf().stream()
                    .anyMatch(call ->
                            call.getTargetOwner().getFullName()
                                .equals("dev.meirong.shop.common.IdempotencyGuard")
                         && call.getName().equals("executeOnce"));
        }
    };
}

关键点:

  • DescribedPredicate 的第一个参数(描述字符串)会拼进最终规则文本,失败时读起来像自然语言。
  • JavaMethodJavaClassJavaField 都可以作为 predicate 的泛型——ArchUnit 提供了一套完整的元模型,能遍历方法体里的调用、访问、注解、继承关系。
  • 一般只需要 20 行,调用 method.getMethodCallsFromSelf()getAnnotations()getRawParameterTypes() 这类 API 就能表达大多数规则。
  • ArchUnit 1.4.2 起新增了 JavaCodeUnit.Predicates.anyParameterThat / allParameters,写"所有参数都满足 X"这类规则不用再自己 stream 了(1.4.2 release notes)。

自定义 predicate 是把"业务知识"写进 harness 的入口。一旦你有了这一手,ArchUnit 的表达力会从"写得好的 lint"跳到"可执行的架构约定”。

引入历史项目:用 FreezingArchRule 渐进式收敛#

新项目可以一开始就上规则,历史项目通常不太适合这么做——一上全红,PR 很难推进。

FreezingArchRule 是 ArchUnit 官方的渐进式推进方案。codecentric 2024 年的实战总结 ArchUnit in Practice 直接把它称为 “the freeze feature is extremely useful and perhaps the most important one when it comes to integrating ArchUnit into existing projects.”

它把当前所有违规存进一份"快照",之后:

  • 新增违规 → 规则失败
  • 历史违规 → 仍然放行
  • 修掉历史违规 → 自动从快照里减掉

用法只多一行包装:

import com.tngtech.archunit.library.freeze.FreezingArchRule;

@ArchTest
static final ArchRule no_field_injection = FreezingArchRule.freeze(
        noFields().should().beAnnotatedWith(
                "org.springframework.beans.factory.annotation.Autowired")
);

配合 src/test/resources/archunit.properties

freeze.store.default.path=src/test/resources/archunit_store
freeze.refreeze=false
  • 第一次跑:ArchUnit 把所有违规写进 archunit_store/ 目录。
  • 之后每次跑:只阻止新增违规。
  • 修了历史违规:快照文件会自动收紧。
  • 规则变了要重做快照:临时把 freeze.refreeze 改成 true 跑一次。

archunit_store/ 提交进仓库,快照就是团队共享的债务清单。修一个违规就等于还一点债,看得见、回不去。

⚠️ 一个实际踩过的坑:默认的文件式 ViolationStore 在多分支并行开发时会在 stored.rules 上出现 merge conflict。解决办法有两个:一是每条规则拆成独立快照文件(ArchUnit 默认会按规则文本做哈希分文件),二是实现自定义 ViolationStore(接口就 6 个方法,存 Redis、存 DB 都可以)。

关于 .allowEmptyShould:一个容易踩错的默认#

原先我倾向每条规则都加 .allowEmptyShould(true)。查完 ArchUnit 官方文档和实战资料后,这其实是反模式

官方 User Guide 直言:“By default ArchUnit will forbid the should-part of rules to be evaluated against an empty set of classes. The reason is that this can lead to rules that by accident do not check any classes at all.” codecentric 的 Keller 举了一个典型例子:规则写成 noClasses().that().resideInAPackage("..services..")...,而有人把 services 改成 service,规则针对的类集合变空——不加 .allowEmptyShould(true) 时 ArchUnit 会失败并明确告诉你"没匹配到任何类",这是一个想要的保护;加了反而会让规则静默通过。

所以正确的使用时机只有一个:共享规则库被多个服务/repo 复用,而某些服务合法地没有目标类型(下一节展开)。在单仓或整仓扫描的场景里,空集合几乎都是 bug,应该让测试响亮地失败。

项目组织:三种形态的落地方式#

按复杂度从低到高,有三种组织方式。

形态 A:普通 repo,直接放进主工程 test 目录#

单仓单应用最省事——不需要独立模块,跟业务代码一起演进,./mvnw test 就能跑到。

my-service/
├── src/
│   └── test/
│       └── java/
│           └── com/example/architecture/
│               ├── ArchitectureRulesTest.java
│               ├── LayeringRulesTest.java
│               └── NamingRulesTest.java
└── pom.xml

起步别贪多,5 到 8 条就够了。规则来源优先从本项目真实痛点反推:review 中反复提的点、重构时最容易回退的边界、新同学最容易踩坑的地方、code agent 最容易生成错的写法。

形态 B:多模块项目,放在根 pom 或独立 test 模块#

项目长成多模块之后,规则开始涉及跨模块边界。这时候可以在根 pom 的 src/test/java 下写规则,依赖所有子模块;或者建一个独立的 architecture-tests 子模块。

形态 C:微服务 monorepo,独立的 architecture-tests 模块#

当前 shop 项目用的就是这一种。规则集中写一次,整仓统一扫描。

需要先说清楚:这不是 ArchUnit 社区的钦定标准。Maven 多模块下到底怎么组织架构测试,目前官方没有给出定论——Issue #962(“Best practices for Maven multi-module…") 从 2022 年开到现在,维护者没有最佳实践答复。所以下面是"我们选的一种做法”,不是"业界共识"。

选这种做法的理由是它解决了一个具体问题:如果使用 Surefire 的 dependenciesToScan,被依赖的模块会在每个上游模块的测试阶段被重复扫一遍。而一个集中模块只 import 一次,全仓一次性扫完。

目录结构:

shop/
├── shared/
│   ├── shop-common/
│   └── shop-contracts/
├── services/
│   ├── auth-server/
│   ├── api-gateway/
│   ├── buyer-bff/
│   └── ...
├── tooling/
│   └── architecture-tests/
│       ├── src/test/java/dev/meirong/shop/architecture/
│       │   ├── ArchitectureRulesTest.java
│       │   ├── CodingRulesTest.java
│       │   ├── LayeringRulesTest.java
│       │   ├── NamingRulesTest.java
│       │   └── SpringRulesTest.java
│       ├── src/test/resources/archunit.properties
│       └── pom.xml
└── pom.xml

关键点在 tooling/architecture-tests/pom.xml——它需要依赖所有待扫描模块,ArchUnit 才能从 classpath 上看到字节码:

<dependencies>
  <!-- 让这个模块"看得见"所有待扫描模块 -->
  <dependency>
    <groupId>dev.meirong.shop</groupId>
    <artifactId>shop-common</artifactId>
    <version>${project.version}</version>
  </dependency>
  <dependency>
    <groupId>dev.meirong.shop</groupId>
    <artifactId>shop-contracts</artifactId>
    <version>${project.version}</version>
  </dependency>
  <dependency>
    <groupId>dev.meirong.shop</groupId>
    <artifactId>auth-server</artifactId>
    <version>${project.version}</version>
  </dependency>
  <!-- api-gateway / buyer-bff / seller-bff / 各 domain service ... -->

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

当前实现按关注点拆成 5 个测试类,全部用统一入口:

@AnalyzeClasses(
        packages = "dev.meirong.shop",
        importOptions = ImportOption.DoNotIncludeTests.class)
class LayeringRulesTest { }

这种组织方式的好处:规则只写一份、能写跨模块边界(“BFF 不得持有 @Entity"只有在扫描全仓时才表达得出来)、规则目录 + 规则实现双轨治理(配套一份 docs/ARCHUNIT-RULES.md)。

跨 repo 共享规则:发布成 Maven 制品#

如果你的微服务是多 repo 架构,形态 C 行不通——每个 repo 看不到彼此的代码。ArchUnit 官方给出的方案是 ArchTests.in(...),把规则集打包成一个类,各服务继承复用。维护者在 Discussion #1213 里也直接点名了"发布成共享 jar"这一条路径。

第一步:公共规则库(独立 repo / 独立 artifact)#

// company-archunit-rules
package com.company.platform.archunit;

public final class PlatformArchitectureRules {

    @ArchTest
    public static final ArchRule NO_FIELD_INJECTION = noFields()
            .should().beAnnotatedWith(
                    "org.springframework.beans.factory.annotation.Autowired")
            .because("prefer constructor injection");

    @ArchTest
    public static final ArchRule NO_REST_TEMPLATE = noClasses()
            .should().dependOnClassesThat()
            .haveFullyQualifiedName("org.springframework.web.client.RestTemplate");

    // 这条规则有条件成立:消费方可能是纯后台服务,没有 @Controller
    @ArchTest
    public static final ArchRule CONTROLLER_NAMING = classes()
            .that().areAnnotatedWith(
                    "org.springframework.web.bind.annotation.RestController")
            .should().haveSimpleNameEndingWith("Controller")
            .allowEmptyShould(true);

    private PlatformArchitectureRules() {}
}

发布到内部 Maven 仓库,例如 com.company.platform:archunit-rules:1.2.0

这里就是 .allowEmptyShould(true)正确使用场景:平台规则库发布给 30 个服务,其中一部分没有 Web 层,Controller 相关规则在那些服务里应该合法地"匹配不到任何类”。

第二步:每个服务引入 + 一行接入#

<dependency>
  <groupId>com.company.platform</groupId>
  <artifactId>archunit-rules</artifactId>
  <version>1.2.0</version>
  <scope>test</scope>
</dependency>
import com.tngtech.archunit.junit.ArchTests;

@AnalyzeClasses(
        packages = "com.company.myservice",
        importOptions = ImportOption.DoNotIncludeTests.class)
class MyServiceArchitectureTest {

    // 一行继承整套平台规则
    @ArchTest
    static final ArchTests platform = ArchTests.in(PlatformArchitectureRules.class);

    // 本服务独有的规则照样写
    @ArchTest
    static final ArchRule orders_must_not_leak_to_billing = noClasses()
            .that().resideInAPackage("..orders..")
            .should().dependOnClassesThat().resideInAPackage("..billing..");
}

ArchTests.in(...) 会把 PlatformArchitectureRules 里所有 @ArchTest 字段按当前服务的 @AnalyzeClasses 扫描范围重新执行一遍,失败时每条规则仍然单独报错。

为什么不是 git submodule、也不是 copy-paste#

在遇到"跨 repo 复用规则"需求时,另外两种直觉做法都不好:

  • Copy-paste:规则一升级要改 N 个 repo,且版本不可追踪。
  • Git submodule:版本和业务 commit 耦合,CI 缓存失效成本高,新成员学习曲线陡。
  • 发布成 jar:version 是一等公民,mvn dependency:tree 能直接看到哪个服务停在哪个规则版本;服务可以选择不升级(团队自治),也可以被强制推升级(在 parent pom 里 bump);IDE 点进去能看到源代码,不需要额外工具。

vitech-team 在 arch-tests 里公开了一个真实可参考的样板。

Monorepo 还是 Polyrepo:按治理优先级决定#

读到这里你会发现,形态 C(monorepo 集中扫描)和发布共享 jar 这两种路径在架构治理上差别很大。到底选哪种,本质上是 monorepo vs polyrepo 的取舍。这件事 2026 年仍然没有标准答案。

倾向 monorepo 的公司

  • Shopify 在 2020 年的 Under Deconstruction 到 2026 年仍被大量引用:Packwerk(Ruby 的类 ArchUnit 工具)就是为模块边界强制而存在的——“单个中心团队推不动规范,自动化工具才能推”。
  • Google 是经典 monorepo。

倾向 polyrepo 的公司

  • Netflix 和 Spotify 仍然是 polyrepo 的典型代表(Spotify 的 Backstage 是 monorepo,但业务服务是 polyrepo)。主要理由是团队自治和发布独立性。

2026 年有一个新变量是 AI 编码。Karl Cardenas 在 2026 年 1 月的 Will AI turn 2026 into the year of the monorepo? 里提了一个观察:AI assistant 在单一连贯的 codebase 里工作时上下文更完整,monorepo 对 agent 友好。但他也明确不下结论:“the correct answer is what makes the most sense for you and your team.”

就 ArchUnit 作为 harness 这个视角而言,两种做法的取舍如下:

维度 Monorepo + 集中 architecture-tests Polyrepo + 共享规则 jar
规则升级 改完立刻全仓生效 发版 → 各服务按自己节奏升版本
跨服务边界规则 天然支持(“BFF 不得持 @Entity”) 做不到,扫描是单服务范围
服务自治 弱,所有服务绑一起升 强,服务可选择留在旧版本
快照 / freeze 整仓一份 每服务一份
治理推进阻力 低,一次 PR 推全仓 高,需要推每个团队升
CI 总时长 偏长(单仓所有服务) 偏短(单服务)

我的经验法则:如果架构治理是首要目标(强合规、大量 junior 工程师、agent 生成代码占比高),monorepo 集中式更合适。如果团队自治和发布独立性优先,polyrepo + 共享 jar 也完全站得住脚。

接入 CI#

规则只有跑在 CI 上才算 harness。当前项目通过 Makefile 统一入口:

arch-test:
	./mvnw -q -pl tooling/architecture-tests -am test

-am 的作用是 also-make:自动编译 architecture-tests 依赖的所有模块。没有这个参数,第一次干净 clone 下来会报找不到类。

GitHub Actions 里一步就能接上:

- name: Architecture tests
  run: make arch-test

失败时 ArchUnit 的输出会直接展示在 CI log 里,带类名和行号,和普通 JUnit 失败一样。

局限与生态#

坦诚地列一下局限,避免把它吹成银弹。

ArchUnit 本身的盲区:

  • 看不到 Retention.SOURCE 注解:Lombok 的 @RequiredArgsConstructor 这种源码级注解,ArchUnit 在字节码层根本扫不到。需要针对 Lombok 用法的规则,用不了 ArchUnit。
  • layeredArchitecture() 只认包不认模块:上面提过,Issue #487 是长期限制,要用手写规则绕过。
  • Maven 多模块的组织模式没有官方指导Issue #962 仍然没结论。社区各种做法并存。
  • FreezingArchRule 默认存储的 merge 问题:前面提过,跨分支时 stored.rules 容易冲突,需要自定义 ViolationStore 或拆分存储。

生态里值得一起知道的几个工具:

  • Spring Modulith:Spring 应用内模块验证的开箱即用选项。底层是 ArchUnit,所以不冲突,而是 Modulith 管自己那部分,ArchUnit 管所有 Modulith 不覆盖的规则。
  • Konsist:Kotlin 生态的类 ArchUnit 工具。关键差别是它解析 Kotlin 源码,ArchUnit 解析 JVM 字节码——这让 Konsist 能看到 Retention.SOURCE 注解、Kotlin DSL、KMP 独有构造。有 Kotlin 重度工作量的团队可以看 Mercedes-Benz.io 2024 年的 Konsist: The Game-Changer
  • jQAssistant:通过把代码导入 Neo4j 用 Cypher 查询,表达力最强但学习曲线最陡。适合做架构取证,不适合做 CI 日常 gating。
  • Packwerk(Ruby):Shopify 的同类工具,思路一致——“让架构边界变成可执行测试"这条路径在非 Java 生态也是业界验证过的。

小结#

  • 我更看重 ArchUnit 的价值,不是“可以写架构测试”,而是把团队架构约束变成 CI 可执行的 harness——这个框架被 Böckeler(2026)和 Podlesniuk(2026)讨论过,概念根源是 Neal Ford 的 fitness functions。
  • 先让它跑起来,再按四层价值推进:共性规则 → 分层依赖 → 模块边界 → 异步一致性。
  • 想把业务语义写成规则,就用自定义 DescribedPredicate,这是把团队知识固化进 harness 的关键一步。
  • 老项目别硬上,FreezingArchRule 是"先止血、再还债"的标准工具;把快照提交进仓库。
  • .allowEmptyShould(true) 不是"保险起见总加上”,通常不建议默认加,只在共享规则库被多服务复用、目标类型合法可能不存在时才打开。
  • 单仓 → 多模块 → monorepo architecture-tests → polyrepo + 共享 jar,按项目复杂度和治理优先级选择。monorepo 和 polyrepo 这个取舍 2026 年仍然没有定论,按架构治理的重要程度来判断。
  • 在 code agent 普及之后,ArchUnit 的角色会更像 guardrail:它既约束人,也约束自动生成的代码。

参考资料#

Harness / Fitness Function 框架

ArchUnit 官方资料

实战经验

Monorepo vs Polyrepo(2024–2026)

Spring Modulith / Konsist

相关文章#