Recipe: AtlasArc with ArchUnit

Choose this recipe when a Java/Kotlin project already owns architecture tests through ArchUnit, or deliberately wants AtlasArc expressed as an ArchRule over an imported JVM class universe. A cycle accepted in the IDE does not break the matching rule, while a new ungoverned cycle still fails CI.

The decision comes from .atlasarc/governance/cycles.json. The rule does not look for annotations and does not maintain a second acceptance list. It gives ArchUnit's imported Java/Kotlin classes to the same AtlasArc evidence mapper, governance matcher, and problem-cycle classifier used by the IDE and standalone evaluator.

fromRepository(...) also reads committed .atlasarc/governance/scope.json. AtlasArc applies that policy to ArchUnit's imported class universe before cycle detection and governance matching, so the native rule evaluates the same repository evidence boundary as the IDE, whole-model reports, JUnit, and standalone CI. ArchUnit still determines which classes are available to the rule: repository scope can remove matching packages from that universe, but it cannot import evidence ArchUnit did not provide.

This is the native ArchUnit path for Java/Kotlin bytecode. A shared cycles.json may also contain TypeScript decisions; the rule leaves those records not evaluated by the JVM test instead of treating them as invalid. Covered JVM defects and a malformed governance file still fail closed. If the project does not already use ArchUnit, or one test must own TypeScript or mixed-stack evidence, start with the JUnit adapter recipe.

How it fits together

1. Add the test dependencies

AtlasArc's Apache-2.0 ArchUnit adapter is available from Maven Central as io.atlasarc:atlasarc-archunit:1.4.0. Add it beside ArchUnit's JUnit 5 integration:

// build.gradle.kts
dependencies {
    testImplementation("io.atlasarc:atlasarc-archunit:1.4.0")
    testImplementation("com.tngtech.archunit:archunit-junit5:1.4.2")
}
<dependency>
  <groupId>io.atlasarc</groupId>
  <artifactId>atlasarc-archunit</artifactId>
  <version>1.4.0</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.tngtech.archunit</groupId>
  <artifactId>archunit-junit5</artifactId>
  <version>1.4.2</version>
  <scope>test</scope>
</dependency>

The ArchUnit adapter, standalone evaluator, portable governance core, and JUnit adapter live in the public AtlasArc.io CI repository. ArchUnit itself remains your normal test dependency. The pinned 1.4.0 Java example demonstrates both the governed pass and ungoverned failure for this published rule.

2. Add the governed-cycle rule

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import io.atlasarc.archunit.AtlasArcGovernanceRules;

import java.nio.file.Path;

@AnalyzeClasses(packages = "com.myapp")
class ArchitectureTest {

    @ArchTest
    static final ArchRule cyclesAcceptedByAtlasArc =
        AtlasArcGovernanceRules.governedCycles()
            .fromRepository(Path.of("."))
            .forAnalysisSource("jvm:whole-project")
            .withSourceRoot(Path.of("src/main/java"))
            .withClassRoot(Path.of("target/classes"))
            .build();
}

For Kotlin or mixed JVM code, add each real source root:

.withSourceRoot(Path.of("src/main/java"))
.withSourceRoot(Path.of("src/main/kotlin"))
.withClassRoot(Path.of("target/classes"))

For a multi-module whole-project rule, qualify every source and class root with the same stable module label used by the IDE and evaluator:

.withModuleSourceRoot("orders", Path.of("orders/src/main/java"))
.withModuleSourceRoot("billing", Path.of("billing/src/main/java"))
.withModuleClassRoot("orders", Path.of("orders/target/classes"))
.withModuleClassRoot("billing", Path.of("billing/target/classes"))

Module labels keep governance decisions scoped to the intended module. Class roots make attribution deterministic even when modules contain the same package and source-file names. If a selector could refer to more than one module, AtlasArc fails closed until the decision is qualified.

For genuinely module-less JVM code, use withSourceRoot(Path) and withClassRoot(Path) instead. This keeps the root package as the hierarchy root. Do not mix named and module-less roots in one rule.

If governance was authored with Include tests selected in the IDE, configure @AnalyzeClasses or your explicit ClassFileImporter to import the same compiled test classes and add their source roots with the same module labels:

.withModuleSourceRoot("orders", Path.of("orders/src/main/java"))
.withModuleSourceRoot("orders", Path.of("orders/src/test/java"))
.withModuleClassRoot("orders", Path.of("orders/target/classes"))
.withModuleClassRoot("orders", Path.of("orders/target/test-classes"))

If Include tests was not selected, keep the ArchUnit import production-only. The checkbox does not create a different analysis-source ID; it changes the evidence included under that source, so IDE and CI input scope must agree.

The analysis-source ID labels this ArchUnit acquisition in diagnostics and record provenance; it is not a governance namespace or an exact-ID applicability gate. Use jvm:whole-project for a repository-wide imported class set, or jvm:module:<name> when the rule imports only that module. Stable module labels on the source roots are what keep identically named packages independent.

3. Run it as an ordinary test

Run ./gradlew test, mvn test, or the same test task in CI. The rule produces an ArchUnit architecture violation when:

This is the value of the ArchUnit adapter alongside the configured evaluator: an existing architecture suite keeps ownership of class import and receives the AtlasArc verdict as a native rule. Use the JUnit adapter when the evaluator config should own acquisition, or the standalone CLI when a pipeline needs process exits or machine output.

Intentional and debt records both honour the reviewed dependency decision. Debt remains debt in AtlasArc's review and reporting surfaces; the ArchUnit rule's job is to enforce whether an ungoverned problem cycle remains.

What the executable proof checks

AtlasArc's own build tests this recipe against compiled fixture classes. The proof is bracketed on both sides:

  1. a plain ArchUnit slice rule confirms that the fixture really contains a package cycle;
  2. the AtlasArc rule passes when cycles.json governs the cycle-forming dependency;
  3. the same rule still fails when a separate ungoverned cycle is added.

That third check prevents a broad exclusion or unreadable governance file from accidentally making the build green.

Limits

Related

See AtlasArc.io CI with JUnit for the configured test path, Govern cycle exceptions for the IDE workflow and stale-record repair, or AtlasArc.io CI for the full integration chooser.