Code Quality Tools

SonarQube, Checkstyle, PMD, SpotBugs, and Error Prone in practice โ€” what pattern-based analysis can never catch, and how to adopt any of these on a five-year-old codebase without blocking every PR on day one

← Back to Index

Why Code Quality Tools Matter โ€” and the One Thing They Can't Do

Static analysis finds a specific, well-understood class of problem: patterns known in advance to be dangerous โ€” an unclosed stream, a redundant null check, a switch with no default case. These are exactly the mistakes a human reviewer misses because they're boring and repetitive, and exactly what a machine is good at catching before a single reviewer even opens the pull request.

// BEFORE โ€” ships, passes code review, passes unit tests that never
// exercise the failure path
public void exportInvoice(Order order) throws IOException {
    FileOutputStream fos = new FileOutputStream("invoice.pdf");
    fos.write(order.toPdfBytes());
    // Never closed. Leaks a file handle on every single call. A reviewer
    // skimming a 40-line diff misses this constantly โ€” it looks fine.
}

// AFTER โ€” SpotBugs flags OBL_UNSATISFIED_OBLIGATION at build time,
// before a human reviewer is even involved
public void exportInvoice(Order order) throws IOException {
    try (FileOutputStream fos = new FileOutputStream("invoice.pdf")) {
        fos.write(order.toPdfBytes());
    }
}
What none of these tools will ever catch: a correct-looking, wrong business rule

Every tool in this topic works by recognizing known dangerous patterns โ€” syntax and bytecode shapes that are almost always bugs, regardless of what the code is for. None of them understand what your discount calculation, your inventory check, or your payment retry logic is actually supposed to do. A method that applies a 5% discount when the business rule says 10% will sail through SonarQube, Checkstyle, PMD, SpotBugs, and Error Prone with zero findings โ€” it's syntactically pristine and semantically wrong, and no pattern-matcher can tell the difference. That gap is exactly what real, meaningful tests close โ€” see Testing for why coverage alone doesn't close it either, and what mutation testing adds on top.

Static Analysis Tools Overview

ToolPurposeOperates on
SonarQube/SonarCloudAggregated quality platform, tracked over timeSource + coverage reports
CheckstyleStyle and naming convention enforcementSource (AST)
PMDBug patterns, dead code, best practicesSource (AST)
SpotBugsBug detection via bytecode analysisCompiled .class files
Error ProneCompile-time bug detection, fails the build immediatelyCompiler AST, during javac
SpotlessFormatting enforcement โ€” the only one of these that auto-fixesSource (raw text/AST)

SonarQube

SonarQube aggregates bugs, vulnerabilities, code smells, and coverage into tracked metrics over time โ€” its distinguishing value over the other tools here is the history and the dashboard, not a fundamentally different detection engine.

Maven Configuration

<properties>
    <sonar.projectKey>order-service</sonar.projectKey>
    <sonar.organization>shop</sonar.organization>
    <sonar.host.url>https://sonarcloud.io</sonar.host.url>
</properties>

mvn sonar:sonar -Dsonar.token=YOUR_TOKEN

GitHub Actions Integration

name: SonarCloud Analysis
on: [push, pull_request]

permissions:
  contents: read

jobs:
  sonar:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: 0 }
    - uses: actions/setup-java@v4
      with: { java-version: '21', distribution: 'temurin', cache: maven }
    - name: Build and analyze
      env:
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
      run: mvn -B verify sonar:sonar

Quality Gate โ€” and how it stays usable on a legacy codebase

// Default "Sonar way" gate โ€” applied only to NEW code by default,
// not the entire existing codebase
New Code Coverage:          > 80%
New Duplicated Lines:       < 3%
New Maintainability Rating: A
New Security Hotspots:      100% reviewed
"New Code" is the built-in answer to "we can't fix 40,000 existing issues overnight"

SonarQube's Quality Gate targets the leak period โ€” code changed since a defined baseline โ€” by default, not the whole repository's history. This is deliberate: it lets a five-year-old codebase adopt strict gates immediately for anything touched going forward, without requiring every existing violation to be fixed before the gate can be turned on at all.

Checkstyle

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-checkstyle-plugin</artifactId>
    <version>3.5.0</version>
    <configuration>
        <configLocation>google_checks.xml</configLocation>
        <failsOnError>true</failsOnError>
    </configuration>
    <executions>
        <execution><id>validate</id><phase>validate</phase><goals><goal>check</goal></goals></execution>
    </executions>
</plugin>

Custom Configuration

<module name="Checker">
    <module name="TreeWalker">
        <module name="AvoidStarImport"/>
        <module name="UnusedImports"/>
        <module name="LineLength"><property name="max" value="120"/></module>
        <module name="MethodLength"><property name="max" value="50"/></module>
        <module name="EqualsHashCode"/>
        <module name="MissingSwitchDefault"/>
    </module>
</module>
// VIOLATION โ€” star import
import java.util.*;

// CORRECT
import java.util.List;

// VIOLATION โ€” constant naming
public static final int maxItems = 100;   // should be MAX_ITEMS

PMD

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-pmd-plugin</artifactId>
    <version>3.26.0</version>
    <configuration>
        <rulesets>
            <ruleset>/category/java/bestpractices.xml</ruleset>
            <ruleset>/category/java/errorprone.xml</ruleset>
        </rulesets>
        <failOnViolation>true</failOnViolation>
    </configuration>
    <executions>
        <execution><goals><goal>check</goal></goals></execution>
    </executions>
</plugin>
// EmptyCatchBlock โ€” the most valuable PMD rule for production incidents
try {
    paymentGateway.charge(order);
} catch (Exception e) {
    // PMD: empty catch block โ€” this silently swallows a payment failure
}

// UseCollectionIsEmpty
if (order.getItems().size() == 0) { }   // flagged
if (order.getItems().isEmpty()) { }        // correct

SpotBugs

SpotBugs analyzes compiled bytecode, not source โ€” it catches a different, complementary category of bug from PMD and Checkstyle, including some the compiler itself has already optimized away in ways source-level tools can't see.

<plugin>
    <groupId>com.github.spotbugs</groupId>
    <artifactId>spotbugs-maven-plugin</artifactId>
    <version>4.8.6.6</version>
    <configuration>
        <effort>Max</effort>
        <threshold>Low</threshold>
        <plugins>
            <!-- adds security-focused bug patterns -->
            <plugin>
                <groupId>com.h3xstream.findsecbugs</groupId>
                <artifactId>findsecbugs-plugin</artifactId>
                <version>1.13.0</version>
            </plugin>
        </plugins>
    </configuration>
    <executions>
        <execution><goals><goal>check</goal></goals></execution>
    </executions>
</plugin>
// NP_NULL_ON_SOME_PATH โ€” possible null pointer dereference
public void notify(Customer customer) {
    String email = customer.getEmail();   // customer could be null
    email.toLowerCase();
}

// OBL_UNSATISFIED_OBLIGATION โ€” resource leak (Section 0's example)

// EQ_COMPARETO_USE_OBJECT_EQUALS โ€” compareTo defined but equals() isn't
// consistent with it, breaking any TreeSet/TreeMap usage silently

Error Prone

Error Prone runs as a compiler plugin โ€” its findings fail the javac step itself, which is the earliest possible point in the entire pipeline to stop a known bug pattern.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
        <release>21</release>
        <compilerArgs>
            <arg>-Xplugin:ErrorProne</arg>
        </compilerArgs>
        <annotationProcessorPaths>
            <path>
                <groupId>com.google.errorprone</groupId>
                <artifactId>error_prone_core</artifactId>
                <version>2.31.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
// StringSplitter โ€” split() takes a REGEX, this is a classic silent trap
"1.2.3".split(".");    // "." matches ANY character โ€” returns an empty array
"1.2.3".split("\\.");  // correct โ€” literal dot

// CollectionIncompatibleType โ€” caught at COMPILE time, not runtime
List<String> skus = new ArrayList<>();
skus.contains(123);   // always false โ€” comparing a List<String> against an Integer

Adopting a Tool on a Legacy Codebase Without Blocking Every PR

Turning on Checkstyle or PMD for the first time on a five-year-old codebase typically surfaces thousands of pre-existing violations โ€” if the build fails on all of them immediately, the tool gets disabled by the end of the week out of sheer necessity. The fix is a baseline: freeze the current violations as accepted, and fail the build only on new ones.

// checkstyle-suppressions.xml โ€” accept existing debt in specific files,
// enforce the rule everywhere else, including new files
<suppressions>
    <suppress checks="MagicNumber" files="LegacyPricingEngine\.java"/>
</suppressions>
# PMD โ€” exclude legacy packages entirely rather than suppress line-by-line
# for a first rollout, then shrink this list over time
<excludes>
    <exclude>**/legacy/**/*.java</exclude>
</excludes>

SonarQube's New Code Quality Gate (Section 2) achieves the same outcome without a maintained suppression file at all โ€” it's generally the least effort path if you're already running SonarQube, since "new code only" is the default behavior, not something you have to configure per rule.

Combining Tools โ€” Give Each One a Clear Job

Running all of these at once without a clear division of labor produces conflicting configuration โ€” Checkstyle and Spotless can easily disagree about brace placement or import order if nobody decided which one owns formatting.

ToolOwns
SpotlessFormatting โ€” the only one that auto-fixes; let it own whitespace, import order, brace style entirely
CheckstyleNaming conventions and structural style rules that aren't auto-fixable
PMDCode smells, dead code, best-practice patterns at the source level
SpotBugsBytecode-level bug patterns โ€” resource leaks, null-path analysis
SonarQubeAggregating all of the above, tracking trends, and the security hotspot workflow
# Run everything in one CI step, in an order that fails fast on the
# cheapest checks first
mvn spotless:check checkstyle:check pmd:check spotbugs:check verify

IDE Integration

// IntelliJ IDEA โ€” SonarLint plugin gives real-time analysis as you type,
// synchronized against the same rules as CI, so nothing surprises you
// only after opening a PR. See "IDEs" for more on IDE-integrated tooling.

See IDEs for how static analysis integrates with each IDE's underlying semantic engine.

Code Formatting โ€” Spotless

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.44.0</version>
    <configuration>
        <java>
            <googleJavaFormat><version>1.23.0</version></googleJavaFormat>
            <removeUnusedImports/>
        </java>
    </configuration>
    <executions>
        <execution><goals><goal>check</goal></goals></execution>
    </executions>
</plugin>

# mvn spotless:check โ€” CI enforcement
# mvn spotless:apply โ€” auto-fix locally, the one command a developer
# actually needs to remember

Suppressing False Positives โ€” With Justification, Not Silently

@SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH",
    justification = "customer is validated non-null by the @Valid annotation upstream")
public void notify(Customer customer) { }

@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public void seedTestData() { }
A suppression without a justification is just deferred debt

Requiring a written reason at the suppression site forces the person suppressing it to actually confirm the finding is a false positive rather than reflexively silencing an inconvenient warning โ€” and gives the next reader (including future you) the reasoning without having to reconstruct it.

Best Practices and Common Pitfalls

โœ… Do

  • Give each tool a distinct, non-overlapping job (Section 8) instead of running all of them with default configs and fighting conflicts
  • Adopt new tools on a legacy codebase via a baseline or "new code only" gate โ€” never a build that instantly fails on thousands of pre-existing issues
  • Require a written justification on every suppression, at the suppression site
  • Run the cheapest, fastest checks first in CI (formatting, then style, then bytecode analysis) so a trivial mistake fails in seconds, not minutes
  • Remember these tools catch known patterns, not business correctness โ€” pair them with the meaningful tests covered in Testing

โŒ Don't

  • Don't treat a clean SonarQube/PMD/SpotBugs report as proof the code is correct โ€” it only means no known pattern matched
  • Don't let Checkstyle and Spotless both try to own formatting โ€” pick one, usually Spotless, since it's the only one that auto-fixes
  • Don't suppress a finding without writing down why โ€” an unjustified suppression is indistinguishable from someone silencing a real bug
  • Don't enable a strict gate against full-repository history on day one of adopting a new tool โ€” start with new code only

Interview Questions

๐ŸŽ“ Junior level

Q: What's the difference between Checkstyle and SpotBugs?
Checkstyle analyzes source code and enforces style and naming conventions โ€” things like import order, line length, and naming patterns. SpotBugs analyzes compiled bytecode looking for actual bug patterns โ€” null pointer risks, resource leaks, inconsistent equals/compareTo implementations. They operate on different representations of the code and catch different categories of problem.

Q: Why does Error Prone run as a compiler plugin instead of a separate build step?
Running at compile time means a caught issue fails the build at the earliest possible point โ€” before tests run, before packaging, before any other stage. It also has direct access to the compiler's own type information, letting it catch things like a fundamentally incompatible type argument to a generic collection method that a purely textual tool couldn't verify.

Q: Why require a justification when suppressing a static analysis warning?
Without one, there's no way to distinguish "I checked this and it's genuinely a false positive" from "this warning was annoying so I made it go away." A written justification forces the actual verification to happen and documents it for anyone reading the code later.

๐Ÿ”ฅ Senior level

Q: A pricing method passes SonarQube, PMD, SpotBugs, and Error Prone with zero findings, yet applies the wrong discount percentage in production. Was any of these tools "wrong," and what category of tool would have caught this?
None of them were wrong โ€” they did exactly what they're designed to do. Every tool covered in this topic works by matching source or bytecode against a catalog of patterns known in advance to be problematic regardless of business context: an unclosed resource, a redundant null check, an inconsistent equals/hashCode pair. None of them have any model of what the correct discount percentage should be โ€” that's domain knowledge encoded only in the specification and in tests that assert the actual expected value. A syntactically clean method with the wrong constant is invisible to pattern-based analysis by construction. The tool category that would have caught it is a test asserting the specific expected output for a specific input โ€” and mutation testing (see Testing) is what would have caught the deeper problem of a test suite that didn't actually verify that value in the first place.

Q: Your team enables Checkstyle on a legacy codebase and the very first CI run fails with 12,000 violations. What went wrong in the rollout, and how should it have been done?
The rollout applied a strict rule set against the entire repository's history in one step, which guarantees the build is broken for everyone immediately and creates strong pressure to simply disable the tool rather than address 12,000 pre-existing issues before anyone can merge anything. The correct rollout separates "debt that already exists" from "debt we're introducing right now": either a suppression file that explicitly accepts the current violations in their existing files (Section 7) while still enforcing the rule on any new or modified file, or โ€” if using SonarQube โ€” relying on its New Code Quality Gate, which is scoped to a defined leak period by default rather than full history. Either approach lets the team start benefiting from the tool on all new work immediately, while planning a separate, deliberate effort to shrink the legacy exclusion list over time, instead of forcing an all-or-nothing choice on day one.

Q: Why can Checkstyle and Spotless conflict with each other if both are configured to manage code formatting, and how should a team resolve that?
Checkstyle enforces rules but does not rewrite code โ€” it fails the build and expects a human (or an IDE action) to fix the violation. Spotless is designed to auto-apply a canonical format via spotless:apply. If both tools are configured with even slightly different opinions about import ordering or brace placement, a developer can run spotless:apply, have Spotless "fix" the file according to its own rules, and then have Checkstyle immediately fail the same file for violating a different rule about the same concern โ€” an unresolvable loop from the developer's perspective. The fix is assigning exactly one tool ownership of formatting-related concerns โ€” conventionally Spotless, since it's the only one of the two capable of auto-fixing โ€” and configuring Checkstyle's rule set to exclude anything Spotless already owns, reserving Checkstyle for naming and structural conventions that have no automatic fix at all.