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:
@@ -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)");
|
||||
}
|
||||
+40
@@ -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;
|
||||
Reference in New Issue
Block a user