Skip to content

Commit 626687a

Browse files
committed
Add ArchUnit automated architecture checks
See gh-1184
1 parent 6de47bd commit 626687a

File tree

5 files changed

+305
-2
lines changed

5 files changed

+305
-2
lines changed

Diff for: build.gradle

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ ext {
88
}
99

1010
subprojects {
11-
apply plugin: 'org.springframework.graphql.build.conventions'
11+
apply plugin: 'org.springframework.graphql.conventions'
12+
apply plugin: 'org.springframework.graphql.architecture'
1213
group = 'org.springframework.graphql'
1314

1415
ext.javadocLinks = [

Diff for: buildSrc/build.gradle

+6-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
2525
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:${kotlinVersion}")
2626
implementation("io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}")
27+
implementation "com.tngtech.archunit:archunit:1.4.0"
2728
}
2829

2930
checkstyle {
@@ -33,9 +34,13 @@ checkstyle {
3334
gradlePlugin {
3435
plugins {
3536
conventionsPlugin {
36-
id = "org.springframework.graphql.build.conventions"
37+
id = "org.springframework.graphql.conventions"
3738
implementationClass = "org.springframework.graphql.build.ConventionsPlugin"
3839
}
40+
architecturePlugin {
41+
id = "org.springframework.graphql.architecture"
42+
implementationClass = "org.springframework.graphql.build.architecture.ArchitecturePlugin"
43+
}
3944
}
4045
}
4146

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.build.architecture;
18+
19+
import com.tngtech.archunit.core.domain.JavaClasses;
20+
import com.tngtech.archunit.core.importer.ClassFileImporter;
21+
import com.tngtech.archunit.lang.ArchRule;
22+
import com.tngtech.archunit.lang.EvaluationResult;
23+
import java.io.File;
24+
import java.io.IOException;
25+
import java.nio.file.Files;
26+
import java.nio.file.StandardOpenOption;
27+
import java.util.List;
28+
import org.gradle.api.DefaultTask;
29+
import org.gradle.api.GradleException;
30+
import org.gradle.api.Task;
31+
import org.gradle.api.file.DirectoryProperty;
32+
import org.gradle.api.file.FileCollection;
33+
import org.gradle.api.file.FileTree;
34+
import org.gradle.api.provider.ListProperty;
35+
import org.gradle.api.provider.Property;
36+
import org.gradle.api.tasks.IgnoreEmptyDirectories;
37+
import org.gradle.api.tasks.Input;
38+
import org.gradle.api.tasks.InputFiles;
39+
import org.gradle.api.tasks.Internal;
40+
import org.gradle.api.tasks.Optional;
41+
import org.gradle.api.tasks.OutputDirectory;
42+
import org.gradle.api.tasks.PathSensitive;
43+
import org.gradle.api.tasks.PathSensitivity;
44+
import org.gradle.api.tasks.SkipWhenEmpty;
45+
import org.gradle.api.tasks.TaskAction;
46+
47+
import static org.springframework.graphql.build.architecture.ArchitectureRules.allPackagesShouldBeFreeOfTangles;
48+
import static org.springframework.graphql.build.architecture.ArchitectureRules.classesShouldNotImportForbiddenTypes;
49+
import static org.springframework.graphql.build.architecture.ArchitectureRules.javaClassesShouldNotImportKotlinAnnotations;
50+
import static org.springframework.graphql.build.architecture.ArchitectureRules.noClassesShouldCallStringToLowerCaseWithoutLocale;
51+
import static org.springframework.graphql.build.architecture.ArchitectureRules.noClassesShouldCallStringToUpperCaseWithoutLocale;
52+
53+
/**
54+
* {@link Task} that checks for architecture problems.
55+
*
56+
* @author Andy Wilkinson
57+
* @author Scott Frederick
58+
*/
59+
public abstract class ArchitectureCheck extends DefaultTask {
60+
61+
private FileCollection classes;
62+
63+
public ArchitectureCheck() {
64+
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
65+
getProhibitObjectsRequireNonNull().convention(true);
66+
getRules().addAll(
67+
classesShouldNotImportForbiddenTypes(),
68+
javaClassesShouldNotImportKotlinAnnotations(),
69+
allPackagesShouldBeFreeOfTangles(),
70+
noClassesShouldCallStringToLowerCaseWithoutLocale(),
71+
noClassesShouldCallStringToUpperCaseWithoutLocale());
72+
getRuleDescriptions().set(getRules().map((rules) -> rules.stream().map(ArchRule::getDescription).toList()));
73+
}
74+
75+
@TaskAction
76+
void checkArchitecture() throws IOException {
77+
JavaClasses javaClasses = new ClassFileImporter()
78+
.importPaths(this.classes.getFiles().stream().map(File::toPath).toList());
79+
List<EvaluationResult> violations = getRules().get()
80+
.stream()
81+
.map((rule) -> rule.evaluate(javaClasses))
82+
.filter(EvaluationResult::hasViolation)
83+
.toList();
84+
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
85+
outputFile.getParentFile().mkdirs();
86+
if (!violations.isEmpty()) {
87+
StringBuilder report = new StringBuilder();
88+
for (EvaluationResult violation : violations) {
89+
report.append(violation.getFailureReport());
90+
report.append(String.format("%n"));
91+
}
92+
Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE,
93+
StandardOpenOption.TRUNCATE_EXISTING);
94+
throw new GradleException("Architecture check failed. See '" + outputFile + "' for details.");
95+
}
96+
else {
97+
outputFile.createNewFile();
98+
}
99+
}
100+
101+
public void setClasses(FileCollection classes) {
102+
this.classes = classes;
103+
}
104+
105+
@Internal
106+
public FileCollection getClasses() {
107+
return this.classes;
108+
}
109+
110+
@InputFiles
111+
@SkipWhenEmpty
112+
@IgnoreEmptyDirectories
113+
@PathSensitive(PathSensitivity.RELATIVE)
114+
final FileTree getInputClasses() {
115+
return this.classes.getAsFileTree();
116+
}
117+
118+
@Optional
119+
@InputFiles
120+
@PathSensitive(PathSensitivity.RELATIVE)
121+
public abstract DirectoryProperty getResourcesDirectory();
122+
123+
@OutputDirectory
124+
public abstract DirectoryProperty getOutputDirectory();
125+
126+
@Internal
127+
public abstract ListProperty<ArchRule> getRules();
128+
129+
@Internal
130+
public abstract Property<Boolean> getProhibitObjectsRequireNonNull();
131+
132+
@Input
133+
// The rules themselves can't be an input as they aren't serializable so we use
134+
// their descriptions instead
135+
abstract ListProperty<String> getRuleDescriptions();
136+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.build.architecture;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import org.gradle.api.Plugin;
22+
import org.gradle.api.Project;
23+
import org.gradle.api.Task;
24+
import org.gradle.api.plugins.JavaPlugin;
25+
import org.gradle.api.plugins.JavaPluginExtension;
26+
import org.gradle.api.tasks.SourceSet;
27+
import org.gradle.api.tasks.TaskProvider;
28+
import org.gradle.language.base.plugins.LifecycleBasePlugin;
29+
30+
/**
31+
* {@link Plugin} for verifying a project's architecture.
32+
*
33+
* @author Andy Wilkinson
34+
*/
35+
public class ArchitecturePlugin implements Plugin<Project> {
36+
37+
@Override
38+
public void apply(Project project) {
39+
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
40+
}
41+
42+
private void registerTasks(Project project) {
43+
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
44+
List<TaskProvider<ArchitectureCheck>> architectureChecks = new ArrayList<>();
45+
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
46+
if (sourceSet.getName().contains("test")) {
47+
// skip test source sets.
48+
continue;
49+
}
50+
TaskProvider<ArchitectureCheck> checkArchitecture = project.getTasks()
51+
.register(taskName(sourceSet), ArchitectureCheck.class,
52+
(task) -> {
53+
task.setClasses(sourceSet.getOutput().getClassesDirs());
54+
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
55+
task.dependsOn(sourceSet.getProcessResourcesTaskName());
56+
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
57+
+ " source set.");
58+
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
59+
});
60+
architectureChecks.add(checkArchitecture);
61+
}
62+
if (!architectureChecks.isEmpty()) {
63+
TaskProvider<Task> checkTask = project.getTasks().named(LifecycleBasePlugin.CHECK_TASK_NAME);
64+
checkTask.configure((check) -> check.dependsOn(architectureChecks));
65+
}
66+
}
67+
68+
private static String taskName(SourceSet sourceSet) {
69+
return "checkArchitecture"
70+
+ sourceSet.getName().substring(0, 1).toUpperCase()
71+
+ sourceSet.getName().substring(1);
72+
}
73+
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.build.architecture;
18+
19+
import com.tngtech.archunit.base.DescribedPredicate;
20+
import com.tngtech.archunit.core.domain.JavaClass;
21+
import com.tngtech.archunit.lang.ArchRule;
22+
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
23+
import com.tngtech.archunit.library.dependencies.SliceAssignment;
24+
import com.tngtech.archunit.library.dependencies.SliceIdentifier;
25+
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
26+
import java.util.List;
27+
28+
abstract class ArchitectureRules {
29+
30+
static ArchRule allPackagesShouldBeFreeOfTangles() {
31+
return SlicesRuleDefinition.slices().matching("(**)").should().beFreeOfCycles();
32+
}
33+
34+
static ArchRule noClassesShouldCallStringToLowerCaseWithoutLocale() {
35+
return ArchRuleDefinition.noClasses()
36+
.should()
37+
.callMethod(String.class, "toLowerCase")
38+
.because("String.toLowerCase(Locale.ROOT) should be used instead");
39+
}
40+
41+
static ArchRule noClassesShouldCallStringToUpperCaseWithoutLocale() {
42+
return ArchRuleDefinition.noClasses()
43+
.should()
44+
.callMethod(String.class, "toUpperCase")
45+
.because("String.toUpperCase(Locale.ROOT) should be used instead");
46+
}
47+
48+
static ArchRule packageInfoShouldBeNullMarked() {
49+
return ArchRuleDefinition.classes()
50+
.that().haveSimpleName("package-info")
51+
.should().beAnnotatedWith("org.jspecify.annotations.NullMarked")
52+
.allowEmptyShould(true);
53+
}
54+
55+
static ArchRule classShouldNotUseSpringNullAnnotations() {
56+
return ArchRuleDefinition.noClasses()
57+
.should().dependOnClassesThat()
58+
.haveFullyQualifiedName("org.springframework.lang.NonNull")
59+
.orShould().dependOnClassesThat()
60+
.haveFullyQualifiedName("org.springframework.lang.Nullable");
61+
}
62+
63+
64+
static ArchRule classesShouldNotImportForbiddenTypes() {
65+
return ArchRuleDefinition.noClasses()
66+
.should().dependOnClassesThat()
67+
.haveFullyQualifiedName("reactor.core.support.Assert")
68+
.orShould().dependOnClassesThat()
69+
.haveFullyQualifiedName("org.slf4j.LoggerFactory");
70+
}
71+
72+
static ArchRule javaClassesShouldNotImportKotlinAnnotations() {
73+
return ArchRuleDefinition.noClasses()
74+
.that(new DescribedPredicate<JavaClass>("is not a Kotlin class") {
75+
@Override
76+
public boolean test(JavaClass javaClass) {
77+
return javaClass.getSourceCodeLocation()
78+
.getSourceFileName().endsWith(".java");
79+
}
80+
}
81+
)
82+
.should().dependOnClassesThat()
83+
.resideInAnyPackage("org.jetbrains.annotations..")
84+
.allowEmptyShould(true);
85+
}
86+
87+
}

0 commit comments

Comments
 (0)