From 26003e4c8f8a78198c77ee649427079c74dbbabb Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Mon, 13 Jul 2026 15:31:05 +0200 Subject: [PATCH 1/5] Implement new rule S9021 This rule detects Spring @Configuration classes declared with the 'final' modifier, which prevents Spring from creating CGLIB proxies necessary for proper bean lifecycle management and singleton enforcement. --- ...ationClassShouldNotBeFinalCheckSample.java | 119 ++++++++++++++++++ ...nfigurationClassShouldNotBeFinalCheck.java | 88 +++++++++++++ ...urationClassShouldNotBeFinalCheckTest.java | 34 +++++ .../org/sonar/l10n/java/rules/java/S9021.html | 97 ++++++++++++++ .../org/sonar/l10n/java/rules/java/S9021.json | 22 ++++ 5 files changed, 360 insertions(+) create mode 100644 java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java create mode 100644 java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java create mode 100644 java-checks/src/test/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckTest.java create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.html create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.json diff --git a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java new file mode 100644 index 00000000000..9520e23e460 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java @@ -0,0 +1,119 @@ +package checks.spring; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Component; + +@Configuration +public final class BasicFinalConfig { // Noncompliant {{Remove the "final" modifier from this "@Configuration" class.}} +// ^^^^^ + + @Bean + public String dataSource() { + return "dataSource"; + } +} + +@Configuration +public final class FinalConfigMultipleBeans { // Noncompliant + + @Bean + public String foo() { + return "foo"; + } + + @Bean + public String bar() { + return foo(); + } +} + +@Configuration +@EnableScheduling +public final class FinalConfigOtherAnnotations { // Noncompliant + + @Bean + public String scheduler() { + return "scheduler"; + } +} + +class OuterClass { + @Configuration + public static final class NestedFinalConfig { // Noncompliant + + @Bean + public String taskScheduler() { + return "taskScheduler"; + } + } +} + +@Configuration(proxyBeanMethods = true) +public final class FinalConfigProxyEnabled { // Noncompliant + + @Bean + public String dataSource() { + return "dataSource"; + } +} + +@Configuration +public class NonFinalConfig { + + @Bean + public String dataSource() { + return "dataSource"; + } + + @Bean + public String jdbcTemplate() { + return dataSource(); + } +} + +@Configuration(proxyBeanMethods = false) +public final class FinalConfigProxyDisabled { + + @Bean + public String dataSource() { + return "dataSource"; + } +} + +@Component +public final class FinalComponentNotConfiguration { + + public String createDataSource() { + return "dataSource"; + } +} + +@Configuration +public abstract class AbstractConfig { + + @Bean + public String dataSource() { + return "dataSource"; + } +} + +class NoAnnotation { + public final class NotAConfigurationClass { + public String method() { + return "value"; + } + } +} + +class OuterClassCompliant { + @Configuration + public static class NestedNonFinalConfig { + + @Bean + public String taskScheduler() { + return "taskScheduler"; + } + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java new file mode 100644 index 00000000000..93f75222c5a --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java @@ -0,0 +1,88 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks.spring; + +import java.util.List; +import java.util.Optional; +import org.sonar.check.Rule; +import org.sonar.java.checks.helpers.ExpressionsHelper; +import org.sonar.java.checks.helpers.SpringUtils; +import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.tree.AnnotationTree; +import org.sonar.plugins.java.api.tree.AssignmentExpressionTree; +import org.sonar.plugins.java.api.tree.ClassTree; +import org.sonar.plugins.java.api.tree.IdentifierTree; +import org.sonar.plugins.java.api.tree.Modifier; +import org.sonar.plugins.java.api.tree.ModifierKeywordTree; +import org.sonar.plugins.java.api.tree.Tree; + +@Rule(key = "S9021") +public class ConfigurationClassShouldNotBeFinalCheck extends IssuableSubscriptionVisitor { + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.CLASS); + } + + @Override + public void visitNode(Tree tree) { + ClassTree classTree = (ClassTree) tree; + + Optional configurationAnnotation = getConfigurationAnnotation(classTree); + if (configurationAnnotation.isEmpty()) { + return; + } + + Optional finalModifier = getFinalModifier(classTree); + if (finalModifier.isEmpty()) { + return; + } + + if (hasProxyBeanMethodsDisabled(configurationAnnotation.get())) { + return; + } + + reportIssue(finalModifier.get(), "Remove the \"final\" modifier from this \"@Configuration\" class."); + } + + private static Optional getConfigurationAnnotation(ClassTree tree) { + return tree.modifiers().annotations().stream() + .filter(annotation -> annotation.symbolType().is(SpringUtils.CONFIGURATION_ANNOTATION)) + .findFirst(); + } + + private static Optional getFinalModifier(ClassTree tree) { + return tree.modifiers().stream() + .filter(modifier -> modifier.is(Tree.Kind.MODIFIER)) + .map(ModifierKeywordTree.class::cast) + .filter(modifier -> modifier.modifier() == Modifier.FINAL) + .findFirst(); + } + + private static boolean hasProxyBeanMethodsDisabled(AnnotationTree annotation) { + return annotation.arguments().stream() + .filter(argument -> argument.is(Tree.Kind.ASSIGNMENT)) + .map(AssignmentExpressionTree.class::cast) + .anyMatch(ConfigurationClassShouldNotBeFinalCheck::setsProxyBeanMethodsToFalse); + } + + private static boolean setsProxyBeanMethodsToFalse(AssignmentExpressionTree assignment) { + return "proxyBeanMethods".equals(((IdentifierTree) assignment.variable()).name()) && + Boolean.FALSE.equals(ExpressionsHelper.getConstantValueAsBoolean(assignment.expression()).value()); + } + +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckTest.java new file mode 100644 index 00000000000..1b9da225479 --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckTest.java @@ -0,0 +1,34 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.checks.spring; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class ConfigurationClassShouldNotBeFinalCheckTest { + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java")) + .withCheck(new ConfigurationClassShouldNotBeFinalCheck()) + .verifyIssues(); + } + +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.html new file mode 100644 index 00000000000..3ade16fe3cd --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.html @@ -0,0 +1,97 @@ +

Why is this an issue?

+

Spring Framework uses CGLIB proxies to enforce singleton semantics for beans defined in @Configuration classes. When you call a +@Bean method from within another @Bean method, Spring intercepts the call through a proxy to return the singleton instance +rather than creating a new object.

+

The Java final keyword prevents class inheritance, which blocks CGLIB from creating the necessary subclass proxy. Without this proxy, +inter-bean method calls create new instances instead of retrieving the singleton, breaking Spring's container management.

+

This typically manifests as a ConfigurationClassPostProcessor exception during application startup, preventing the Spring context from +initializing.

+

What is the potential impact?

+

When Spring @Configuration classes are marked final, the application fails to start. The +ConfigurationClassPostProcessor throws an exception because CGLIB cannot create the required proxy subclass.

+

If this check were bypassed, the impact would be more subtle: inter-bean method calls would create duplicate instances instead of using singletons, +leading to:

+
    +
  • Resource leaks: Multiple database connection pools or file handles
  • +
  • Inconsistent state: Different parts of the application working with different object instances
  • +
  • Configuration drift: Bean initialization logic executing multiple times with potentially different results
  • +
+

How to fix it

+

Remove the final modifier from the @Configuration class. This allows Spring to create the CGLIB proxy needed to enforce +singleton semantics for inter-bean method calls.

+

Code examples

+

Noncompliant code example

+
+@Configuration
+public final class DatabaseConfig {  // Noncompliant
+
+    @Bean
+    public DataSource dataSource() {
+        return new HikariDataSource();
+    }
+
+    @Bean
+    public JdbcTemplate jdbcTemplate() {
+        return new JdbcTemplate(dataSource());  // May create duplicate DataSource
+    }
+}
+
+

Compliant solution

+
+@Configuration
+public class DatabaseConfig {
+
+    @Bean
+    public DataSource dataSource() {
+        return new HikariDataSource();
+    }
+
+    @Bean
+    public JdbcTemplate jdbcTemplate() {
+        return new JdbcTemplate(dataSource());  // Uses singleton DataSource
+    }
+}
+
+

If you don't need inter-bean method calls and want to optimize startup performance, use @Configuration(proxyBeanMethods = false). This +disables CGLIB proxying, allowing the class to be final. However, you must use dependency injection instead of calling @Bean +methods directly.

+

Noncompliant code example

+
+@Configuration
+public final class DatabaseConfig {  // Noncompliant
+
+    @Bean
+    public DataSource dataSource() {
+        return new HikariDataSource();
+    }
+
+    @Bean
+    public JdbcTemplate jdbcTemplate() {
+        return new JdbcTemplate(dataSource());  // Direct method call
+    }
+}
+
+

Compliant solution

+
+@Configuration(proxyBeanMethods = false)
+public final class DatabaseConfig {
+
+    @Bean
+    public DataSource dataSource() {
+        return new HikariDataSource();
+    }
+
+    @Bean
+    public JdbcTemplate jdbcTemplate(DataSource dataSource) {  // Injected parameter
+        return new JdbcTemplate(dataSource);
+    }
+}
+
+

Resources

+

Documentation

+ diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.json new file mode 100644 index 00000000000..cdca057e4e8 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9021.json @@ -0,0 +1,22 @@ +{ + "title": "\"@Configuration\" classes should not be final", + "type": "BUG", + "status": "ready", + "remediation": { + "func": "Constant/Issue", + "constantCost": "5min" + }, + "tags": [ + "spring" + ], + "defaultSeverity": "Critical", + "ruleSpecification": "RSPEC-9021", + "sqKey": "S9021", + "scope": "Main", + "code": { + "impacts": { + "RELIABILITY": "HIGH" + }, + "attribute": "LOGICAL" + } +} From 1addd80d7bbfdc274e71619e0d64cabfa287d6cb Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Tue, 14 Jul 2026 09:18:01 +0200 Subject: [PATCH 2/5] Fix CI: Fixed compilation error in ConfigurationClassShouldNotBeFinalCheck by replacing invalid Tree.Kind.MODIFIER with ModifiersUtils.findModifier() --- .../spring/ConfigurationClassShouldNotBeFinalCheck.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java index 93f75222c5a..fc90555b138 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java @@ -21,6 +21,7 @@ import org.sonar.check.Rule; import org.sonar.java.checks.helpers.ExpressionsHelper; import org.sonar.java.checks.helpers.SpringUtils; +import org.sonar.java.model.ModifiersUtils; import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; import org.sonar.plugins.java.api.tree.AnnotationTree; import org.sonar.plugins.java.api.tree.AssignmentExpressionTree; @@ -66,11 +67,7 @@ private static Optional getConfigurationAnnotation(ClassTree tre } private static Optional getFinalModifier(ClassTree tree) { - return tree.modifiers().stream() - .filter(modifier -> modifier.is(Tree.Kind.MODIFIER)) - .map(ModifierKeywordTree.class::cast) - .filter(modifier -> modifier.modifier() == Modifier.FINAL) - .findFirst(); + return ModifiersUtils.findModifier(tree.modifiers(), Modifier.FINAL); } private static boolean hasProxyBeanMethodsDisabled(AnnotationTree annotation) { From c0a0f2ec3a852010570fe6e5cdd158775fe7f0c3 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Tue, 14 Jul 2026 09:25:01 +0200 Subject: [PATCH 3/5] Fix CI failures - Removed `public` modifier from top-level classes in ConfigurationClassShouldNotBeFinalCheckSample.java to fix Java compilation error (multiple public classes in a single file) - Removed `public` access modifier from top-level classes in ConfigurationClassShouldNotBeFinalCheckSample.java to fix compilation error caused by multiple public classes in a single file. --- ...gurationClassShouldNotBeFinalCheckSample.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java index 9520e23e460..105a6cc81c0 100644 --- a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; @Configuration -public final class BasicFinalConfig { // Noncompliant {{Remove the "final" modifier from this "@Configuration" class.}} +final class BasicFinalConfig { // Noncompliant {{Remove the "final" modifier from this "@Configuration" class.}} // ^^^^^ @Bean @@ -16,7 +16,7 @@ public String dataSource() { } @Configuration -public final class FinalConfigMultipleBeans { // Noncompliant +final class FinalConfigMultipleBeans { // Noncompliant @Bean public String foo() { @@ -31,7 +31,7 @@ public String bar() { @Configuration @EnableScheduling -public final class FinalConfigOtherAnnotations { // Noncompliant +final class FinalConfigOtherAnnotations { // Noncompliant @Bean public String scheduler() { @@ -51,7 +51,7 @@ public String taskScheduler() { } @Configuration(proxyBeanMethods = true) -public final class FinalConfigProxyEnabled { // Noncompliant +final class FinalConfigProxyEnabled { // Noncompliant @Bean public String dataSource() { @@ -60,7 +60,7 @@ public String dataSource() { } @Configuration -public class NonFinalConfig { +class NonFinalConfig { @Bean public String dataSource() { @@ -74,7 +74,7 @@ public String jdbcTemplate() { } @Configuration(proxyBeanMethods = false) -public final class FinalConfigProxyDisabled { +final class FinalConfigProxyDisabled { @Bean public String dataSource() { @@ -83,7 +83,7 @@ public String dataSource() { } @Component -public final class FinalComponentNotConfiguration { +final class FinalComponentNotConfiguration { public String createDataSource() { return "dataSource"; @@ -91,7 +91,7 @@ public String createDataSource() { } @Configuration -public abstract class AbstractConfig { +abstract class AbstractConfig { @Bean public String dataSource() { From 0e784c5e5f211c2acc224a9ef8c2bcb6e9d483ac Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Tue, 14 Jul 2026 09:40:49 +0200 Subject: [PATCH 4/5] Fix CI: Removed incorrect column marker '// ^^^^^' from ConfigurationClassShouldNotBeFinalCheckSample.java that pointed to column 8 (position of old 'public' modifier) after 'public' was removed and 'final' moved to column 1 --- .../spring/ConfigurationClassShouldNotBeFinalCheckSample.java | 1 - 1 file changed, 1 deletion(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java index 105a6cc81c0..4c26c2f6660 100644 --- a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java @@ -7,7 +7,6 @@ @Configuration final class BasicFinalConfig { // Noncompliant {{Remove the "final" modifier from this "@Configuration" class.}} -// ^^^^^ @Bean public String dataSource() { From 36ee1079ad8ed510741d3851bea77777ec8ca206 Mon Sep 17 00:00:00 2001 From: Romain Brenguier Date: Tue, 14 Jul 2026 10:19:29 +0200 Subject: [PATCH 5/5] Document intentional limitation to direct @Configuration annotations Add explicit documentation and test case clarifying that S9021 intentionally only detects direct @Configuration annotations, not meta-annotations like @SpringBootApplication. This design decision avoids complexity with checking proxyBeanMethods on composed annotations. Co-Authored-By: Claude Sonnet 4.5 --- ...onfigurationClassShouldNotBeFinalCheckSample.java | 12 ++++++++++++ .../ConfigurationClassShouldNotBeFinalCheck.java | 2 ++ 2 files changed, 14 insertions(+) diff --git a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java index 4c26c2f6660..a14fbeaed72 100644 --- a/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/spring/ConfigurationClassShouldNotBeFinalCheckSample.java @@ -1,5 +1,6 @@ package checks.spring; +import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -116,3 +117,14 @@ public String taskScheduler() { } } } + +// Meta-annotations like @SpringBootApplication are intentionally not detected by this rule +// to avoid complexity with determining proxyBeanMethods for composed annotations +@SpringBootApplication +final class FinalSpringBootApplication { // Compliant - meta-annotations not detected + + @Bean + public String appBean() { + return "appBean"; + } +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java index fc90555b138..21bad8678c6 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/spring/ConfigurationClassShouldNotBeFinalCheck.java @@ -43,6 +43,8 @@ public List nodesToVisit() { public void visitNode(Tree tree) { ClassTree classTree = (ClassTree) tree; + // Note: This check only detects direct @Configuration annotations, not meta-annotations + // like @SpringBootApplication. This is intentional to avoid complexity with proxyBeanMethods. Optional configurationAnnotation = getConfigurationAnnotation(classTree); if (configurationAnnotation.isEmpty()) { return;