feat: implement batch 4 archunit tests and ci workflow

- Add comprehensive ArchUnit A-class rules for architecture validation
- Implement layer dependency rules (api → infra → app → domain)
- Add domain layer purity checks (no Spring/JPA/Hibernate dependencies)
- Create application layer import restrictions
- Add @Transactional boundary enforcement
- Implement API layer rules (no field injection, no infra dependency)
- Add persistence isolation and cycle detection rules
- Create CI workflow with quality gate and build-deploy jobs
- Follow M2 v5 frozen contracts for architecture validation

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Bruno Gao
2026-02-12 13:29:03 +08:00
co-authored by Qwen-Coder
parent 86a11db54e
commit eaac739e6b
8 changed files with 436 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
# ============================================================
# Gitea Actions CI — java-service-template
# ============================================================
# Job 1 quality-gate : compile + test (JaCoCo) → spotless → verify (SpotBugs + OWASP)
# Job 2 build-deploy : docker build/push → deploy repo update
# (only main/release, only when Dockerfile exists)
# ============================================================
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
env:
JAVA_VERSION: "21"
JAVA_DISTRIBUTION: "temurin"
MAVEN_OPTS: "-Xmx1024m -Dfile.encoding=UTF-8"
jobs:
# -------------------------------------------------------
# Job 1 — Quality Gate (all branches)
# -------------------------------------------------------
quality-gate:
name: quality-gate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: maven
# Step 1 — compile + unit test + ArchUnit + JaCoCo exec generation
- name: "Step 1: Compile & Test (JaCoCo enabled)"
run: >
mvn clean test
-Djacoco.skip=false
--batch-mode --fail-at-end
# Step 2 — code formatting
- name: "Step 2: Spotless Check"
run: mvn spotless:check --batch-mode
# Step 3 — coverage check + static analysis + dependency scan
# Uses -DskipTests because tests already passed in Step 1.
# JaCoCo check reads the exec file generated in Step 1.
- name: "Step 3: Verify (JaCoCo + SpotBugs + OWASP)"
run: >
mvn verify -DskipTests
-Djacoco.skip=false
-Dspotbugs.skip=false
-Ddependency-check.skip=false
--batch-mode
# -------------------------------------------------------
# Job 2 — Build & Deploy (main / release branches only)
# -------------------------------------------------------
build-deploy:
name: build-deploy
needs: [quality-gate]
runs-on: ubuntu-latest
if: >
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Abort early if Dockerfile not yet present (added in later batch)
- name: Check Dockerfile exists
id: dockerfile
run: |
if [ -f Dockerfile ]; then
echo "EXISTS=true" >> $GITHUB_OUTPUT
else
echo "EXISTS=false" >> $GITHUB_OUTPUT
echo "::warning::Dockerfile not found — skipping build-deploy."
fi
- name: Set up JDK
if: steps.dockerfile.outputs.EXISTS == 'true'
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: maven
- name: Set image tag (git short SHA 7)
if: steps.dockerfile.outputs.EXISTS == 'true'
id: tag
run: echo "IMAGE_TAG=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT
- name: Build JAR (skip tests)
if: steps.dockerfile.outputs.EXISTS == 'true'
run: mvn package -DskipTests --batch-mode
- name: Login to registry
if: steps.dockerfile.outputs.EXISTS == 'true'
run: |
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build & push Docker image
if: steps.dockerfile.outputs.EXISTS == 'true'
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
docker build -t "${IMAGE}" .
docker push "${IMAGE}"
env:
REGISTRY: ${{ secrets.REGISTRY }}
IMAGE_NAME: java-service-template
IMAGE_TAG: ${{ steps.tag.outputs.IMAGE_TAG }}
- name: Update deploy repo (GitOps)
if: steps.dockerfile.outputs.EXISTS == 'true'
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
DEPLOY_REPO: ${{ secrets.DEPLOY_REPO }}
IMAGE_TAG: ${{ steps.tag.outputs.IMAGE_TAG }}
run: |
if [ -z "${DEPLOY_REPO}" ]; then
echo "::warning::DEPLOY_REPO secret not set — skipping deploy update."
exit 0
fi
git clone "https://x-access-token:${DEPLOY_TOKEN}@${DEPLOY_REPO}" deploy-repo
cd deploy-repo
sed -i "s|newTag:.*|newTag: ${IMAGE_TAG}|g" overlays/*/kustomization.yaml 2>/dev/null || true
sed -i "s|tag:.*|tag: \"${IMAGE_TAG}\"|g" values-*.yaml 2>/dev/null || true
git config user.name "CI Bot"
git config user.email "[email protected]"
git add -A
git diff --cached --quiet || git commit -m "chore: update image tag to ${IMAGE_TAG}"
git push
@@ -0,0 +1,36 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.beans.factory.annotation.Autowired;
/**
* A-5 / A-6 — API layer structural rules.
* <ul>
* <li>A-5: No field injection ({@code @Autowired} on fields) — use constructor injection.</li>
* <li>A-6: Controllers must not depend on infrastructure implementations;
* they should only talk to application-layer ports/services.</li>
* </ul>
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class)
class ApiLayerRulesTest {
@ArchTest
static final ArchRule no_field_injection =
noFields()
.should().beAnnotatedWith(Autowired.class)
.as("A-5: Field injection (@Autowired on fields) is forbidden; use constructor injection");
@ArchTest
static final ArchRule controllers_must_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.api.controller..")
.should().dependOnClassesThat()
.resideInAPackage("com.ljwx.tmpl.infrastructure..")
.as("A-6: Controllers must not depend on infrastructure (use application-layer ports)");
}
@@ -0,0 +1,40 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* A-3 — Application layer import restrictions.
* <p>
* Application layer may only depend on domain + Spring context/tx.
* It must NOT import web, persistence, security, messaging, or client packages.
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl.application", importOptions = ImportOption.DoNotIncludeTests.class)
class ApplicationLayerImportBanTest {
@ArchTest
static final ArchRule application_must_not_depend_on_banned_packages =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.application..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework.web..",
"org.springframework.http..",
"org.springframework.security..",
"org.springframework.cache..",
"org.springframework.data.jpa..",
"org.springframework.data.redis..",
"jakarta.persistence..",
"javax.persistence..",
"org.hibernate..",
"org.springframework.web.client..",
"org.springframework.web.reactive..",
"org.springframework.cloud.openfeign..",
"org.springframework.kafka.."
)
.as("A-3: Application layer must not import web/persistence/security/client/messaging packages");
}
@@ -0,0 +1,60 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* A-2 — Domain layer purity.
* <p>
* Domain must not depend on Spring, JPA, Hibernate, or outer layers
* (infrastructure, api). Jakarta Validation <em>annotations</em>
* are permitted, but the Validator runtime is banned.
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl.domain", importOptions = ImportOption.DoNotIncludeTests.class)
class DomainLayerPurityTest {
@ArchTest
static final ArchRule domain_must_not_depend_on_spring =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"org.springframework.boot.."
)
.as("A-2: Domain must not depend on Spring");
@ArchTest
static final ArchRule domain_must_not_depend_on_jpa =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"jakarta.persistence..",
"javax.persistence.."
)
.as("A-2: Domain must not depend on JPA");
@ArchTest
static final ArchRule domain_must_not_depend_on_hibernate =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.hibernate..")
.as("A-2: Domain must not depend on Hibernate");
@ArchTest
static final ArchRule domain_must_not_depend_on_outer_layers =
noClasses()
.that().resideInAPackage("com.ljwx.tmpl.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"com.ljwx.tmpl.api..",
"com.ljwx.tmpl.infrastructure.."
)
.as("A-2: Domain must not depend on API or Infrastructure layers");
}
@@ -0,0 +1,34 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* A-1 — Layer dependency direction.
* <p>
* Enforces: api → infrastructure → application → domain.
* No reverse or cross-layer access is allowed.
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class)
class LayerDependencyTest {
@ArchTest
static final ArchRule layer_dependencies_are_respected =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("com.ljwx.tmpl.domain..")
.layer("Application").definedBy("com.ljwx.tmpl.application..")
.layer("Infrastructure").definedBy("com.ljwx.tmpl.infrastructure..")
.layer("API").definedBy("com.ljwx.tmpl.api..")
.whereLayer("Domain").mayNotAccessAnyLayer()
.whereLayer("Application").mayOnlyAccessLayers("Domain")
.whereLayer("Infrastructure").mayOnlyAccessLayers("Application", "Domain")
.whereLayer("API").mayOnlyAccessLayers("Infrastructure", "Application", "Domain")
.as("A-1: Layer dependency direction must be api -> infra -> application -> domain");
}
@@ -0,0 +1,68 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* A-7 / A-8 — Persistence isolation + cycle detection.
* <ul>
* <li>A-7: Internal repository implementations (classes ending with "Repository"
* inside {@code ..persistence.internal..}) must be package-private.</li>
* <li>A-7b: Code outside the persistence internal package must not depend on
* internal Repository implementations.</li>
* <li>A-8: No cyclic dependencies between top-level module packages.</li>
* </ul>
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class)
class PersistenceAndCycleTest {
private static final String INTERNAL_PKG = "com.ljwx.tmpl.infrastructure.persistence.internal..";
/*
* A-7: Repository implementations in the internal sub-package must be package-private.
* Currently no classes exist in this package (they will be added in Batch 5),
* so this rule passes vacuously. Once repositories are added, it will enforce
* the package-private convention.
*/
@ArchTest
static final ArchRule internal_repositories_must_not_be_public =
classes()
.that().resideInAPackage(INTERNAL_PKG)
.and().haveSimpleNameEndingWith("Repository")
.should().notBePublic()
.as("A-7: Internal Repository classes must be package-private");
/*
* A-7b: Outside code must not depend on internal persistence Repository implementations.
* Uses a combined target-class predicate: classes that BOTH reside in the internal
* package AND have names ending with "Repository".
*/
@ArchTest
static final ArchRule outside_must_not_depend_on_internal_repositories =
noClasses()
.that().resideOutsideOfPackage(INTERNAL_PKG)
.should().dependOnClassesThat(
com.tngtech.archunit.core.domain.JavaClass.Predicates
.resideInAPackage(INTERNAL_PKG)
.and(com.tngtech.archunit.core.domain.JavaClass.Predicates
.simpleNameEndingWith("Repository"))
)
.as("A-7b: Outside code must not depend on internal Repository implementations");
/*
* A-8: No cyclic dependencies between top-level module packages
* (domain, application, infrastructure, api).
*/
@ArchTest
static final ArchRule no_top_level_package_cycles =
slices()
.matching("com.ljwx.tmpl.(*)..")
.should().beFreeOfCycles()
.as("A-8: No cyclic dependencies between top-level module packages");
}
@@ -0,0 +1,35 @@
package com.ljwx.tmpl.api.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.transaction.annotation.Transactional;
/**
* A-4 — {@code @Transactional} boundary enforcement.
* <p>
* {@code @Transactional} (class-level or method-level) is only allowed
* in the application layer ({@code com.ljwx.tmpl.application..}).
*/
@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class)
class TransactionalBoundaryTest {
@ArchTest
static final ArchRule transactional_classes_only_in_application =
classes()
.that().areAnnotatedWith(Transactional.class)
.should().resideInAPackage("com.ljwx.tmpl.application..")
.as("A-4: @Transactional on classes is only allowed in the application layer");
@ArchTest
static final ArchRule transactional_methods_only_in_application =
methods()
.that().areAnnotatedWith(Transactional.class)
.should().beDeclaredInClassesThat()
.resideInAPackage("com.ljwx.tmpl.application..")
.as("A-4: @Transactional on methods is only allowed in the application layer");
}
@@ -0,0 +1,16 @@
/**
* ArchUnit A-class architecture tests — CI blocking rules.
*
* <p>Enforced contracts (v5 frozen):
* <ul>
* <li>A-1 Layer dependency direction: api → infrastructure → application → domain</li>
* <li>A-2 Domain purity: no Spring / JPA / Hibernate; no dependency on outer layers</li>
* <li>A-3 Application layer: no Web / Persistence / Security / Client imports</li>
* <li>A-4 {@code @Transactional} only in application layer</li>
* <li>A-5 No field injection ({@code @Autowired} on fields)</li>
* <li>A-6 Controllers must not depend on infrastructure</li>
* <li>A-7 Internal repository implementations must not be public</li>
* <li>A-8 No cyclic package dependencies</li>
* </ul>
*/
package com.ljwx.tmpl.api.architecture;