Maven’s transitive dependency hell and how we solved it

Tomer Aberbach

Software Engineer

Jump to section

Jump to section

Jump to section

At Stainless, we generate libraries for several languages and always learn esoteric things along the way. This is a story about surprising Java dependency manager behavior, a bug that impacted our users, why a good fix was not immediately obvious, and how we devised a solution that doesn’t compromise the developer experience.

Early on, we decided to generate Java SDKs that depend on Jackson, Java’s de facto standard JSON serialization and deserialization library. We thought depending on Jackson, MvnRepository’s 9th most popular library, would be harmless. We were wrong.

A seemingly harmless upgrade

Users should get quick feedback when omitting a required request field, so we decided to add required field checks to our builder classes. This alone would have been problematic because the builders were also used for deserializing responses.¹

To bypass the checks, we migrated Jackson to deserialize our classes through their constructors by moving its annotations from builder methods to class constructor parameters. One annotation, @JsonAnySetter, was used to put unknown properties in an additionalProperties map.

The thing is, our Jackson version was 2.14.3, but @JsonAnySetter only works on constructor parameters starting with Jackson 2.18.1. We figured a minor version bump was no big deal, but by upgrading we introduced a latent bug!

Broken forwards compatibility in the OpenAI Java SDK

In March, 2025, OpenAI updated its Chat Completions API to include an annotations response field for its new web search tool. The field was set to an empty array for requests that didn’t specify the new tool. OpenAI also updated its OpenAPI spec to include the new field and regenerated their Java SDK through Stainless.

However, within hours of the API update, SDK users began filing GitHub issues about JSON deserialization errors. The errors pointed to incorrect use of @JsonAnySetter, and the unknown annotations field was being rejected by older SDKs. We had tested our @JsonAnySetter usage, so how did this happen?

We determined that these users inadvertently depended on a Jackson version older than 2.18.1. Older versions couldn’t handle @JsonAnySetter annotations on constructor parameters, so they failed to deserialize JSON when encountering an unknown field.

But how was this possible? Didn’t our SDKs require at least Jackson 2.18.1? It depends who you ask…

Gradle vs Maven

Java’s two most popular dependency managers, Gradle and Maven, disagree on how to resolve version conflicts.

Consider an application that depends on two different Jackson versions, once directly and once transitively through a Stainless SDK:

my-java-app
|    // Stainless SDK
+--- com.acme:acme-java:1.0.0
|    \\--- com.acme:acme-java-core:1.0.0
|         |    // Transitive dependency
|         \\--- com.fasterxml.jackson.core:jackson-databind:2.18.1
|    // Direct dependency
\\--- com.fasterxml.jackson.core:jackson-databind:2.15.0

This is fine for languages like JavaScript, but not Java. The JVM cannot load two classes with the same fully qualified name.² For example, there can only be one version of com.fasterxml.jackson.databind.json.JsonMapper at runtime.

So dependency managers have a choice to make. Which Jackson version will be used at runtime?

And the users who had errors with the OpenAI Java SDK? They were all using Maven. That explained the issue!

Wait, so Java library authors can’t declare supported dependency versions?

Unlike Gradle, or most dependency managers (e.g. npm, pip, go, RubyGems, NuGet, etc.) for that matter, Maven makes it impossible for a library author to guarantee compatible versions of the library’s dependencies are used at runtime.

This is particularly problematic for Jackson (through no fault of its developers) because it is widely used. A library that depends on Jackson will have users that depend on Jackson directly and inadvertently override the library’s desired version.

A user could work around the issue by directly depending on a more recent Jackson version, but how would they know to do that? After all, they aren’t intimately familiar with the versioning constraints of their transitive dependencies. That should be the dependency manager’s job!

There have been multiple Maven issues filed about this, but it seems unlikely that the behavior will be changed.

Regardless, we felt strongly that we had to solve this issue holistically despite Maven’s odd behavior. It was unacceptable that users could write code that worked one day, but stopped working the next, purely based on which Jackson version Maven’s baffling logic decided to use.

We began by defining our ideal outcome.

Our requirements

  1. Rare forced upgrades

    Users that directly depend on Jackson should rarely be forced to upgrade it. They might be developing in a legacy system where upgrading is difficult.

  2. No hidden footguns

    Users that depend on an incompatible Jackson version at runtime should find out immediately, with a nice error message. They should not find out in production months later when they hit some edge case that their version doesn’t handle.

  3. No DX regressions

    Users should not have a worse developer experience, especially if they don’t directly depend on Jackson.

Our ideas

We came up with several ideas:

  • Shading

  • Lowering the Jackson dependency version by…

    • Reverting our required field checking or additionalProperties feature

    • Or rewriting our additionalProperties feature

  • Checking for incompatible Jackson versions at runtime

  • Dropping Jackson as a dependency

But not a single option satisfied every requirement.

Shading

The most obvious solution is shading, a Java strategy for circumventing dependency managers by bundling dependencies into your library.

For example, we could duplicate and move com.fasterxml.jackson.databind.json.JsonMapper to com.acme.shaded.com.fasterxml.jackson.databind.json.JsonMapper when publishing our SDK. From the user’s perspective, our SDK doesn’t depend on Jackson and there are no classpath conflicts.

Shading would satisfy our “rare forced upgrades” and “no hidden footguns” requirements, but not our “no DX regressions” requirement because it is infeasible to make libraries with shaded dependencies nice to use for developers.

Why?

  • It is confusing when the shaded dependency is used in the public API, which it is in our SDKs:

    • When interacting with our SDKs, the user will have to import com.acme.shaded.com.fasterxml.jackson.databind.json.JsonMapper instead of com.fasterxml.jackson.databind.json.JsonMapper, even if they already use the latter themselves, with no way to convert between the two types.

    • When using jump-to-definition on SDK symbols to debug type errors, the SDK’s source code will say com.fasterxml.jackson.databind.json.JsonMapper even though it uses com.acme.shaded.com.fasterxml.jackson.databind.json.JsonMapper ! Shading only affects the compiled class files, not the original source code.

    • The shaded Jackson class files will have no corresponding source files because the shaded versions are based on the compiled class files, not the original dependency’s source. So jump-to-definition on Jackson symbols will show decompiled class files without comments.

  • It increases the size of the library even when the user depends on the same Jackson version that we depend on.

  • It makes it impossible for the user to resolve a security vulnerability on their end by pinning a different Jackson version. They have to wait for us to bump the shaded Jackson version and rerelease the SDK.

Lowering the Jackson dependency version

To make incompatibility less likely we considered downgrading Jackson by either reverting our required field checking or additionalProperties feature, or rewriting our additionalProperties feature to work with an older version.

This would satisfy our “rare forced upgrades” requirement if we choose an old enough version, but there are several issues:

  • Even if we declare an “old” Jackson version, then it’s still possible for a user to directly depend on an even older version that breaks one day in production (doesn’t satisfy our “no hidden footguns" requirement).

  • There have been Jackson security vulnerabilities in versions as recent as 2.15. If we downgrade Jackson, then users will depend on an vulnerable version unless they also depend on a higher Jackson version directly (doesn’t satisfy our “no DX regressions" requirement).

  • Even if we declare a version with no known vulnerabilities, it’s possible a vulnerability is found later. This would force us to choose between upgrading Jackson, which brings us back to square one, or having a forever vulnerable SDK.

Checking for incompatible Jackson versions at runtime

We considered comparing our declared Jackson version to the detected version at runtime when the SDK client is instantiated, and throwing if the version is incompatible.

This would satisfy our “no hidden footguns” requirement, but it certainly doesn’t satisfy our “rare forced upgrades” requirement. If our declared Jackson version is still 2.18.1, then users will encounter this error and be forced to upgrade frequently.

Dropping Jackson as a dependency

The situation seemed so intractable that we even considered implementing our own JSON serialization and deserialization and dropping Jackson as a dependency. After all, sometimes the best way to solve a problem is not to have it in the first place!

We rejected this approach for a few reasons:

  • Annotating the SDK data models with Jackson allows users to serialize and deserialize them for their own purposes, especially if they already use Jackson themselves, which is likely. We would lose that with a bespoke solution.

  • Jackson is well-maintained and performant, which would be difficult to match with our own implementation.

  • It would be a lot of work!

Our solution

After much thought, we devised a multi-pronged solution that satisfies all our requirements:

  1. Rewrite our additionalProperties feature to work as far back as Jackson 2.13.4

    We chose this version, released on October 5, 2022, because:

    • It’s older than the Jackson version we originally depended on (2.14.3).

    • It appeared to be sufficiently old for our users. Every Maven user that filed an issue was depending on a more recent Jackson version. We couldn’t consult download counts because Maven repositories don’t report them.

    • Going any lower would have required rewriting basic features.

    This satisfies our “rare forced upgrades” requirement.

  2. Keep our declared version as 2.18.1 so that users get the latest version by default

    This way users who don’t directly depend on Jackson (or do, but use Gradle) end up depending on a recent and secure version. If we had changed our declared Jackson version to 2.13.4, then that would have been a bad default.

    This decision helped us stay compliant with our “no DX regressions” requirement.

  3. Compile and test the SDK against Jackson 2.13.4 to verify we don’t regress

    We used Gradle’s resolutionsStrategy to compile and test against a lower Jackson version without affecting the publicly declared version:

    configurations.all {
        resolutionStrategy {
            // Compile and test against a lower Jackson version to ensure we're compatible with it.
            // We publish with a higher version (see below) to ensure users depend on a secure version by default.
            force("com.fasterxml.jackson.core:jackson-databind:2.13.4")
            // ...
    
    

    This ensures we continue to satisfy our “rare forced upgrades” requirement going forward.

  4. Throw an error if the Jackson version used at runtime is even older than 2.13.4

    We began checking the detected version at runtime when the SDK client is instantiated, and throwing if it’s incompatible with version 2.13.4:

    """
    This SDK requires a minimum Jackson version of $MINIMUM_JACKSON_VERSION, but the following incompatible Jackson versions were detected at runtime:
    
    ${incompatibleJacksonVersions.asSequence().map { (version, incompatibilityReason) ->
        "- `${version.toFullString().replace("/", ":")}` ($incompatibilityReason)"
    }.joinToString("\\n")}
    
    This can happen if you are either:
    1. Directly depending on different Jackson versions
    2. Depending on some library that depends on different Jackson versions, potentially transitively
    
    Double-check that you are depending on compatible Jackson versions.
    
    See <https://www.github.com/openai/openai-java#jackson> for more information.
    """

    This ensures that if a user is depending on a particularly old Jackson version and needs to upgrade, then they find out right away, which satisfies our “no hidden footguns” requirement.

Since implementing this solution we’ve stopped receiving GitHub issues about this problem entirely!

Conclusion

Building SDKs with the best DX can be a deep unexpected rabbit hole at times. Even something as seemingly simple as adding a dependency can quickly become a non-trivial design problem. Luckily we and our customers are happy with where we landed here.

If you want your own Java SDK with all this goodness, then go to sdk.new to set one up and let us know what you think!

Footnotes

  1. Required response fields should not be checked during deserialization because that would not be forwards compatible in the general case (e.g. if a response field changes to be optional under certain conditions).

  2. Technically you can work around this in an application by using a second ClassLoader, but that’s not suitable for a library.

Originally posted

Jul 3, 2025