共用 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 #1213Medium 文章,有以下幾種方式:

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

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

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