API Versioning with Kotlin and Spring Boot

API Versioning with Kotlin and Spring Boot

I wrote a short poem that explains the need for API versioning:

There was an API
And it was good
Then PM came
And said “you should
Change the field
From array to string”
How can I do it?
This is BC!
And there it comes
The magical V
V like a version
Of your API.

Let’s get into the details!

Why and when to version an API?

I believe almost everyone once asked themselves the question “Should I version the API I created?”. Almost like always, there’s no single answer. However, if you already thought about it, it might be a good idea to pay attention to the problem before it becomes a real production issue.

From my experience, all the APIs that work directly with the client (mobile apps, web applications) should be versioned. Imagine that you want to perform a breaking change like in the poem: the API supports multiple categories per item, but customers don’t like it for some reason. The business comes and asks you to change the API so it always accepts only a single category. If you change the API in place you’ll break all the clients that already use the current solution. For the web application there could probably be a short time while it stops working (for the time of deployment), but how can you force the users to upgrade all the mobile apps at once?

On the other hand, if your API is internal you might not need full API versioning. Sometimes it’ll be easier to introduce a copy-paste endpoint, switch the usage on the client’s side, and then remove the old one. This approach requires you to know exactly how many clients the API has and align with all the consumers beforehand.

How to version the API?

There are several approaches to API versioning that can be used. I believe the most common are described below.

Versioning via URL path param

http://api.example.com/v1/items

The idea is to introduce the API version as a part of the path for each request coming to your API. The main issue with this solution is that for each new version of the APIs you need to modify your API specification for all endpoints to change the URL. I believe it can be pretty messy in the long run.

Versioning via URL query param

http://api.example/com/items?version=1

The approach is similar, but this time your application will need to parse and look for specific query path parameters for each request coming to the application. It might not be a big issue on the server side, but from the mobile/frontend side it might be a bit tricky to implement this solution, especially if there are endpoints that already use the query params to fetch data (like pagination). Usually, the clients use some kind of generator to not write all the code manually, and those will not support this kind of versioning without additional effort.

Versioning by extending the Accept header content

Accept: text/json; version=1

This idea is about appending a version string to the existing Accepts header. I don’t personally like this solution for similar reasons as with the URL query param. I believe that it might not be so straightforward on the client side to modify the Accept header for each request — they are usually automatically populated by the HTTP client based on the passed content type.

Versioning through the custom header

Accept-Version: 1.0

The idea is to add a custom header to every request. This is in my opinion the best and clearest approach that I’ll show you how to implement in the Spring Boot application.

Versioning through the custom header — Spring Boot configuration

To achieve the goal we need to add several small classes to the project, that will allow us to easily define the same endpoints across different controllers (or even the same) being identified by the custom annotation we’re going to create.

First of all, we need to define the custom annotation and enum with supported API versions:

Then we need to write ApiVersionCondition class that implements the Spring RequestCondition interface. It requires implementing 3 methods:

  • combine defines the rules of overriding the annotations between classes and methods. In this case method annotations takes precedence over class annotations.

  • getMatchingCondition is responsible for reading the API version from the request and comparing it with the API version set in the annotation. We also fall back to version 1.0.0 if the request doesn’t contain the Accept-version header. Another possibility is to always require the header and return null when it’s not present.

  • compareTo is used to define version order. In our case, it’s an enum so its compareTo method is used.

The next step is to extend the Spring RequestMappingHandlerMapping class that will tell spring how to create the ApiVersionCondition for classes and methods. This time we need to override 2 methods:

  • getCustomMethodCondition defines how to create the ApiVersionCondition from a Method

  • getCustomTypeCondition defines how to create the ApiVersionCondition from a Class

And the last step before we can start using the annotation in the controllers is to add the new RequestMappingHandlerMapping class to the Spring Boot Configuration

Using the new annotation in controllers

Let’s create 3 classes for testing purposes. We have 1 API interface and 2 implementations for different API versions. We create the 1.0.0 version annotation on the V1 controller and the 2.0.0 annotation on the V2 controller with the 2.1.0 annotation of one of its methods.

Color API interface

V1.0.0 controller

V2.0.0 controller with one V2.1.0 method

Results for different curl calls

Calling list endpoint without version header or with 1.0.0 version returns the V1.0.0 controller response:

curl --location --request GET 'http://localhost:8080/colors'
curl --location --request GET 'http://localhost:8080/colors' --header 'Accept-version: 1.0.0'
Response:
[
    {
        "tag": "V1.0.0 list color",
        "hex": "#FF0000"
    }
]

Calling list endpoint with 2.0.0 or 2.1.0 version header returns V2.0.0 controller response:

curl --location --request GET 'http://localhost:8484/colors' --header 'Accept-version: 2.0.0'
curl --location --request GET 'http://localhost:8484/colors' --header 'Accept-version: 2.1.0'
Response:
{
    "colors": [
        {
            "tag": "V2.0.0 list color",
            "hex": "#f0f0f0"
        }
    ]
}

Calling single item endpoint without version header or with 1.0.0/2.0.0 returns V1.0.0 controller value:

curl --location --request GET 'http://localhost:8484/color'
curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 1.0.0'
curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 2.0.0'
Response:
{
    "tag": "V1.0.0 single color",
    "hex": "#FF0000"
}

Finally calling single item endpoint with 2.1.0 version header returns V2.1.0 controller method value

curl --location --request GET 'http://localhost:8484/color' --header 'Accept-version: 2.1.0'
Response:
{
    "tag": "V2.1.0 single color",
    "hex": "#f0f0f0"
}

Performance impact

Last thing to check is the impact on application performance. For that purpose I used Apache Benchmark. I ran a test that calls the endpoint 10000 times with no concurrent users:

ab -n 100000 -c 1 -H "Accept-version: 2.0.0" http://localhost:8484/colors

The first test I ran with the @ApiVersion annotation processor enabled:

Concurrency Level:      1
Time taken for tests:   95.117 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      16800000 bytes
HTML transferred:       4000000 bytes
Requests per second:    1051.33 [#/sec] (mean)
Time per request:       0.951 [ms] (mean)
Time per request:       0.951 [ms] (mean, across all concurrent requests)
Transfer rate:          172.48 [Kbytes/sec] received

Then I removed all the configs and annotations we created and I left only one controller which I tested with the same command:

Concurrency Level:      1
Time taken for tests:   82.624 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      16800000 bytes
HTML transferred:       4000000 bytes
Requests per second:    1210.30 [#/sec] (mean)
Time per request:       0.826 [ms] (mean)
Time per request:       0.826 [ms] (mean, across all concurrent requests)
Transfer rate:          198.57 [Kbytes/sec] received

The result is that the annotation processor has some impact on the requests but the mean impact per request is only 1/8 ms. I think this is acceptable value and will matter only in performance-critical environments.


I hope this will help you to manage the API versions in your code easier and will make your code architecture cleaner. Happy to hear any feedback from you!