At Stainless we design SDKs that gracefully evolve with your API. In particular, we pay close attention to forwards compatibility in our SDKs. Non-breaking changes to your API shouldn’t become breaking changes when it's time to update your SDKs!
Achieving this can be surprisingly difficult and problems arise in unexpected places. Let’s take a look at a recent seemingly small issue that came up when designing our Java SDK.
An example API
Suppose we’re designing a Java SDK for a Pet Store API. We’d probably start with a class and builder for each API object. One class might look like so:
Suppose we ship the SDK and users are happily using it. Then one day we decide to make tagId
optional.
“No problem,” we think. Let’s just make the tagId
parameter boxed so that it can accept null
:
Seems reasonable right? There are actually a couple of problems.
Problem 1: Java primitive conversions
Imagine a user wrote the following code before our change:
We might expect the user’s code to keep working. We just widened the type we accept, right? Wrong.
What happened here? It turns out that Java performs automatic conversion between:
- Primitive types (e.g.
int
tolong
) - Unboxed and boxed (e.g.
long
toLong
)
But it cannot do both at the same time! Some more examples:
Problem 2: Binary compatibility
Imagine a user wrote the following code before our change:
Clearly it doesn’t run into Problem 1, so are we in the clear? Nope.
Suppose this code was released as a library called nickel-lib
prior to the new SDK version. If another user:
- Depends on both the SDK (directly) and
nickel-lib
- Upgrades the direct SDK dependency version without also upgrading
nickel-lib
- Dependency resolution choose the newer version
Then nickel-lib
would encounter a NoSuchMethodError
the next time the user runs their code. Why?
Although tagId(42L)
is source-compatible with both tagId(long)
and tagId(Long)
, tagId(long)
and tagId(Long)
have different JVM type signatures, and just one of those is chosen when compiling tagId(42L)
.
The result is that nickel-lib
's *.class
file, compiled before our change, is still trying to look up tagId(long)
after our change, but it no longer exists. Our change is not binary compatible.
Solution
So what’s an SDK engineer to do?
When making our change, we should have preserved the original signature like so:
This fixes both problems:
And there’s an additional benefit even when a parameter is nullable from the beginning. Users can pass literal int
for nullable Long
fields without trouble.
Get updates from Stainless
Did this happen at Stainless?
We actually caught this problem during design review, so the issue never made its way to customers!
At Stainless, we obsess over these types of details like no one else—so you can ship beautiful, reliable code that your users love. Start shipping SDKs this afternoon with the Stainless Studio.