Using Maven Enforcer to Keep SDK Consumers Sane

Why SDKs Need Governance

You’ve built a well-structured SDK. Versioning is clean (Part 1). The BOM manages dependencies (Part 2). Starters wire things up automatically (Part 3).

Then a consumer adds a transitive dependency that pulls in Gson. Your SDK uses Jackson. Now there are two JSON libraries, subtly different serialization behaviors, and a production bug that takes three days to diagnose.

This happens because Maven is permissive by default. It resolves conflicts silently. It doesn’t care if consumers mix incompatible libraries.

The Enforcer plugin changes that. It fails builds when rules are violated—before code runs, before tests pass, before anyone deploys anything.

Beyond dependencies, Maven also doesn’t enforce build environment consistency. Without explicit rules, there’s nothing stopping Developer A from building with JDK 21 while Developer B uses JDK 17.

The "Works on My Machine" Problem

Without enforcement:

  • Developer A uses Java 21, Developer B uses Java 17

  • Service X has Jackson 2.15, Service Y has Jackson 2.17

  • One team uses commons-lang, another uses commons-lang3

Everything compiles. Tests pass locally. Production breaks in ways that take hours to reproduce.

Transitive Dependency Chaos

A consumer adds a third-party library. That library depends on Gson. Your SDK’s Jackson-based serialization now shares the classpath with Gson. Nothing fails at compile time. The bug surfaces at runtime, in production, on a Friday.

The Enforcer plugin makes this a build failure. The consumer sees the error immediately, excludes the transitive or chooses a different library, and the problem never reaches production.

Enforcer only runs in projects where it’s configured. Consumers get these rules automatically when they inherit your starter-parent (Part 3). If consumers only import your BOM without using the parent POM, they won’t inherit the Enforcer configuration—they’d need to add it themselves.

The Enforcer Plugin Basics

Configuration Structure

The Enforcer plugin runs rules during the build lifecycle. Configure it in two parts: pluginManagement for defaults, and plugins to activate it:

<build>
    <!-- Define configuration defaults -->
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
                <version>3.5.0</version>
                <executions>
                    <execution>
                        <id>enforce</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <rules>
                        <!-- Rules go here -->
                    </rules>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>

    <!-- Activate the plugin -->
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-enforcer-plugin</artifactId>
        </plugin>
    </plugins>
</build>
pluginManagement alone doesn’t execute plugins—it only sets defaults. You must also declare the plugin in <plugins> for it to run. Child modules inherit both, so the plugin runs automatically in all modules.

Where It Runs

By default, Enforcer runs during the validate phase—before compilation. This means fast feedback: if something violates a rule, the build fails immediately.

Fail vs Warn

The <fail> setting is per-execution, not per-rule. To have some rules fail and others warn, use separate executions:

<execution>
    <id>enforce-convergence</id>
    <configuration>
        <rules>
            <dependencyConvergence/>
        </rules>
        <fail>false</fail>  <!-- Warn only for all rules in this execution -->
    </configuration>
    <goals>
        <goal>enforce</goal>
    </goals>
</execution>

Use <fail>true</fail> for hard requirements. Use <fail>false</fail> during migration periods or for rules you’re evaluating. Group rules by fail/warn behavior into separate executions.

Rules That Matter

requireJavaVersion and requireMavenVersion

Pin the build environment. No more "it works on my machine":

<rules>
    <requireMavenVersion>
        <message>This SDK requires Maven 3.9.10 or later.</message>
        <version>[3.9.10,)</version>
    </requireMavenVersion>
    <requireJavaVersion>
        <message>This SDK requires JDK 21 or later.</message>
        <version>[21,)</version>
    </requireJavaVersion>
</rules>

The version range [3.9.10,) means "3.9.10 or higher". Similar for Java, it means "JDK 21 or higher". The custom messages explain the requirement and guide developers on how to fix it.

Why this matters: Maven 3.9.x improved dependency resolution. JDK 21 has virtual threads. If your SDK depends on these, enforce them.

bannedDependencies

The most useful rule. Explicitly ban libraries that shouldn’t appear in consumer builds:

<execution>
    <id>enforce-banned-dependencies</id>
    <goals>
        <goal>enforce</goal>
    </goals>
    <configuration>
        <rules>
            <bannedDependencies>
                <excludes>
                    <!-- Alternative JSON libraries -->
                    <exclude>com.google.code.gson:gson</exclude>
                    <exclude>com.alibaba:fastjson</exclude>
                    <exclude>com.alibaba.fastjson2:fastjson2</exclude>
                    <exclude>org.codehaus.jackson:*</exclude>
                    <exclude>net.sf.json-lib:json-lib</exclude>

                    <!-- Legacy Commons -->
                    <exclude>commons-lang:commons-lang</exclude>
                    <exclude>commons-httpclient:commons-httpclient</exclude>

                    <!-- Old JUnit -->
                    <exclude>junit:junit</exclude>
                </excludes>
                <message>Banned dependency detected. This SDK standardizes on Jackson for JSON and JUnit 5 for testing.</message>
            </bannedDependencies>
        </rules>
        <fail>true</fail>
    </configuration>
</execution>

Patterns work too:

  • org.codehaus.jackson:* bans all artifacts in that group

  • com.google.code.gson:gson bans a specific artifact

  • com.google.code.gson:gson:2.8.9 bans a specific version

dependencyConvergence

Requires that all versions of the same dependency resolve to the same version:

<rules>
    <dependencyConvergence/>
</rules>

This is the strictest rule. If library A depends on guava:31.0 and library B depends on guava:32.0, the build fails.

We run this with <fail>false</fail> initially. The output shows every convergence issue. Once you’ve resolved the critical ones (usually through BOM overrides), flip to <fail>true</fail>.

Enabling dependencyConvergence with <fail>true</fail> on an existing project will likely break the build. Start with warnings, fix incrementally.

requireUpperBoundDeps (Alternative)

If full convergence is too strict, requireUpperBoundDeps is a practical middle ground:

<rules>
    <requireUpperBoundDeps/>
</rules>

This rule fails when the resolved version is lower than the highest version requested by any dependency. In other words: if library A wants guava:32.0 and library B wants guava:31.0, but Maven’s "nearest wins" strategy resolves to 31.0, the build fails. It ensures you always get at least the highest requested version—preventing subtle runtime issues from version downgrades.

requireProperty

Enforce that certain properties are set:

<rules>
    <requireProperty>
        <property>project.build.sourceEncoding</property>
        <message>Source encoding must be set (use UTF-8).</message>
        <regex>UTF-8</regex>
    </requireProperty>
</rules>

We use this for encoding properties. Every project should explicitly set UTF-8—implicit defaults vary by platform.

Banned Dependencies: An Opinionated List

After running an SDK across multiple teams, here’s what we ban and why.

JSON Libraries (Pick One)

If your SDK standardizes on Jackson:

<exclude>com.google.code.gson:gson</exclude>
<exclude>com.alibaba:fastjson</exclude>
<exclude>com.alibaba.fastjson2:fastjson2</exclude>
<exclude>org.codehaus.jackson:*</exclude>
<exclude>net.sf.json-lib:json-lib</exclude>
<exclude>jakarta.json:jakarta.json-api</exclude>
<exclude>org.eclipse:yasson</exclude>

Rationale:

  • Consistent serialization behavior across all services

  • One library to patch when CVEs hit

  • No classpath conflicts between JSON parsers

Legacy Apache Commons

<exclude>commons-lang:commons-lang</exclude>          <!-- Use commons-lang3 -->
<exclude>commons-httpclient:commons-httpclient</exclude>  <!-- Use httpclient5 -->
<exclude>commons-logging:commons-logging</exclude>        <!-- Use SLF4J -->

These have modern replacements. The old versions are maintenance-only and occasionally conflict with their successors.

Old Testing Libraries

<exclude>junit:junit</exclude>           <!-- Use JUnit 5 -->
<exclude>org.mockito:mockito-all</exclude>  <!-- Use mockito-core -->

JUnit 4 and JUnit 5 can coexist, but it’s confusing. Pick one.

Trade-offs and Pushback

"But I Need Gson for This Library"

Legitimate exceptions exist. A third-party SDK may require a banned library. Prefer a narrow allowlist exception instead of skipping Enforcer:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <executions>
        <execution>
            <id>enforce-banned-dependencies</id>
            <goals>
                <goal>enforce</goal>
            </goals>
            <configuration>
                <rules>
                    <bannedDependencies>
                        <excludes>
                            <exclude>com.google.code.gson:gson</exclude>
                        </excludes>
                        <includes>
                            <!-- Allow only this exact exception -->
                            <include>com.google.code.gson:gson:2.10.1</include>
                        </includes>
                    </bannedDependencies>
                </rules>
                <fail>true</fail>
            </configuration>
        </execution>
    </executions>
</plugin>

This keeps the global ban in place while allowing one explicit artifact version.

If multiple modules legitimately need the same library long-term, remove it from the global ban list and document the decision.

Document why any exception exists. Future maintainers need context.

When to Use Warnings vs Failures

RuleRecommendation

requireJavaVersion

Fail

requireMavenVersion

Fail

bannedDependencies

Fail

requireUpperBoundDeps

Fail (preferred for most SDKs)

dependencyConvergence

Warn initially, then fail (stricter alternative)

requireProperty

Fail

Start strict on things that cause runtime problems (wrong Java version, banned libraries). Be lenient on things that cause build noise while you’re cleaning up (convergence issues).

Communicating Bans to Consumers

The error message matters:

<bannedDependencies>
    <message>
        Banned dependency detected.

        This SDK standardizes on Jackson for JSON processing.
        If you need JSON support, use:
          - com.fasterxml.jackson.core:jackson-databind

        For questions, contact the platform team.
    </message>
    <excludes>
        <exclude>com.google.code.gson:gson</exclude>
    </excludes>
</bannedDependencies>

A developer hitting this error should immediately know what to do. "Banned" isn’t helpful. "Use Jackson instead" with a clear alternative is.

Putting It Together

A complete Enforcer configuration in your parent POM:

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
                <version>3.5.0</version>
                <executions>
                    <execution>
                        <id>enforce-versions</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                        <configuration>
                            <rules>
                                <requireMavenVersion>
                                    <version>[3.9.10,)</version>
                                </requireMavenVersion>
                                <requireJavaVersion>
                                    <version>[21,)</version>
                                </requireJavaVersion>
                            </rules>
                        </configuration>
                    </execution>
                    <execution>
                        <id>enforce-dependency-rules</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                        <configuration>
                            <rules>
                                <requireUpperBoundDeps/>
                                <bannedDependencies>
                                    <excludes>
                                        <exclude>com.google.code.gson:gson</exclude>
                                        <exclude>com.alibaba:fastjson</exclude>
                                        <exclude>commons-lang:commons-lang</exclude>
                                        <exclude>junit:junit</exclude>
                                    </excludes>
                                </bannedDependencies>
                            </rules>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </pluginManagement>

    <!-- Activate the plugin -->
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-enforcer-plugin</artifactId>
        </plugin>
    </plugins>
</build>

Separate executions let you control fail/warn independently per rule category. This example uses requireUpperBoundDeps instead of full dependencyConvergence—a practical choice for most SDKs.

Conclusion

The Enforcer plugin is friction by design. Every failed build is a problem caught early—before it becomes a runtime bug, a security incident, or a three-day debugging session.

The rules that matter most:

  • requireJavaVersion / requireMavenVersion: Pin the build environment

  • bannedDependencies: Prevent library proliferation

  • requireUpperBoundDeps / dependencyConvergence: Surface version conflicts

Start strict on environment and bans. Start lenient on convergence, tighten over time.

Clear error messages turn "build failed" into "build failed, here’s what to do." Invest in those messages.

Enforcer rules catch incompatible builds before consumers ship them.