共用 ArchUnit 規則模組實作指南

面向 Java / Spring 微服務生態系的架構治理完整實踐手冊


目錄

  1. 為什麼需要共用模組
  2. 模組結構設計
  3. Maven/Gradle 設定
  4. 共用規則的寫法
  5. 微服務端的整合方式
  6. 版本管理與發布策略
  7. 可擴展性設計
  8. CI/CD 整合
  9. 完整程式碼範例
  10. 常見陷阱與最佳實踐

1. 為什麼需要共用模組

1.1 痛點

在微服務架構中,一個組織可能有數十到數百個 Java 微服務。如果每個服務各自維護 ArchUnit 架構規則,會面臨以下問題:

  • 規則散落在各服務中:命名慣例、分層依賴、禁止使用 deprecated API 等規則在各處重複定義
  • 維護成本高:改一條架構慣例需要同步修改數十個 repo
  • 一致性難以保證:各團隊對「什麼是好架構」的理解不同,規則表達也不一致
  • 新人上手困難:不了解各服務的架構慣例,容易踩雷

1.2 共用模組帶來的價值

根據 TNG/ArchUnit 官方討論 (GitHub Discussion #1213) 和 Medium 文章 (Tijl Bormans, 2024):

  • 單一事實來源:架構規則集中定義在一個 JAR 中,所有微服務共用
  • 集中維護:規則升級只需發布新版 JAR,各微服務更新版本號即可
  • 一致性:所有微服務遵循相同的架構慣例
  • 可審計:架構治理變成可版本控制、可追溯的制品

1.3 實際案例

組織 規模 做法
Netflix 5,000+ repos, 358+ rules 使用 Nebula ArchRules 插件系統,透過 Gradle plugin 自動發現並執行規則
Société Générale 開源 arch-unit-maven-plugin Maven 插件方式,將規則打包成 JAR 作為 plugin dependency
Spring 官方 Spring Modulith 底層使用 ArchUnit 驗證模組邊界,規則內建於框架

2. 模組結構設計

2.1 推薦的目錄結構

company-archunit/
├── pom.xml                          # 父 POM(BOM)
├── company-archunit-core/           # 核心規則模組
│   ├── pom.xml
│   └── src/
│       ├── main/java/
│       │   └── com/company/archunit/
│       │       ├── rules/           # 架構規則定義
│       │       │   ├── LayeredArchitectureRules.java
│       │       │   ├── NamingConventionRules.java
│       │       │   ├── DependencyRules.java
│       │       │   ├── SpringSpecificRules.java
│       │       │   └── GeneralCodingRules.java
│       │       ├── predicates/      # 自訂 Predicates
│       │       │   └── CompanyPredicates.java
│       │       ├── conditions/      # 自訂 Conditions
│       │       │   └── CompanyConditions.java
│       │       ├── config/          # 規則配置類
│       │       │   └── RuleConfiguration.java
│       │       └── suite/           # 規則套件(整合入口)
│       │           └── CompanyArchRules.java
│       └── main/resources/
│           └── archunit.properties  # 預設配置
├── company-archunit-spring-boot-starter/  # Spring Boot Starter(可選)
│   ├── pom.xml
│   └── src/
│       ├── main/java/
│       │   └── com/company/archunit/autoconfigure/
│       │       └── ArchUnitAutoConfiguration.java
│       └── main/resources/
│           └── META-INF/spring/
│               └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── company-archunit-sample/         # 使用範例
    ├── pom.xml
    └── src/test/java/
        └── com/company/archunit/sample/
            └── SampleArchitectureTest.java

2.2 套件命名慣例

套件 用途 備註
com.company.archunit.rules 所有架構規則類 每個類別包含一組相關規則
com.company.archunit.predicates 自訂 DescribedPredicate 用於 that() 子句
com.company.archunit.conditions 自訂 ArchCondition 用於 should() 子句
com.company.archunit.config 規則配置 允許微服務端覆蓋預設行為
com.company.archunit.suite 規則套件整合 用 ArchTests.in() 組合所有規則

2.3 Maven 坐標命名

<groupId>com.company</groupId>
<artifactId>company-archunit-core</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

3. Maven/Gradle 設定

3.1 父 POM(BOM / dependencyManagement)

共用模組的版本統一管理,避免各微服務自行指定 ArchUnit 版本:

<!-- company-archunit/pom.xml -->
<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.company</groupId>
    <artifactId>company-archunit</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>company-archunit-core</module>
        <module>company-archunit-spring-boot-starter</module>
        <module>company-archunit-sample</module>
    </modules>

    <properties>
        <java.version>17</java.version>
        <archunit.version>1.3.0</archunit.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- ArchUnit 版本鎖定 -->
            <dependency>
                <groupId>com.tngtech.archunit</groupId>
                <artifactId>archunit</artifactId>
                <version>${archunit.version}</version>
            </dependency>
            <dependency>
                <groupId>com.tngtech.archunit</groupId>
                <artifactId>archunit-junit5</artifactId>
                <version>${archunit.version}</version>
            </dependency>
            <dependency>
                <groupId>com.tngtech.archunit</groupId>
                <artifactId>archunit-junit5-api</artifactId>
                <version>${archunit.version}</version>
            </dependency>

            <!-- 共用模組 -->
            <dependency>
                <groupId>com.company</groupId>
                <artifactId>company-archunit-core</artifactId>
                <version>${project.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.2.5</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

3.2 共用模組 POM(company-archunit-core)

關鍵:ArchUnit 相關依賴用 compile scope(不是 test),因為規則要被打包成 JAR 供其他專案引用:

<!-- company-archunit-core/pom.xml -->
<project>
    <parent>
        <groupId>com.company</groupId>
        <artifactId>company-archunit</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>company-archunit-core</artifactId>

    <dependencies>
        <!-- ArchUnit 核心(compile scope,非 test) -->
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit</artifactId>
        </dependency>

        <!-- ArchUnit JUnit 5 支援 -->
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5-api</artifactId>
        </dependency>
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5</artifactId>
        </dependency>

        <!-- JUnit 5(test scope,用於共用模組自身的測試) -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

3.3 微服務端 POM(消費者)

<!-- 在微服務的 pom.xml 中 -->
<dependencies>
    <!-- 測試範圍引用共用規則 -->
    <dependency>
        <groupId>com.company</groupId>
        <artifactId>company-archunit-core</artifactId>
        <version>1.0.0</version>
        <scope>test</scope>
    </dependency>

    <!-- ArchUnit JUnit 5(如果微服務尚未引入) -->
    <dependency>
        <groupId>com.tngtech.archunit</groupId>
        <artifactId>archunit-junit5</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3.4 Gradle 設定(對應方式)

// build.gradle.kts - 微服務端
plugins {
    java
}

dependencies {
    testImplementation("com.company:company-archunit-core:1.0.0")
    testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}

3.5 Maven Surefire dependenciesToScan(進階方式)

如果想讓共用模組的測試自動在微服務中執行,不需要手寫測試類別,可以使用 Surefire 的 dependenciesToScan:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <dependenciesToScan>
            <dependency>com.company:company-archunit-core</dependency>
        </dependenciesToScan>
    </configuration>
</plugin>

但要注意:dependenciesToScan 會讓微服務掃描所有依賴模組的 classes(不只是自己專案的),可能導致效能問題。建議搭配 importOptions = DoNotIncludeArchives.class 或自訂 LocationProvider。


4. 共用規則的寫法

4.1 命名慣例規則

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public final class NamingConventionRules {

    private NamingConventionRules() {}

    /** 所有 Exception 子類必須以 Exception 結尾 */
    public static final ArchRule EXCEPTIONS_MUST_END_WITH_EXCEPTION =
        classes()
            .that().areAssignableTo(Exception.class)
            .should().haveSimpleNameEndingWith("Exception")
            .as("所有 Exception 子類必須以 Exception 結尾");

    /** 所有 Interface 必須以 Service / Repository / Client / Manager 結尾 */
    public static final ArchRule INTERFACES_MUST_FOLLOW_NAMING =
        classes()
            .that().areInterfaces()
            .and().resideInAPackage("..service..")
            .or().resideInAPackage("..repository..")
            .or().resideInAPackage("..client..")
            .should().haveSimpleNameEndingWith("Service", "Repository", "Client", "Manager")
            .as("Service/Repository/Client/Manager 介面必須遵循命名慣例");

    /** Entity 類必須以 Entity 結尾 */
    public static final ArchRule ENTITY_MUST_END_WITH_ENTITY =
        classes()
            .that().resideInAPackage("..entity..")
            .should().haveSimpleNameEndingWith("Entity")
            .as("Entity 類必須以 Entity 結尾");
}

4.2 分層架構規則

使用 ArchUnit 的 LayeredArchitecture API,搭配通配符匹配不同微服務的套件結構:

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.Architectures;

import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

public final class LayeredArchitectureRules {

    private LayeredArchitectureRules() {}

    /**
     * 標準分層架構規則。
     * 使用 * 作為微服務名稱的占位符,例如:
     * com.mycompany.order.rest → com.mycompany.*.rest
     */
    public static final ArchRule STANDARD_LAYERED_ARCHITECTURE =
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("Rest").definedBy("..rest..")
            .layer("Service").definedBy("..service..")
            .layer("Domain").definedBy("..domain..")
            .layer("Repository").definedBy("..repository..")
            .layer("Config").definedBy("..config..")

            .whereLayer("Rest").mayOnlyBeAccessedByLayers("Config")
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Rest", "Config")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository", "Config")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service", "Config")
            .whereLayer("Config").mayNotBeAccessedByAnyLayer()

            .as("標準分層架構:Rest → Service → Domain ← Repository");
}

4.3 依賴規則

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.core.domain.JavaClass;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.lang.conditions.JavaClassPredicates.*;

public final class DependencyRules {

    private DependencyRules() {}

    /** Controller 不得直接依賴 Repository(必須透過 Service) */
    public static final ArchRule CONTROLLERS_MUST_NOT_DEPEND_ON_REPOSITORIES =
        noClasses()
            .that().resideInAPackage("..rest..")
            .should().dependOnClassesThat()
            .resideInAPackage("..repository..")
            .as("Controller 不得直接依賴 Repository");

    /** Domain 層不得依賴 Infra 層 */
    public static final ArchRule DOMAIN_MUST_NOT_DEPEND_ON_INFRA =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infra..")
            .as("Domain 層不得依賴 Infra 層");

    /** 不得使用 java.util.Date(應使用 java.time) */
    public static final ArchRule NO_JAVA_UTIL_DATE =
        noClasses()
            .should().dependOnClassesThat()
            .resideInAnyPackage("java.util.Date")
            .as("不得使用 java.util.Date,請使用 java.time");

    /** 不得使用 System.out / System.err */
    public static final ArchRule NO_STANDARD_STREAMS =
        com.tngtech.archunit.library.GeneralCodingRules
            .NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;

    /** 不得使用 Joda-Time */
    public static final ArchRule NO_JODA_TIME =
        com.tngtech.archunit.library.GeneralCodingRules
            .NO_CLASSES_SHOULD_USE_JODA_TIME;
}

4.4 Spring 專屬規則

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public final class SpringSpecificRules {

    private SpringSpecificRules() {}

    /** @RestController 必須以 Controller 結尾 */
    public static final ArchRule REST_CONTROLLER_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
            .should().haveSimpleNameEndingWith("Controller")
            .as("@RestController 類必須以 Controller 結尾");

    /** @Service 必須以 Service 結尾 */
    public static final ArchRule SERVICE_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.stereotype.Service")
            .should().haveSimpleNameEndingWith("Service")
            .as("@Service 類必須以 Service 結尾");

    /** @Repository 必須以 Repository 結尾 */
    public static final ArchRule REPOSITORY_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.stereotype.Repository")
            .should().haveSimpleNameEndingWith("Repository")
            .as("@Repository 類必須以 Repository 結尾");

    /** 不得在 Service 層使用 @Autowired 欄位注入(應使用建構子注入) */
    public static final ArchRule NO_FIELD_INJECTION =
        noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .areAnnotatedWith("org.springframework.beans.factory.annotation.Autowired")
            .as("Service 層不得使用 @Autowired 欄位注入");
}

4.5 自訂 Predicate

package com.company.archunit.predicates;

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

public final class CompanyPredicates {

    private CompanyPredicates() {}

    /** 判斷是否為 Company 內部類別(非第三方) */
    public static final DescribedPredicate<JavaClass> BELONG_TO_COMPANY =
        new DescribedPredicate<JavaClass>("屬於公司內部類別") {
            @Override
            public boolean apply(JavaClass javaClass) {
                return javaClass.getPackageName().startsWith("com.company");
            }
        };

    /** 判斷是否為 Spring Bean */
    public static DescribedPredicate<JavaClass> isSpringAnnotatedWith(String annotationName) {
        return new DescribedPredicate<JavaClass>("使用 @" + annotationName + " 標註") {
            @Override
            public boolean apply(JavaClass javaClass) {
                return javaClass.isAnnotatedWith(annotationName);
            }
        };
    }
}

4.6 自訂 Condition

package com.company.archunit.conditions;

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;

public final class CompanyConditions {

    private CompanyConditions() {}

    /** 確保類別有 Javadoc 註解 */
    public static final ArchCondition<JavaClass> HAVE_JAVADOC =
        new ArchCondition<JavaClass>("有 Javadoc 註解") {
            @Override
            public void check(JavaClass item, ConditionEvents events) {
                if (item.getJavaDoc().isEmpty() || item.getJavaDoc().get().isEmpty()) {
                    events.add(SimpleConditionEvent.violated(
                        item, String.format("類別 %s 缺少 Javadoc", item.getName())));
                }
            }
        };
}

4.7 規則套件(整合入口)

package com.company.archunit.suite;

import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ArchTests;

import com.company.archunit.rules.*;

public final class CompanyArchRules {

    private CompanyArchRules() {}

    // ── 規則分組 ──────────────────────────────────────────────
    public static final ArchTests NAMING_CONVENTIONS =
        ArchTests.in(NamingConventionRules.class);

    public static final ArchTests LAYERED_ARCHITECTURE =
        ArchTests.in(LayeredArchitectureRules.class);

    public static final ArchTests DEPENDENCY_RULES =
        ArchTests.in(DependencyRules.class);

    public static final ArchTests SPRING_RULES =
        ArchTests.in(SpringSpecificRules.class);

    // ── 全部規則(一次引入所有) ──────────────────────────────
    public static final ArchTests ALL_RULES =
        ArchTests.in(CompanyArchRules.class);
}

5. 微服務端的整合方式

根據 ArchUnit 官方文件和社群實踐,共有以下幾種整合方式:

方式一:ArchTests.in() — 最推薦

在微服務中建立一個測試類別,透過 ArchTests.in() 引入共用規則:

package com.company.myservice.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchTests;

import com.company.archunit.suite.CompanyArchRules;

@AnalyzeClasses(
    packages = "com.company.myservice",
    importOptions = { com.tngtech.archunit.lang.junit.ImportOption.DoNotIncludeTests.class }
)
public class MyServiceArchitectureTest {

    // 引入所有共用規則
    @ArchTest
    static final ArchTests COMPANY_RULES = ArchTests.in(CompanyArchRules.class);

    // 可以只引入部分規則
    @ArchTest
    static final ArchTests ONLY_NAMING = ArchTests.in(NamingConventionRules.class);

    // 也可以加入本服務的特有規則
    @ArchTest
    static final ArchRule MY_SERVICE_SPECIFIC_RULE = ...
}

優點: - 完全控制哪些規則套用到本服務 - 可以混合共用規則和本地規則 - 測試報告清楚顯示規則來源 - 符合 ArchUnit 官方推薦的作法

方式二:Maven Surefire dependenciesToScan

讓 Surefire 自動掃描共用模組中的 @AnalyzeClasses 測試:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <dependenciesToScan>
            <dependency>com.company:company-archunit-core</dependency>
        </dependenciesToScan>
    </configuration>
</plugin>

共用模組中的測試類:

@AnalyzeClasses(packages = "com.company.myservice")
public class SharedArchitectureTest {
    @ArchTest
    static final ArchRule layered = LayeredArchitectureRules.STANDARD_LAYERED_ARCHITECTURE;
}

注意:此方式在多模組 Maven 專案中可能掃描到不相關的 classes,需搭配 importOptions = DoNotIncludeArchives.class 使用。

方式三:ArchUnit Maven Plugin(Société Générale)

在 Maven build 中直接執行規則,不需要寫 JUnit 測試:

<plugin>
    <groupId>com.societegenerale.commons</groupId>
    <artifactId>arch-unit-maven-plugin</artifactId>
    <version>5.0.0</version>
    <configuration>
        <rules>
            <preConfiguredRules>
                <rule>com.company.archunit.rules.NamingConventionRules</rule>
                <rule>com.company.archunit.rules.LayeredArchitectureRules</rule>
            </preConfiguredRules>
        </rules>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>com.company</groupId>
            <artifactId>company-archunit-core</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>
</plugin>

優點:不需要在微服務中寫任何測試程式碼。 缺點:報告格式不如 JUnit 測試直觀。

方式四:Gradle ArchRules Runner(Netflix Nebula)

適用於 Gradle 生態系,自動發現並執行規則:

// build.gradle.kts
plugins {
    id("com.netflix.nebula.archrules.runner")
}

dependencies {
    archRules("com.company:company-archunit-core:1.0.0")
}

自動產生 checkArchRulesMain task,執行規則並產生報告。

方式五:直接 @Test + check()

最靈活但最低階的方式:

@Test
void verifyArchitecture() {
    JavaClasses classes = new ClassFileImporter()
        .importPackages("com.company.myservice");

    LayeredArchitectureRules.STANDARD_LAYERED_ARCHITECTURE.check(classes);
    DependencyRules.CONTROLLERS_MUST_NOT_DEPEND_ON_REPOSITORIES.check(classes);
}

缺點:沒有 ArchUnit 的快取機制,每次執行都重新掃描 classes,效能較差。


6. 版本管理與發布策略

6.1 語意化版本(Semantic Versioning)

共用 ArchUnit 規則模組應遵循 SemVer:

版本類型 範例 何時發布
Major (X.0.0) 1.0.0 → 2.0.0 刪除或破壞向後相容的規則變更
Minor (0.X.0) 1.0.0 → 1.1.0 新增規則、新增可選配置
Patch (0.0.X) 1.0.0 → 1.0.1 修正規則描述、修正 predicate 邏輯

6.2 ArchUnit 版本相容性

共用模組應明確標注支援的 ArchUnit 版本範圍:

<properties>
    <!-- 支援 ArchUnit 1.x 系列 -->
    <archunit.version>1.3.0</archunit.version>
</properties>

如果共用模組升級了 ArchUnit 版本,各微服務可能需要同步升級,應在 release notes 中明確說明。

6.3 發布到 Maven Repository

<!-- 在共用模組的 POM 中加入 publishing 設定 -->
<distributionManagement>
    <repository>
        <id>releases</id>
        <url>https://nexus.company.com/repository/releases/</url>
    </repository>
    <snapshotRepository>
        <id>snapshots</id>
        <url>https://nexus.company.com/repository/snapshots/</url>
    </snapshotRepository>
</distributionManagement>

6.4 Release 流程

  1. 在 company-archunit repo 建立 release 分支
  2. 更新版本號(移除 -SNAPSHOT)
  3. 更新 CHANGELOG
  4. 建立 Git Tag
  5. 發布到 Maven Repository
  6. 合併回 main
  7. 開始下一個 SNAPSHOT 版本

6.5 與 BOM 整合

如果組織已有 BOM(Bill of Materials),將共用 ArchUnit 模組加入:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.company</groupId>
            <artifactId>company-archunit-core</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5</artifactId>
            <version>${archunit.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

微服務只需在自己的 POM 中宣告:

<dependency>
    <groupId>com.company</groupId>
    <artifactId>company-archunit-core</artifactId>
    <scope>test</scope>
</dependency>
<!-- 不需要指定版本,由 BOM 統一管理 -->

7. 可擴展性設計

7.1 讓微服務能覆蓋或擴充規則

根據 TNG/ArchUnit Discussion #1213 和 Medium 文章,有以下幾種方式:

方式一:覆蓋規則中的參數(推薦)

將套件路徑等參數抽出成可配置的常數:

public final class LayeredArchitectureRules {

    // 可被微服務覆蓋的預設值
    private static final String BASE_PACKAGE = "com.company.*";
    private static final String REST_PACKAGE = BASE_PACKAGE + ".rest..";
    private static final String SERVICE_PACKAGE = BASE_PACKAGE + ".service..";
    private static final String DOMAIN_PACKAGE = BASE_PACKAGE + ".domain..";
    private static final String REPOSITORY_PACKAGE = BASE_PACKAGE + ".repository..";

    // 產生可自訂參數的規則
    public static ArchRule layeredArchitecture(String rest, String service, String domain, String repository) {
        return layeredArchitecture()
            .consideringAllDependencies()
            .layer("Rest").definedBy(rest)
            .layer("Service").definedBy(service)
            .layer("Domain").definedBy(domain)
            .layer("Repository").definedBy(repository)
            .whereLayer("Rest").mayOnlyBeAccessedByLayers("Config")
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Rest", "Config")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
            .as("自訂分層架構");
    }

    // 預設規則
    public static final ArchRule STANDARD = layeredArchitecture(
        REST_PACKAGE, SERVICE_PACKAGE, DOMAIN_PACKAGE, REPOSITORY_PACKAGE);
}

微服務端使用:

@AnalyzeClasses(packages = "com.company.myservice")
public class MyServiceArchitectureTest {
    @ArchTest
    static final ArchRule CUSTOM_LAYERED = LayeredArchitectureRules
        .layeredArchitecture(
            "com.company.myservice.api..",   // 自訂 REST 套件
            "com.company.myservice.biz..",   // 自訂 Service 套件
            "com.company.myservice.model..", // 自訂 Domain 套件
            "com.company.myservice.dao.."    // 自訂 Repository 套件
        );
}

方式二:使用 FreezingArchRule 處理已知違規

// 微服務端:凍結已知違規,只報告新增違規
@ArchTest
static final ArchRule FROZEN_RULE = FreezingArchRule
    .freeze(LayeredArchitectureRules.STANDARD)
    .persistIn(new TextFileBasedViolationStore());  // 違規存到 src/test/resources/archunit_freeze/

方式三:組合式規則(CompositeArchRule)

微服務可以自由組合、替換、排除共用規則:

@AnalyzeClasses(packages = "com.company.myservice")
public class MyServiceArchitectureTest {

    // 引入共用的分層規則
    @ArchTest
    static final ArchTests LAYERED = ArchTests.in(LayeredArchitectureRules.class);

    // 排除特定共用規則(例如本服務不需要檢查 Spring 特定規則)
    // → 不引入 ArchTests.in(SpringSpecificRules.class) 即可

    // 加入本服務的特有規則
    @ArchTest
    static final ArchRule MY_CUSTOM_RULE = classes()
        .that().resideInAPackage("..special..")
        .should().onlyDependOnClassesThat()
        .resideInAnyPackage("..special..", "..common..");
}

7.2 讓規則可透過配置檔控制

在 archunit.properties 中加入自訂配置:

# archunit.properties
company.archunit.base-package=com.company
company.archunit.enable-layered=true
company.archunit.enable-naming=true
company.archunit.enable-spring-specific=false  # 本服務不用 Spring 規則

規則類別讀取配置:

public final class ConfigurableRules {

    public static ArchRule configurableLayeredRule() {
        ArchConfiguration config = ArchConfiguration.get();
        String basePackage = config.getProperty("company.archunit.base-package", "com.company");
        boolean enabled = Boolean.parseBoolean(
            config.getProperty("company.archunit.enable-layered", "true"));

        if (!enabled) {
            return new AlwaysSatisfiedArchRule(); // 回傳永遠通過的規則
        }

        return layeredArchitecture()
            .consideringAllDependencies()
            .layer("Rest").definedBy(basePackage + ".*.rest..")
            .layer("Service").definedBy(basePackage + ".*.service..")
            .layer("Domain").definedBy(basePackage + ".*.domain..")
            .layer("Repository").definedBy(basePackage + ".*.repository..")
            .as("可配置的分層架構");
    }
}

7.3 處理不同類型的微服務

微服務類型 差異 處理方式
Spring MVC Controller 用 @Controller / @RestController 使用 SpringSpecificRules
Spring WebFlux Reactor / Mono / Flux 規則中排除 WebFlux 特有套件
非 Spring 服務 使用 Guice / CDI 等 不引入 SpringSpecificRules,只用通用規則

設計原則:分組規則,讓微服務按需選用

// Spring 服務:引入所有規則
@ArchTest
static final ArchTests ALL = ArchTests.in(CompanyArchRules.class);

// WebFlux 服務:排除 Controller 層規則
@ArchTest
static final ArchTests WITHOUT_SPRING_CONTROLLER =
    ArchTests.in(NamingConventionRules.class);

// 非 Spring 服務:只用通用規則
@ArchTest
static final ArchTests GENERAL_ONLY = ArchTests.in(DependencyRules.class);

8. CI/CD 整合

8.1 共用模組的 CI

共用模組本身也需要 CI 流程:

# .github/workflows/archunit-core-ci.yml
name: Company ArchUnit Core CI

on:
  push:
    branches: [main]
    paths:
      - 'company-archunit-core/**'
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build and Test
        run: mvn -pl company-archunit-core clean verify

      - name: Publish SNAPSHOT
        if: github.ref == 'refs/heads/main'
        run: mvn -pl company-archunit-core deploy -DskipTests
        env:
          MAVEN_USERNAME: ${{ secrets.NEXUS_USERNAME }}
          MAVEN_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}

8.2 微服務端的整合 CI

# .github/workflows/my-service-ci.yml
name: My Service CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  architecture-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Run Architecture Tests
        run: mvn test -Dtest=*ArchitectureTest -pl my-service

      # 可選:如果使用 FreezingArchRule,需要提交更新的違規文件
      - name: Commit updated violation store
        if: github.ref == 'refs/heads/main'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add src/test/resources/archunit_freeze/
          git diff --staged --quiet || git commit -m "chore: update archunit violation store" && git push

8.3 使用 Société Générale Maven Plugin 的 CI

<!-- 微服務 pom.xml 中 -->
<plugin>
    <groupId>com.societegenerale.commons</groupId>
    <artifactId>arch-unit-maven-plugin</artifactId>
    <version>5.0.0</version>
    <configuration>
        <rules>
            <preConfiguredRules>
                <rule>com.company.archunit.rules.NamingConventionRules</rule>
                <rule>com.company.archunit.rules.LayeredArchitectureRules</rule>
                <rule>com.company.archunit.rules.DependencyRules</rule>
            </preConfiguredRules>
        </rules>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>com.company</groupId>
            <artifactId>company-archunit-core</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>
    <executions>
        <execution>
            <phase>test</phase>
            <goals>
                <goal>arch-test</goal>
            </goals>
        </execution>
    </executions>
</plugin>

9. 完整程式碼範例

9.1 共用模組完整程式碼

CompanyArchRules.java(規則套件)

package com.company.archunit.suite;

import com.tngtech.archunit.lang.ArchTests;
import com.company.archunit.rules.*;

public final class CompanyArchRules {

    private CompanyArchRules() {}

    public static final ArchTests NAMING = ArchTests.in(NamingConventionRules.class);
    public static final ArchTests LAYERS = ArchTests.in(LayeredArchitectureRules.class);
    public static final ArchTests DEPENDENCIES = ArchTests.in(DependencyRules.class);
    public static final ArchTests SPRING = ArchTests.in(SpringSpecificRules.class);
    public static final ArchTests ALL = ArchTests.in(CompanyArchRules.class);
}

NamingConventionRules.java

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public final class NamingConventionRules {

    private NamingConventionRules() {}

    public static final ArchRule EXCEPTIONS_END_WITH_EXCEPTION =
        classes()
            .that().areAssignableTo(Exception.class)
            .should().haveSimpleNameEndingWith("Exception");

    public static final ArchRule CONTROLLERS_END_WITH_CONTROLLER =
        classes()
            .that().resideInAPackage("..controller..")
            .or().resideInAPackage("..rest..")
            .should().haveSimpleNameEndingWith("Controller");

    public static final ArchRule SERVICES_END_WITH_SERVICE =
        classes()
            .that().resideInAPackage("..service..")
            .and().haveSimpleNameNotEndingWith("Exception")
            .should().haveSimpleNameEndingWith("Service");

    public static final ArchRule REPOSITORIES_END_WITH_REPOSITORY =
        classes()
            .that().resideInAPackage("..repository..")
            .or().resideInAPackage("..repo..")
            .should().haveSimpleNameEndingWith("Repository", "Repo");

    public static final ArchRule NO_JAVA_UTIL_DATE =
        noClasses().should().dependOnClassesThat()
            .resideInAnyPackage("java.util.Date");

    public static final ArchRule NO_JODA_TIME =
        noClasses().should().dependOnClassesThat()
            .resideInAnyPackage("org.joda.time..");

    public static final ArchRule NO_STANDARD_STREAMS =
        noClasses().should().accessClassesThat()
            .resideInAnyPackage("java.lang.System");

    public static final ArchRule NO_JUNIT_ASSERT_IMPORT =
        noClasses().should().dependOnClassesThat()
            .resideInAnyPackage("org.junit.Assert");

    public static final ArchRule NO_POWERMOCK =
        noClasses().should().dependOnClassesThat()
            .resideInAnyPackage("org.powermock..");
}

LayeredArchitectureRules.java

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

public final class LayeredArchitectureRules {

    private LayeredArchitectureRules() {}

    public static final ArchRule STANDARD = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Rest").definedBy("..rest..")
        .layer("Service").definedBy("..service..")
        .layer("Domain").definedBy("..domain..")
        .layer("Repository").definedBy("..repository..")
        .layer("Config").definedBy("..config..")
        .whereLayer("Rest").mayOnlyBeAccessedByLayers("Config")
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Rest", "Config")
        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository", "Config")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service", "Config")
        .whereLayer("Config").mayNotBeAccessedByAnyLayer();

    public static ArchRule custom(String rest, String service, String domain, String repo) {
        return layeredArchitecture()
            .consideringAllDependencies()
            .layer("Rest").definedBy(rest)
            .layer("Service").definedBy(service)
            .layer("Domain").definedBy(domain)
            .layer("Repository").definedBy(repo)
            .whereLayer("Rest").mayOnlyBeAccessedByLayers("Config")
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Rest", "Config")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
    }
}

DependencyRules.java

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public final class DependencyRules {

    private DependencyRules() {}

    public static final ArchRule CONTROLLERS_MUST_NOT_DEPEND_ON_REPOSITORIES =
        noClasses()
            .that().resideInAPackage("..controller..")
            .or().resideInAPackage("..rest..")
            .should().dependOnClassesThat()
            .resideInAPackage("..repository..");

    public static final ArchRule DOMAIN_MUST_NOT_DEPEND_ON_INFRA =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infra..");

    public static final ArchRule REPOSITORY_MUST_NOT_DEPEND_ON_SERVICE =
        noClasses()
            .that().resideInAPackage("..repository..")
            .should().dependOnClassesThat()
            .resideInAPackage("..service..");

    public static final ArchRule INTERNAL_PACKAGES_MUST_NOT_BE_ACCESSED_FROM_OUTSIDE =
        classes()
            .that().resideInAPackage("..internal..")
            .should().onlyBeAccessedbyClassesThat()
            .resideInAnyPackage("..internal..");

    public static final ArchRule NO_CYCLIC_DEPENDENCIES =
        slices().matching("com.company.(*).main..")
            .should().beFreeOfCycles();
}

SpringSpecificRules.java

package com.company.archunit.rules;

import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

public final class SpringSpecificRules {

    private SpringSpecificRules() {}

    public static final ArchRule REST_CONTROLLER_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
            .should().haveSimpleNameEndingWith("Controller");

    public static final ArchRule SERVICE_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.stereotype.Service")
            .should().haveSimpleNameEndingWith("Service");

    public static final ArchRule REPOSITORY_NAMING =
        classes()
            .that().areAnnotatedWith("org.springframework.stereotype.Repository")
            .should().haveSimpleNameEndingWith("Repository");

    public static final ArchRule NO_FIELD_INJECTION =
        noClasses()
            .should().dependOnClassesThat()
            .areAnnotatedWith("org.springframework.beans.factory.annotation.Autowired");
}

9.2 微服務消費者完整程式碼

MyServiceArchitectureTest.java

package com.company.myservice.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchTests;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

import com.company.archunit.suite.CompanyArchRules;

@AnalyzeClasses(
    packages = "com.company.myservice",
    importOptions = { com.tngtech.archunit.lang.junit.ImportOption.DoNotIncludeTests.class }
)
class MyServiceArchitectureTest {

    // ── 引入共用規則 ──────────────────────────────────────────
    @ArchTest
    static final ArchTests COMPANY_NAMING = ArchTests.in(
        com.company.archunit.rules.NamingConventionRules.class);

    @ArchTest
    static final ArchTests COMPANY_LAYERS = ArchTests.in(
        com.company.archunit.rules.LayeredArchitectureRules.class);

    @ArchTest
    static final ArchTests COMPANY_DEPENDENCIES = ArchTests.in(
        com.company.archunit.rules.DependencyRules.class);

    @ArchTest
    static final ArchTests COMPANY_SPRING = ArchTests.in(
        com.company.archunit.rules.SpringSpecificRules.class);

    // ── 一次引入所有共用規則 ──────────────────────────────────
    // @ArchTest
    // static final ArchTests ALL_COMPANY_RULES = ArchTests.in(CompanyArchRules.class);

    // ── 本服務的特有規則 ──────────────────────────────────────
    @ArchTest
    static final ArchRule MY_SERVICE_MODULE_RULE = slices()
        .matching("com.company.myservice.(*).main..")
        .should().notDependOnEachOther();

    @ArchTest
    static final ArchRule MY_SERVICE_API_ONLY_EXPOSES_TO_OUTSIDE = classes()
        .that().resideInAPackage("..api..")
        .should().onlyBeAccessedbyClassesThat()
        .resideOutsideOfPackage("..internal..");
}

MyServiceNamingTest.java(更細粒度的控制)

package com.company.myservice.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchTests;

@AnalyzeClasses(packages = "com.company.myservice")
class MyServiceNamingTest {

    // 只引入命名慣例規則
    @ArchTest
    static final ArchTests NAMING = ArchTests.in(
        com.company.archunit.rules.NamingConventionRules.class);

    // 也可以只引入部分規則
    // @ArchTest
    // static final ArchTests ONLY_EXCEPTION_NAMING = ArchTests.in(
    //     com.company.archunit.rules.NamingConventionRules.class,
    //     "EXCEPTIONS_END_WITH_EXCEPTION");  // 指定特定規則
}

10. 常見陷阱與最佳實踐

10.1 常見陷阱

陷阱一:共用模組的依賴範圍不對

問題:將 ArchUnit 依賴設為 test scope,導致規則無法被其他專案作為 compile dependency 引用。

解決:共用模組中 ArchUnit 必須是 compile scope。

<!-- 共用模組 pom.xml -->
<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit</artifactId>
    <!-- compile scope(預設),不是 test -->
</dependency>

陷阱二:@AnalyzeClasses 的 packages 設定錯誤

問題:共用規則中寫死了套件名,導致不同微服務無法共用。

解決:共用規則中不使用 @AnalyzeClasses,規則只定義在 static field 或 method 中。由微服務端的 @AnalyzeClasses 決定掃描哪些 classes。

// 共用模組:只定義規則,不掃描 classes
public final class MyRules {
    public static final ArchRule MY_RULE = classes()...;
}

// 微服務端:決定掃描範圍
@AnalyzeClasses(packages = "com.company.myservice")
public class MyTest {
    @ArchTest
    static final ArchTests RULES = ArchTests.in(MyRules.class);
}

陷阱三:跨模組的 cycles 檢查在 Maven 多模組中不準確

問題:在 Maven 多模組專案中使用 dependenciesToScan,導致 ArchUnit 掃描到所有模組的 classes。

解決:使用 importOptions = DoNotIncludeArchives.class 或自訂 LocationProvider。

@AnalyzeClasses(
    packages = "com.company.myservice",
    importOptions = {
        DoNotIncludeTests.class,
        DoNotIncludeArchives.class  // 排除 JAR 中的 classes
    }
)

陷阱四:FreezingArchRule 在 CI 中的坑

問題:CI 環境中違反規則的檔案沒有提交到 repo,導致 FreezingArchRule 嘗試建立新的 violation store。

解決: - CI 中設定 freeze.store.default.allowStoreUpdate=false - 本地開發時允許更新:-Darchunit.freeze.store.default.allowStoreUpdate=true - 建議為每個微服務建立獨立的 violation store 路徑

# 微服務 A 的 archunit.properties
freeze.store.default.path=${project.basedir}/src/test/resources/archunit_freeze/

# 微服務 B 的 archunit.properties
freeze.store.default.path=${project.basedir}/src/test/resources/archunit_freeze/

陷阱五:規則產生誤報(False Positive)

問題:規則太過嚴格,導致正常程式碼也被標記為違規。

解決:使用 because() 說明規則原因,搭配 allowEmptyShould(false) 避免規則套用到空的 class 集合。

public static final ArchRule MY_RULE = classes()
    .that().resideInAPackage("..service..")
    .should().onlyDependOnClassesThat()
    .resideInAnyPackage("..domain..", "..common..")
    .because("Service 層只能依賴 Domain 層和 Common 模組")
    .allowEmptyShould(false);  // 如果掃描不到 service 套件中的類別,測試會失敗

陷阱六:忽略第三方庫

問題:ArchUnit 預設會掃描所有 classes,包括第三方庫,導致規則誤報。

解決:使用 consideringOnlyDependenciesInAnyPackage 限制規則範圍。

public static final ArchRule MY_RULE = classes()
    .that().resideInAPackage("..service..")
    .should().onlyDependOnClassesThat()
    .resideInAnyPackage("..domain..", "java..", "javax..", "org.springframework..")
    .because("只檢查公司內部的依賴關係");

10.2 最佳實踐

  1. 規則分組清晰:將規則按類別(Naming、Layered、Dependency、Spring)分組,讓微服務按需選用
  2. 參數化規則:將套件路徑等可變部分抽出成參數,讓不同微服務可以自訂
  3. 漸進式引入:新專案一開始就加入共用規則;舊專案使用 FreezingArchRule 逐步清理
  4. 文件化:為每條規則寫清楚的 as() 描述和 because() 原因,方便新人理解
  5. 版本鎖定:透過 BOM 或 dependencyManagement 統一管理 ArchUnit 版本
  6. CI 嚴格檢查:CI 中設定 freeze.store.default.allowStoreUpdate=false,確保開發者必須提交違規文件的更新
  7. 定期更新:追蹤 ArchUnit 新版本,定期升級共用模組
  8. 避免循環依賴:共用模組不應依賴任何微服務的 classes
  9. 測試共用規則本身:為共用規則撰寫正向和負向測試,確保規則本身的正確性

10.3 參考資源

資源 連結
ArchUnit 官方文件 https://www.archunit.org/userguide/html/000_Index.html
ArchUnit GitHub https://github.com/TNG/ArchUnit
ArchUnit Examples (JUnit 5) https://github.com/TNG/ArchUnit-Examples
Société Générale Maven Plugin https://github.com/societe-generale/arch-unit-maven-plugin
Netflix Nebula ArchRules https://github.com/nebula-plugins/nebula-archrules-plugin
Netflix 博客:Scaling ArchUnit https://netflixtechblog.com/scaling-archunit-with-nebula-archrules-b4642c464c5a
Tijl Bormans 文章 https://medium.com/@tijl.b/enforcing-architectural-consistency-in-java-microservices-with-archunit-and-junit5-481b22e22b87
TNG Discussion #1213 https://github.com/TNG/ArchUnit/discussions/1213
Spring Modulith Module ArchUnit https://github.com/clutcher/spring-modulith-module-archunit
ArchUnit Spring Integration https://github.com/rweisleder/archunit-spring
ArchUnit-Layers-Example https://github.com/electronickai/ArchUnit-Layers-Example

本指南基於 ArchUnit 1.3.0+、JUnit 5、Maven 3.9+、Spring Boot 3.x 撰寫。 最後更新:2026-08