Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions architecture-validator/docs/user-guide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,25 @@ The plugin provides ten built-in architecture rules that enforce key Hexagonal A
=== Rule: `domain_must_only_depend_on_domain_or_jdk_core`
Domain model classes (identified by the `domainModel` package pattern) may only depend on other classes inside the `domainModel` packages themselves or on the configured `domainAllowedPackages` JDK allow-list (`java.lang..`, `java.time..`, `java.util..`, and `java.math..` by default).
This is an allow-list, not a deny-list: it exhaustively keeps the domain free of adapters, domain services, ports, `commonPackages`, and any third-party or framework dependency (Jackson, JPA, Lombok, Spring, …).
The rule also inspects source annotations before compilation, so source-retained annotations such as Lombok's `@Getter` cannot bypass the allow-list.
`domainAllowedPackages` is a plain, user-configurable list — a project may extend it with another JDK package, or shrink it (including removing `java.math..`, or every JDK-core entry) if it wants the domain model held to an even stricter standard.

=== Rule: `domain_services_must_only_depend_on_domain_core_and_ports`
Domain service classes (identified by the `domainServices` package pattern) may depend on the domain model, other domain services, inbound/outbound ports, or `domainAllowedPackages` — but never on adapters or any third-party/framework class.
Source annotations are evaluated against this same allow-list, including annotations with `SOURCE` retention that do not appear in compiled bytecode.
The `domainServices` package covers what the accompanying book calls both "application services" (which orchestrate the flow through the hexagon) and "domain services" (which enforce cross-aggregate business rules): in practice both live in the same package and are held to the same architectural constraints, since the distinction is one of intent/semantics rather than package location.

=== Rule: `ports_must_only_depend_on_domain_or_jdk_core`
Port-freedom: inbound and outbound port interfaces may only depend on the domain model, other ports, or `domainAllowedPackages`.
A port contract must stay completely framework-free — its *implementation* is free to depend on whatever framework or library it needs, but the port interface itself, and any types it references in its signature, must not.
The same restriction applies to source annotations on port declarations and their members.

=== Source Annotation Findings and Duplicate Violations
The built-in allow-list rules inspect both compiled dependencies through ArchUnit and annotation usages from the Java source. The source analysis exists because annotations with `SOURCE` retention, such as many Lombok annotations, are removed by compilation and cannot be seen by ArchUnit.

An ordinary imported type is checked only by ArchUnit. A source-retained annotation is checked only by source analysis. An annotation with `CLASS` or `RUNTIME` retention may be visible to both mechanisms, depending on how ArchUnit models that annotation as a dependency. In that case, the report can show two violations for the same forbidden annotation: one from the relevant ArchUnit rule and one from `source_annotation_dependencies_must_obey_core_allow_lists`.

This duplication is intentional. Both violations apply the same allow-list policy and identify separate evidence sources: the compiled class and the original Java source. The source finding should not be disabled merely to reduce report counts, because doing so would reopen the gap for source-retained annotations.

=== Rule: `inbound_ports_must_be_interfaces` / `outbound_ports_must_be_interfaces`
Classes in the `inPorts`/`outPorts` packages (excluding `portDataTypePackages`, such as command/result DTOs that legitimately live alongside ports) must be interfaces, not concrete classes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package com.arc_e_tect.gradle.architecture;

import com.sun.source.tree.AnnotationTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.ImportTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TreePathScanner;
import com.tngtech.archunit.core.domain.PackageMatcher;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.ConfigurableFileCollection;
Expand Down Expand Up @@ -35,6 +41,10 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

/**
* Generates the built-in hexagonal architecture test, and a bridge suite for any external rule
Expand All @@ -46,6 +56,7 @@ public abstract class GenerateArchitectureTestsTask extends DefaultTask {
private static final String TEMPLATE_PATH = "templates/HexagonalArchitectureTest.java.template";
private static final String GENERATED_PACKAGE = "com.arc_e_tect.gradle.architecture.generated";
private static final String EXTERNAL_SUITE_CLASS_NAME = "ExternalRulePackSuite";
private static final String SOURCE_DEPENDENCY_TEST_CLASS_NAME = "SourceDependencyValidationTest";
private static final Pattern PACKAGE_SEGMENT = Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*");

/** Creates a new task instance. Instantiated by Gradle infrastructure. */
Expand Down Expand Up @@ -283,13 +294,214 @@ public void generate() {
Path target = outputRoot.resolve("com/arc_e_tect/gradle/architecture/generated/HexagonalArchitectureTest.java");
Files.createDirectories(target.getParent());
Files.writeString(target, rendered, StandardCharsets.UTF_8);
generateSourceDependencyValidationTest(outputRoot);
}
generateExternalRulePackSuite(outputRoot);
} catch (IOException exception) {
throw new GradleException("Failed to generate architecture tests", exception);
}
}

private void generateSourceDependencyValidationTest(Path outputRoot) throws IOException {
List<SourceDependencyViolation> violations = findSourceAnnotationDependencyViolations();
Path target = outputRoot.resolve(
"com/arc_e_tect/gradle/architecture/generated/" + SOURCE_DEPENDENCY_TEST_CLASS_NAME + ".java");
Files.createDirectories(target.getParent());
Files.writeString(target, renderSourceDependencyValidationTest(violations), StandardCharsets.UTF_8);
}

private List<SourceDependencyViolation> findSourceAnnotationDependencyViolations() throws IOException {
if (!getMainSourceDirectory().isPresent()) {
return List.of();
}
Path sourceRoot = getMainSourceDirectory().get().getAsFile().toPath();
if (!Files.isDirectory(sourceRoot)) {
return List.of();
}

List<File> sourceFiles;
try (Stream<Path> paths = Files.walk(sourceRoot)) {
sourceFiles = paths
.filter(path -> path.toString().endsWith(".java"))
.map(Path::toFile)
.toList();
}
if (sourceFiles.isEmpty()) {
return List.of();
}

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new GradleException("Architecture source dependency validation requires a JDK, not a JRE");
}

try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
Iterable<? extends JavaFileObject> units = fileManager.getJavaFileObjectsFromFiles(sourceFiles);
JavacTask task = (JavacTask) compiler.getTask(null, fileManager, null, List.of("-proc:none"), null, units);
List<SourceDependencyViolation> violations = new ArrayList<>();
for (CompilationUnitTree unit : task.parse()) {
String packageName = unit.getPackageName() == null ? "" : unit.getPackageName().toString();
List<SourceDependencyPolicy> policies = policiesFor(packageName);
if (policies.isEmpty()) {
continue;
}

for (String annotationType : annotationTypes(unit, packageName)) {
for (SourceDependencyPolicy policy : policies) {
if (!residesInAnyPackage(annotationType, policy.allowedPackages())) {
violations.add(new SourceDependencyViolation(
policy.ruleName(),
sourceRoot.relativize(Path.of(unit.getSourceFile().toUri())).toString(),
annotationType,
policy.description()));
}
}
}
}
return violations;
}
}

private List<SourceDependencyPolicy> policiesFor(String packageName) {
List<SourceDependencyPolicy> policies = new ArrayList<>();
if (packageMatchesAny(packageName, getDomainModel().get())) {
policies.add(new SourceDependencyPolicy(
"domain_must_only_depend_on_domain_or_jdk_core",
concat(getDomainModel().get(), getDomainAllowedPackages().get()),
"domain model, or the configured JDK allow-list"));
}
if (packageMatchesAny(packageName, getDomainServices().get())) {
policies.add(new SourceDependencyPolicy(
"domain_services_must_only_depend_on_domain_core_and_ports",
concat(getDomainModel().get(), getDomainServices().get(), getInPorts().get(), getOutPorts().get(),
getDomainAllowedPackages().get()),
"domain model, domain services, ports, or the configured JDK allow-list"));
}
if (packageMatchesAny(packageName, resolveAllPortPatterns())) {
policies.add(new SourceDependencyPolicy(
"ports_must_only_depend_on_domain_or_jdk_core",
concat(getDomainModel().get(), getInPorts().get(), getOutPorts().get(), getDomainAllowedPackages().get()),
"domain model, ports, or the configured JDK allow-list"));
}
return policies;
}

@SafeVarargs
private static List<String> concat(List<String>... packageLists) {
return Stream.of(packageLists)
.flatMap(Collection::stream)
.distinct()
.toList();
}

private static boolean residesInAnyPackage(String typeName, List<String> packagePatterns) {
return packageMatchesAny(packageName(typeName), packagePatterns);
}

private static boolean packageMatchesAny(String packageName, List<String> packagePatterns) {
return packagePatterns.stream().anyMatch(pattern -> PackageMatcher.of(pattern).matches(packageName));
}

private static String packageName(String typeName) {
int lastDot = typeName.lastIndexOf('.');
return lastDot < 0 ? "" : typeName.substring(0, lastDot);
}

private static List<String> annotationTypes(CompilationUnitTree unit, String packageName) {
Map<String, String> explicitImports = new LinkedHashMap<>();
List<String> wildcardImports = new ArrayList<>();
for (ImportTree importTree : unit.getImports()) {
if (importTree.isStatic()) {
continue;
}
String importedType = importTree.getQualifiedIdentifier().toString();
if (importedType.endsWith(".*")) {
wildcardImports.add(importedType.substring(0, importedType.length() - 2));
} else {
explicitImports.put(importedType.substring(importedType.lastIndexOf('.') + 1), importedType);
}
}

List<String> annotationTypes = new ArrayList<>();
new TreePathScanner<Void, Void>() {
@Override
public Void visitAnnotation(AnnotationTree annotationTree, Void ignored) {
String annotationType = resolveAnnotationType(
annotationTree.getAnnotationType().toString(),
explicitImports,
wildcardImports);
if (annotationType != null) {
annotationTypes.add(annotationType);
}
return super.visitAnnotation(annotationTree, ignored);
}
}.scan(unit, null);
return annotationTypes;
}

private static String resolveAnnotationType(
String annotationType,
Map<String, String> explicitImports,
List<String> wildcardImports
) {
String explicitImport = explicitImports.get(annotationType);
if (explicitImport != null) {
return explicitImport;
}
if (annotationType.contains(".")) {
String rootType = annotationType.substring(0, annotationType.indexOf('.'));
String rootImport = explicitImports.get(rootType);
return rootImport == null ? annotationType : rootImport + annotationType.substring(rootType.length());
}
if (!wildcardImports.isEmpty()) {
return wildcardImports.getFirst() + "." + annotationType;
}
return null;
}

private static String renderSourceDependencyValidationTest(List<SourceDependencyViolation> violations) {
StringBuilder source = new StringBuilder("""
package com.arc_e_tect.gradle.architecture.generated;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.fail;

class SourceDependencyValidationTest {

@Test
void source_annotation_dependencies_must_obey_core_allow_lists() {
""");
if (violations.isEmpty()) {
source.append(" }\n}\n");
return source.toString();
}

source.append(" fail(\"")
.append(escapeJava(renderViolations(violations)))
.append("\");\n }\n}\n");
return source.toString();
}

private static String renderViolations(List<SourceDependencyViolation> violations) {
return violations.stream()
.map(violation -> violation.ruleName() + ": " + violation.sourceFile()
+ " uses source annotation " + violation.annotationType()
+ ", which is outside the allowed " + violation.allowedDescription())
.collect(Collectors.joining("\\n"));
}

private record SourceDependencyPolicy(String ruleName, List<String> allowedPackages, String description) {
}

private record SourceDependencyViolation(
String ruleName,
String sourceFile,
String annotationType,
String allowedDescription
) {
}

private List<String> resolveCoreLayerPatterns() {
LinkedHashSet<String> patterns = new LinkedHashSet<>();
patterns.addAll(getDomainModel().get());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ void shouldFailWhenInboundAdaptersDependOnServiceImplementationsDirectly() throw
assertThat(result.getOutput()).contains("adapters_must_not_depend_on_domain_services_directly FAILED");
}

@Test
@DisplayName("should fail when a domain model uses a source-retained annotation outside its allow-list")
void shouldFailWhenDomainModelUsesSourceRetainedAnnotationOutsideAllowList() throws IOException {
Path projectDir = createProjectWithSourceRetainedDomainAnnotation("source-retained-domain-annotation");

BuildResult result = createRunner(projectDir)
.withArguments("testArchitecture", "--stacktrace")
.buildAndFail();

assertThat(result.getOutput()).contains("source_annotation_dependencies_must_obey_core_allow_lists");
String mergedXml = readAllXml(projectDir.resolve("build/reports/architecture-validator/xml"));
assertThat(mergedXml)
.contains("domain_must_only_depend_on_domain_or_jdk_core")
.contains("lombok.Getter");
}

private Path createProjectWithFailingArchitectureTest(String projectName, boolean ignoreFailures) throws IOException {
Path projectDir = tempDir.resolve(projectName);
Files.createDirectories(projectDir);
Expand Down Expand Up @@ -180,6 +196,65 @@ void shouldFlagArchitectureViolation() {
return projectDir;
}

private Path createProjectWithSourceRetainedDomainAnnotation(String projectName) throws IOException {
Path projectDir = tempDir.resolve(projectName);
Files.createDirectories(projectDir);

write(projectDir.resolve("settings.gradle"), """
pluginManagement {
repositories {
gradlePluginPortal()
}
}

rootProject.name = '%s'
""".formatted(projectName));

write(projectDir.resolve("build.gradle"), """
plugins {
id 'java'
id 'com.arc-e-tect.architecture-validator'
}

group = 'com.example.archtest'
version = '0.0.1'

repositories {
mavenCentral()
}

architectureValidator {
basePackage = 'com.example.archtest'
}
""");

write(projectDir.resolve("src/main/java/lombok/Getter.java"), """
package lombok;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Getter {
}
""");

write(projectDir.resolve("src/main/java/com/example/archtest/application/domain/model/Order.java"), """
package com.example.archtest.application.domain.model;

import lombok.Getter;

@Getter
public record Order(String id) {
}
""");

return projectDir;
}

private Path createProjectWithEmptyHexagonalPackageStructure(String projectName) throws IOException {
Path projectDir = tempDir.resolve(projectName);
Files.createDirectories(projectDir);
Expand Down
Loading
Loading