共用 ArchUnit 規則模組實作指南
面向 Java / Spring 微服務生態系的架構治理完整實踐手冊
目錄
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 流程
- 在
company-archunitrepo 建立 release 分支 - 更新版本號(移除
-SNAPSHOT) - 更新 CHANGELOG
- 建立 Git Tag
- 發布到 Maven Repository
- 合併回 main
- 開始下一個 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 最佳實踐
- 規則分組清晰:將規則按類別(Naming、Layered、Dependency、Spring)分組,讓微服務按需選用
- 參數化規則:將套件路徑等可變部分抽出成參數,讓不同微服務可以自訂
- 漸進式引入:新專案一開始就加入共用規則;舊專案使用
FreezingArchRule逐步清理 - 文件化:為每條規則寫清楚的
as()描述和because()原因,方便新人理解 - 版本鎖定:透過 BOM 或
dependencyManagement統一管理 ArchUnit 版本 - CI 嚴格檢查:CI 中設定
freeze.store.default.allowStoreUpdate=false,確保開發者必須提交違規文件的更新 - 定期更新:追蹤 ArchUnit 新版本,定期升級共用模組
- 避免循環依賴:共用模組不應依賴任何微服務的 classes
- 測試共用規則本身:為共用規則撰寫正向和負向測試,確保規則本身的正確性
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