Code Quality Gates for an SDK Built with Maven
This is Part 5 of a series: Maven Patterns from Building an Internal SDK.
|
Why SDK Quality Standards Matter More
A bug in application code affects one service. A bug in SDK code affects every service that uses the SDK. If a utility method has a problem, that problem ships to 30 services. If a default value is wrong, it is wrong everywhere.
Because of this, SDK code needs a higher quality standard than regular application code. The challenge is: how do you enforce quality consistently across all modules without making builds slow or annoying?
The Approach
Configure quality plugins once in the root parent POM. All modules inherit the same rules automatically. There is no need for per-module configuration, and no risk of forgetting to enable a plugin in some module.
We use three plugins:
PMD: Static analysis that detects common bugs
Modernizer: Detects usage of outdated Java APIs
Spring Java Format: Automatic code formatting
All three run during the validate or verify phase. Developers get feedback on every build, not just in CI.
PMD: Static Analysis That Detects Real Bugs
PMD analyzes Java source code and reports common problems: empty catch blocks, unused variables, methods that are too complex, and more. It has been widely used for many years, runs fast, and has good Maven support.
Configuration
Define the plugin in pluginManagement in the root parent:
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.26.0</version>
<configuration>
<failOnViolation>true</failOnViolation>
<printFailingErrors>true</printFailingErrors>
<linkXRef>false</linkXRef>
<targetJdk>21</targetJdk>
<rulesets>
<ruleset>/category/java/bestpractices.xml</ruleset>
<ruleset>/category/java/errorprone.xml</ruleset>
</rulesets>
<excludeRoots>
<excludeRoot>target/generated-sources</excludeRoot>
</excludeRoots>
</configuration>
<executions>
<execution>
<id>pmd-check</id>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>Then activate it in <plugins>:
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
</plugin>
</plugins>What each setting does:
failOnViolation=true: The build fails when PMD finds a violation. This is intentional.printFailingErrors=true: Prints the actual error details in the console, not just a generic "check failed" message.linkXRef=false: Disables HTML cross-reference report generation. This makes the build faster. In a CI pipeline, you do not need these reports.Two rulesets:
bestpracticesanderrorprone. These two categories find real problems and produce very few false positives.
Rules Worth Enabling
The bestpractices and errorprone categories cover the most useful checks:
Empty catch blocks
Unused imports and variables
Missing
@OverrideannotationsViolations of the equals/hashCode contract
Switch statements without a default case
Potential null pointer problems
These findings are clear and specific. When PMD reports them, they are almost always actual mistakes in the code.
Rules to Skip
Some built-in rules create too many warnings without real benefit:
Naming rules (
codestyle.xml): These flag method names likeparseXMLbecause of consecutive capital letters. This kind of warning causes more debate than value.Documentation rules: Requiring Javadoc on every public method adds noise in an internal SDK. Good method names are usually enough.
Design complexity rules with low thresholds: A cyclomatic complexity limit of 10 will flag many reasonable switch statements. If you use these rules, set the threshold higher (for example, 20).
Start with bestpractices and errorprone only. Once the team is comfortable with those, consider adding more categories.
CPD (Copy-Paste Detection)
PMD includes CPD, a tool that finds duplicated code blocks. You can enable it as a separate execution:
<execution>
<id>cpd-check</id>
<goals>
<goal>cpd-check</goal>
</goals>
<configuration>
<minimumTokens>100</minimumTokens>
<failOnViolation>false</failOnViolation>
</configuration>
</execution>The token threshold of 100 is a good starting point. A lower value will flag small similarities (like builder chains or test setup code) that are not real problems. Increase the value if CPD produces too many warnings.
We run CPD in warn-only mode (<failOnViolation>false</failOnViolation>). Some duplication is expected across starters because they follow similar auto-configuration patterns. CPD helps find code that could be extracted into a shared method, but it should not block the build.
Modernizer: Detecting Outdated API Usage
Modernizer finds places where your code uses old Java APIs that have better modern alternatives. For example: new Date() instead of Instant.now(), Hashtable instead of HashMap, or Vector instead of ArrayList.
Configuration
<plugin>
<groupId>org.gaul</groupId>
<artifactId>modernizer-maven-plugin</artifactId>
<version>2.9.0</version>
<configuration>
<javaVersion>21</javaVersion>
<failOnViolations>true</failOnViolations>
<includeTestClasses>false</includeTestClasses>
</configuration>
<executions>
<execution>
<id>modernizer-check</id>
<phase>verify</phase>
<goals>
<goal>modernizer</goal>
</goals>
</execution>
</executions>
</plugin>What each setting does:
javaVersion=21: Reports APIs that have replacements available in Java 21 or earlier versions.includeTestClasses=false: Skips test code. Test classes are often more relaxed about API usage, and flagging them adds noise.failOnViolations=true: The build fails on violations. Outdated APIs should not ship in SDK code.
What It Detects
Modernizer has a built-in list of outdated APIs, organized by the Java version that introduced their replacement. With javaVersion=21, it reports usage like:
com.google.common.base.Optional— usejava.util.Optionalinsteadcom.google.common.io.BaseEncoding— usejava.util.Base64insteadorg.apache.commons.io.Charsets.UTF_8— usejava.nio.charset.StandardCharsets.UTF_8insteadnew StringBuffer()— usenew StringBuilder()insteadOlder collection patterns that can be replaced with the Stream API
Modernizer produces very few false positives. Almost every finding is a genuine improvement.
When to Suppress
Sometimes you must use an older API. For example, a third-party library may require Vector as a parameter type. In these cases, use the @SuppressModernizer annotation on the method:
@SuppressModernizer
public void legacyLibraryCallback(Vector<String> results) {
// Third-party library requires Vector
}Spring Java Format: Automatic Code Formatting
Spring Java Format is the code formatter used by the Spring team. It applies a fixed formatting style with no configuration options. There is nothing to configure and nothing to debate.
Why Automatic Formatting Works Well
Teams spend time discussing formatting preferences: tabs vs spaces, where to place braces, how to wrap long lines. Once you choose a formatter and enforce it in the build, those discussions stop. Code reviews can focus on logic and design instead of whitespace.
Configuration
<plugin>
<groupId>io.spring.javaformat</groupId>
<artifactId>spring-javaformat-maven-plugin</artifactId>
<version>0.0.45</version>
<executions>
<execution>
<id>format-validate</id>
<phase>validate</phase>
<goals>
<goal>validate</goal>
</goals>
</execution>
</executions>
</plugin>The validate goal only checks whether the code is formatted correctly. It does not change any files. Developers must fix formatting locally before pushing.
To apply formatting automatically:
mvn spring-javaformat:applyIDE Integration
Spring Java Format has plugins for IntelliJ and Eclipse. After installing the plugin, the IDE formats code on save using the same rules as the Maven plugin. This way, developers do not need to think about formatting at all.
If a developer does not have the IDE plugin, they can run mvn spring-javaformat:apply before committing. In both cases, CI will catch any formatting violations because the validate goal fails the build.
Generated Code
You should exclude generated source files. Running a formatter on generated code is unnecessary and can break code generators that produce specific output:
<configuration>
<excludes>
<exclude>**/generated/**</exclude>
</excludes>
</configuration>Putting It Together
Here is a complete quality configuration for the root parent POM with all three plugins:
<build>
<pluginManagement>
<plugins>
<!-- Formatting -->
<plugin>
<groupId>io.spring.javaformat</groupId>
<artifactId>spring-javaformat-maven-plugin</artifactId>
<version>0.0.45</version>
<executions>
<execution>
<id>format-validate</id>
<phase>validate</phase>
<goals>
<goal>validate</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Static analysis -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.26.0</version>
<configuration>
<failOnViolation>true</failOnViolation>
<printFailingErrors>true</printFailingErrors>
<linkXRef>false</linkXRef>
<targetJdk>21</targetJdk>
<rulesets>
<ruleset>/category/java/bestpractices.xml</ruleset>
<ruleset>/category/java/errorprone.xml</ruleset>
</rulesets>
<excludeRoots>
<excludeRoot>target/generated-sources</excludeRoot>
</excludeRoots>
</configuration>
<executions>
<execution>
<id>pmd-check</id>
<goals>
<goal>check</goal>
</goals>
</execution>
<execution>
<id>cpd-check</id>
<goals>
<goal>cpd-check</goal>
</goals>
<configuration>
<minimumTokens>100</minimumTokens>
<failOnViolation>false</failOnViolation>
</configuration>
</execution>
</executions>
</plugin>
<!-- Legacy API detection -->
<plugin>
<groupId>org.gaul</groupId>
<artifactId>modernizer-maven-plugin</artifactId>
<version>2.9.0</version>
<configuration>
<javaVersion>21</javaVersion>
<failOnViolations>true</failOnViolations>
<includeTestClasses>false</includeTestClasses>
</configuration>
<executions>
<execution>
<id>modernizer-check</id>
<phase>verify</phase>
<goals>
<goal>modernizer</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>io.spring.javaformat</groupId>
<artifactId>spring-javaformat-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.gaul</groupId>
<artifactId>modernizer-maven-plugin</artifactId>
</plugin>
</plugins>
</build>Execution Order
Maven runs plugins in phase order:
validate: Spring Java Format checks formattingverify(viacheckgoal): PMD runs static analysis and CPD checks for duplicationverify: Modernizer detects outdated API usage
Formatting is checked first. If the code is not properly formatted, there is no reason to run the other analysis tools on it.
Consumer Inheritance
If you configure these plugins in the root parent and consumers use your sdk-starter-parent (Part 3), they inherit all quality gates automatically. Every consumer project gets the same rules without any additional setup.
This is a strong approach, but it is also opinionated. Some teams may not be ready for it. There are two options:
Full inheritance: Consumers get all quality plugins automatically. This is the strictest approach.
Opt-in via profiles: Wrap the quality plugins in a Maven profile. Consumers activate the profile when they are ready.
We chose full inheritance. The initial resistance from teams was worth the long-term consistency it provided.
Trade-offs and Tuning
If Rules Are Too Strict, Developers Will Skip Them
If developers regularly run builds with -Dpmd.skip=true -Dspring-javaformat.skip=true, your rules are probably too aggressive. Pay attention to repeated complaints. The goal is to catch real problems, not to enforce rules that no one agreed to.
When to Fail vs Warn
| Plugin | Recommendation |
|---|---|
Spring Java Format | Fail (auto-fix is available) |
PMD (bestpractices, errorprone) | Fail |
PMD CPD | Warn |
Modernizer | Fail |
Build Performance
These plugins add some time to each build. On a 30-module SDK, the approximate overhead is:
Spring Java Format: ~5 seconds
PMD: ~10-15 seconds
Modernizer: ~3-5 seconds
The total is under 30 seconds. For an SDK build that already takes several minutes, this is a small cost.
If build speed is important during local development, you can create a profile that skips quality checks. Make sure CI always runs the full build with all checks enabled.
<profile>
<id>fast</id>
<properties>
<pmd.skip>true</pmd.skip>
<modernizer.skip>true</modernizer.skip>
<spring-javaformat.skip>true</spring-javaformat.skip>
</properties>
</profile>Beyond the Baseline: Other Tools Worth Knowing
The three plugins described in this post are our minimum quality gate. They are a starting point, not a limit. The Java ecosystem has many static analysis and compile-time checking tools that work well with Maven. All of them are configurable: you can choose which rules to enable, set severity levels, and decide whether violations should fail the build or only produce warnings.
Here are some of the most widely used tools to consider as your SDK grows:
SpotBugs (the successor to FindBugs): Analyzes compiled
.classfiles (bytecode) and finds bugs that source-level tools like PMD cannot detect. These include null pointer dereferences, infinite recursive loops, resource leaks, and concurrency problems. Thecom.github.spotbugs:spotbugs-maven-pluginintegrates directly into the Maven build. You can also add thecom.h3xstream.findsecbugs:findsecbugs-pluginfor security-related checks like SQL injection, XXE, and insecure deserialization.Checkstyle: Enforces coding conventions such as naming rules, whitespace, Javadoc requirements, and import ordering. It is more granular than Spring Java Format. If you need rules that go beyond formatting (for example, requiring Javadoc on public APIs, limiting line length, or enforcing specific annotation patterns), Checkstyle is a good choice. The
maven-checkstyle-pluginsupports custom rule files and includes Google and Sun presets.Error Prone: A compiler plugin from Google that detects bugs during compilation, not as a separate analysis step. It catches problems like wrong argument order in
assertEquals, missing break statements in switch cases, and incorrect@Overrideusage. Because it runs as part ofjavac, it adds very little build time. It is configured inmaven-compiler-pluginby adding it to<annotationProcessorPaths>and passing-Xplugin:ErrorPronevia<compilerArgs>.NullAway: A plugin for Error Prone that focuses specifically on null safety. It enforces
@Nullableand@NonNullannotations at compile time. It is stricter than PMD’s null checks but easier to adopt than the full Checker Framework.Checker Framework: Provides the most thorough type-level guarantees, covering nullness, taint tracking, lock ordering, and regex validity. It is powerful but requires adding annotations throughout your codebase. This is best suited for libraries and SDKs where strong correctness guarantees are worth the effort.
OWASP Dependency-Check: Scans your dependency tree against the National Vulnerability Database (NVD) for known security vulnerabilities (CVEs). It is not a code analysis tool, but it fits well into a quality gate pipeline. The
dependency-check-mavenplugin can fail the build when it finds dependencies with vulnerabilities above a configurable severity threshold.
Each of these tools is available as a Maven plugin. They all support pluginManagement for centralized configuration, custom rule sets for tuning, and skip properties for developer convenience.
We started with just three plugins on purpose. PMD, Modernizer, and Spring Java Format gave us the most value with the least effort to set up and maintain. SpotBugs and OWASP Dependency-Check are the most likely tools we will add next. The principle is simple: add a tool when it solves a problem you have actually experienced, not just because it exists.
Conclusion
Quality gates work best when they are consistent, automatic, and focused on real problems.
The configuration that works well for us:
Spring Java Format: Removes all formatting discussions. Auto-fix makes it easy to comply.
PMD with
bestpracticesanderrorprone: Finds actual bugs with very few false warnings.Modernizer: Prevents outdated API usage from entering the codebase. Almost no false positives.
Configure these once in the root parent POM. All modules inherit the same rules. CI enforces them on every build.
With this setup, the SDK has a consistent quality standard across every module. Code reviews focus on logic and design instead of formatting and obvious mistakes. That is the value you get from 30 seconds of additional build time.
This is the final post in the series. Across five posts, we covered the full lifecycle of an SDK built with Maven: versioning, dependency management, starters, governance, and quality gates. Each pattern works on its own, but together they form a solid foundation that scales from a few modules to dozens of consumers.