diff --git a/buildSrc/README.md b/buildSrc/README.md
index 260ebcf24e..a9fcab3705 100644
--- a/buildSrc/README.md
+++ b/buildSrc/README.md
@@ -9,7 +9,8 @@ The `org.springframework.build.conventions` plugin applies all conventions to th
* Configuring the Java compiler, see `JavaConventions`
* Configuring the Kotlin compiler, see `KotlinConventions`
-* Configuring testing in the build with `TestConventions`
+* Configuring testing in the build with `TestConventions`
+* Configuring the ArchUnit rules for the project, see `org.springframework.build.architecture.ArchitectureRules`
This plugin also provides a DSL extension to optionally enable Java preview features for
compiling and testing sources in a module. This can be applied with the following in a
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
index 5cba7cf86f..dadcb07c4c 100644
--- a/buildSrc/build.gradle
+++ b/buildSrc/build.gradle
@@ -20,6 +20,7 @@ ext {
dependencies {
checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}"
implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}"
+ implementation "com.tngtech.archunit:archunit:1.3.0"
implementation "org.gradle:test-retry-gradle-plugin:1.5.6"
implementation "io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}"
implementation "io.spring.nohttp:nohttp-gradle:0.0.11"
@@ -27,6 +28,10 @@ dependencies {
gradlePlugin {
plugins {
+ architecturePlugin {
+ id = "org.springframework.architecture"
+ implementationClass = "org.springframework.build.architecture.ArchitecturePlugin"
+ }
conventionsPlugin {
id = "org.springframework.build.conventions"
implementationClass = "org.springframework.build.ConventionsPlugin"
diff --git a/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java b/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java
index 3e63c16ed4..1cd9e43cf0 100644
--- a/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java
+++ b/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java
@@ -21,12 +21,15 @@ import org.gradle.api.Project;
import org.gradle.api.plugins.JavaBasePlugin;
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin;
+import org.springframework.build.architecture.ArchitecturePlugin;
+
/**
* Plugin to apply conventions to projects that are part of Spring Framework's build.
* Conventions are applied in response to various plugins being applied.
*
*
When the {@link JavaBasePlugin} is applied, the conventions in {@link CheckstyleConventions},
* {@link TestConventions} and {@link JavaConventions} are applied.
+ * The {@link ArchitecturePlugin} plugin is also applied.
* When the {@link KotlinBasePlugin} is applied, the conventions in {@link KotlinConventions}
* are applied.
*
@@ -37,6 +40,7 @@ public class ConventionsPlugin implements Plugin {
@Override
public void apply(Project project) {
project.getExtensions().create("springFramework", SpringFrameworkExtension.class);
+ new ArchitecturePlugin().apply(project);
new CheckstyleConventions().apply(project);
new JavaConventions().apply(project);
new KotlinConventions().apply(project);
diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java
new file mode 100644
index 0000000000..78c2d94e90
--- /dev/null
+++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2002-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.build.architecture;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.lang.ArchRule;
+import com.tngtech.archunit.lang.EvaluationResult;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.Task;
+import org.gradle.api.file.DirectoryProperty;
+import org.gradle.api.file.FileCollection;
+import org.gradle.api.file.FileTree;
+import org.gradle.api.provider.ListProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.IgnoreEmptyDirectories;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.Internal;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.OutputDirectory;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.SkipWhenEmpty;
+import org.gradle.api.tasks.TaskAction;
+
+import static org.springframework.build.architecture.ArchitectureRules.*;
+
+/**
+ * {@link Task} that checks for architecture problems.
+ *
+ * @author Andy Wilkinson
+ * @author Scott Frederick
+ */
+public abstract class ArchitectureCheck extends DefaultTask {
+
+ private FileCollection classes;
+
+ public ArchitectureCheck() {
+ getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
+ getProhibitObjectsRequireNonNull().convention(true);
+ getRules().addAll(packageInfoShouldBeNullMarked(),
+ classesShouldNotImportForbiddenTypes(),
+ javaClassesShouldNotImportKotlinAnnotations(),
+ allPackagesShouldBeFreeOfTangles(),
+ noClassesShouldCallStringToLowerCaseWithoutLocale(),
+ noClassesShouldCallStringToUpperCaseWithoutLocale());
+ getRuleDescriptions().set(getRules().map((rules) -> rules.stream().map(ArchRule::getDescription).toList()));
+ }
+
+ @TaskAction
+ void checkArchitecture() throws IOException {
+ JavaClasses javaClasses = new ClassFileImporter()
+ .importPaths(this.classes.getFiles().stream().map(File::toPath).toList());
+ List violations = getRules().get()
+ .stream()
+ .map((rule) -> rule.evaluate(javaClasses))
+ .filter(EvaluationResult::hasViolation)
+ .toList();
+ File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
+ outputFile.getParentFile().mkdirs();
+ if (!violations.isEmpty()) {
+ StringBuilder report = new StringBuilder();
+ for (EvaluationResult violation : violations) {
+ report.append(violation.getFailureReport());
+ report.append(String.format("%n"));
+ }
+ Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING);
+ throw new GradleException("Architecture check failed. See '" + outputFile + "' for details.");
+ }
+ else {
+ outputFile.createNewFile();
+ }
+ }
+
+ public void setClasses(FileCollection classes) {
+ this.classes = classes;
+ }
+
+ @Internal
+ public FileCollection getClasses() {
+ return this.classes;
+ }
+
+ @InputFiles
+ @SkipWhenEmpty
+ @IgnoreEmptyDirectories
+ @PathSensitive(PathSensitivity.RELATIVE)
+ final FileTree getInputClasses() {
+ return this.classes.getAsFileTree();
+ }
+
+ @Optional
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract DirectoryProperty getResourcesDirectory();
+
+ @OutputDirectory
+ public abstract DirectoryProperty getOutputDirectory();
+
+ @Internal
+ public abstract ListProperty getRules();
+
+ @Internal
+ public abstract Property getProhibitObjectsRequireNonNull();
+
+ @Input
+ // The rules themselves can't be an input as they aren't serializable so we use
+ // their descriptions instead
+ abstract ListProperty getRuleDescriptions();
+}
diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java
new file mode 100644
index 0000000000..22fdcef2b2
--- /dev/null
+++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2002-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.build.architecture;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.api.plugins.JavaPluginExtension;
+import org.gradle.api.tasks.SourceSet;
+import org.gradle.api.tasks.TaskProvider;
+import org.gradle.language.base.plugins.LifecycleBasePlugin;
+
+/**
+ * {@link Plugin} for verifying a project's architecture.
+ *
+ * @author Andy Wilkinson
+ */
+public class ArchitecturePlugin implements Plugin {
+
+ @Override
+ public void apply(Project project) {
+ project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
+ }
+
+ private void registerTasks(Project project) {
+ JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
+ List> architectureChecks = new ArrayList<>();
+ for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
+ if (sourceSet.getName().contains("test")) {
+ // skip test source sets.
+ continue;
+ }
+ TaskProvider checkArchitecture = project.getTasks()
+ .register(taskName(sourceSet), ArchitectureCheck.class,
+ (task) -> {
+ task.setClasses(sourceSet.getOutput().getClassesDirs());
+ task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
+ task.dependsOn(sourceSet.getProcessResourcesTaskName());
+ task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
+ + " source set.");
+ task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
+ });
+ architectureChecks.add(checkArchitecture);
+ }
+ if (!architectureChecks.isEmpty()) {
+ TaskProvider checkTask = project.getTasks().named(LifecycleBasePlugin.CHECK_TASK_NAME);
+ checkTask.configure((check) -> check.dependsOn(architectureChecks));
+ }
+ }
+
+ private static String taskName(SourceSet sourceSet) {
+ return "checkArchitecture"
+ + sourceSet.getName().substring(0, 1).toUpperCase()
+ + sourceSet.getName().substring(1);
+ }
+
+}
diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java
new file mode 100644
index 0000000000..e03ae4c98f
--- /dev/null
+++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2002-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.build.architecture;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.lang.ArchRule;
+import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
+import com.tngtech.archunit.library.dependencies.SliceAssignment;
+import com.tngtech.archunit.library.dependencies.SliceIdentifier;
+import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
+import java.util.List;
+
+abstract class ArchitectureRules {
+
+ static ArchRule allPackagesShouldBeFreeOfTangles() {
+ return SlicesRuleDefinition.slices()
+ .assignedFrom(new SpringSlices()).should().beFreeOfCycles();
+ }
+
+ static ArchRule noClassesShouldCallStringToLowerCaseWithoutLocale() {
+ return ArchRuleDefinition.noClasses()
+ .should()
+ .callMethod(String.class, "toLowerCase")
+ .because("String.toLowerCase(Locale.ROOT) should be used instead");
+ }
+
+ static ArchRule noClassesShouldCallStringToUpperCaseWithoutLocale() {
+ return ArchRuleDefinition.noClasses()
+ .should()
+ .callMethod(String.class, "toUpperCase")
+ .because("String.toUpperCase(Locale.ROOT) should be used instead");
+ }
+
+ static ArchRule packageInfoShouldBeNullMarked() {
+ return ArchRuleDefinition.classes()
+ .that().haveSimpleName("package-info")
+ .should().beAnnotatedWith("org.jspecify.annotations.NullMarked")
+ .allowEmptyShould(true);
+ }
+
+ static ArchRule classesShouldNotImportForbiddenTypes() {
+ return ArchRuleDefinition.noClasses()
+ .should().dependOnClassesThat()
+ .haveFullyQualifiedName("reactor.core.support.Assert")
+ .orShould().dependOnClassesThat()
+ .haveFullyQualifiedName("org.slf4j.LoggerFactory")
+ .orShould().dependOnClassesThat()
+ .haveFullyQualifiedName("org.springframework.lang.NonNull")
+ .orShould().dependOnClassesThat()
+ .haveFullyQualifiedName("org.springframework.lang.Nullable");
+ }
+
+ static ArchRule javaClassesShouldNotImportKotlinAnnotations() {
+ return ArchRuleDefinition.noClasses()
+ .that().haveSimpleNameNotEndingWith("Kt")
+ .and().haveSimpleNameNotEndingWith("Dsl")
+ .should().dependOnClassesThat()
+ .resideInAnyPackage("org.jetbrains.annotations..")
+ .allowEmptyShould(true);
+ }
+
+ static class SpringSlices implements SliceAssignment {
+
+ private final List ignoredPackages = List.of("org.springframework.asm",
+ "org.springframework.cglib",
+ "org.springframework.javapoet",
+ "org.springframework.objenesis");
+
+ @Override
+ public SliceIdentifier getIdentifierOf(JavaClass javaClass) {
+
+ String packageName = javaClass.getPackageName();
+ for (String ignoredPackage : ignoredPackages) {
+ if (packageName.startsWith(ignoredPackage)) {
+ return SliceIdentifier.ignore();
+ }
+ }
+ return SliceIdentifier.of("spring framework");
+ }
+
+ @Override
+ public String getDescription() {
+ return "Spring Framework Slices";
+ }
+ }
+}
diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml
index 6162aeca7b..a95eb7e692 100644
--- a/src/checkstyle/checkstyle-suppressions.xml
+++ b/src/checkstyle/checkstyle-suppressions.xml
@@ -7,12 +7,9 @@
-
-
-
@@ -25,7 +22,6 @@
-
@@ -37,16 +33,13 @@
-
-
-
diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml
index c62d96fc05..8a345036fc 100644
--- a/src/checkstyle/checkstyle.xml
+++ b/src/checkstyle/checkstyle.xml
@@ -20,16 +20,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -143,11 +133,6 @@
-
-
-
-
-
@@ -220,22 +205,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-