Almost a year ago I wrote a blog post about MicroService versioning where I explained a few common problems with MicroServices and a few different approaches on how to solve versioning. My favorite solution to the versioning problem is to have the service 'downgrade' responses to older versions of that response based on a client supplied 'protocol version'. In this blog post I will walk you through an implementation of this adapter pattern using Spring’s @ControllerAdvice.
While this particular blog post is written on top of Spring Boot (which I use on the job every day) the same solution can be adapted to other REST frameworks or more traditional monolithic architectures.
This blog post is accompanied with an example repository.
Introduction
So let’s start with a short recap of my previous blog post. Whenever you design an API you have to take backward compatibility into consideration. Unfortunately this won’t always work out the way we want; it’s impossible to predict the future. You can come a long way with just adding new information to REST responses but sometimes the need to remain backward compatible can prevent you from making the 'right' decisions. With MicroServices when you both have external applications and internal services all using the same REST endpoints the problem becomes even more complex.
I’m not a fan of '/v2' type URLs for two simple reasons: first of all they break the principle of an URL to identify a resource. Secondly it’s typically not possible to keep a separate older version of the service on-line; this would create a large maintenance overhead. Abusing the 'Accept' header is also not something I’m fond of; it’s meant for mime types. So if it’s at all possible I prefer the client to just send a customer header. In this example I’m using the 'X-Protocol-Version' header.
Use case
We have a mobile application, for both Android and iOS backed by a Spring Boot back-end. The API is currently at version 3 where we have a UserController that either returns a single User or a list of all our Users. We made some design mistakes at the start. In version 1 our User only had a 'name':
curl -H "X-Protocol-Version:1" http://localhost:8080/user/0 { "name": "Jack Johnson" }
Unfortunately we later found out that we needed the name to be split into a first and last name, this became version 2:
curl -H "X-Protocol-Version:2" http://localhost:8080/user/0 { "firstName": "Jack", "lastName": "Johnson" }
Also another feature required us to store the user’s e-mail so we also added an 'email' field to the response. Although it was supposed to a backward compatible change one of the mobile app versions that was still supported had a bug that crashed the app when we added the e-mail, which is how we end up with version 3:
curl -H "X-Protocol-Version:3" http://localhost:8080/user/0 { "firstName": "Jack", "lastName": "Johnson", "email": "[email protected]" }
These cases are in fact based on real life issues I ran into with our current project. When there’s a pressure to get features out sometimes make mistakes. Fortunately with this method we are actually able to correct these mistakes.
So as you can see we get different responses on the exact same end point depending on which protocol version we supply. So how does this work? In my previous blog post the 'quick and dirty' implementation had the adapter logic in the controller. I wanted to have a generic solution that is reusable not only between different controllers but also between different services. In this example the UserController is a bog-standard REST controller that just retrieves information from a UserService, constructs a view (UserDTO) and passes it on. So where does the translation happen?
@ControllerAdvice
My first intuition when I went on to try and make a generic solution was to use Filters or Interceptors. Unfortunately it’s not that straightforward: both Filters and Interceptors either work before the controller is called or after the UserDTO is serialized into JSON. While deserializing, mapping and then serializing is an option I considered, it’s also rather inefficient. Fortunately there is a way to intercept these controller responses before they are serialized.
I used @ControllerAdvice in a previous blog post to implement reusable error handlers and again Spring showed it’s strengths in being a very pluggable and customizable framework. Another type of @ControllerAdvice is ResponseBodyAdvice: it allows you to inspect and change Controller responses. In this solution I have implemented my own in the form of AdapterAdvice.
It works by allowing us to have DTO’s like the UserDTO implement a Versioned interface. It’s used as a marker interface to check if the Adapter should kick in and try to change the DTO to another version. Spring handles this in two separate steps: it first checks if a particular ResponseBodyAdvice needs to act on a response by calling public boolean supports(…)
. In that method we check if the parameter implements the Versioned interface:
@Override
public boolean supports(
MethodParameter methodParameter,
Class<? extends HttpMessageConverter<?>> aClass) {
for(Class<?> interf : ((Class<?>)methodParameter
.getGenericParameterType())
.getInterfaces()) {
if(interf.equals(Versioned.class)) {
return true;
}
}
return false;
}
So if a DTO implements Versioned it will be handled by the ResponseBodyAdvice, if not it’s skipped by Spring. So when a certain version does not need to be supported anymore simply removing the Versioned interface from a DTO excludes it from this mechanism. If supports(…)
returns true Spring will then call public T beforeBodyWrite(…)
where we handle modifying the DTO:
@Override
public Versioned beforeBodyWrite(
Versioned versioned,
MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
String version = serverHttpRequest
.getHeaders()
.getFirst(PROTOCOL_VERSION_HEADER);
if(version == null) {
throw new RuntimeException(
String.format("Missing '%s' header.", PROTOCOL_VERSION_HEADER)
);
}
return versioned.toVersion(parseInt(version));
}
What’s awesome is that we have access to the request, response and controller response here. You can do tons of 'magic' in ResponseBodyAdvice (these kinds of little well thought out things make me appreciate Spring even more). If no protocol version is specified I throw an exception. I prefer this 'defensive' approach since it makes it clear that the consumer is breaking the contract by not supplying the protocol version. If you are introducing this at a later moment you could assume 'version 1' instead although I would then recommend returning that version in the response as well:
serverHttpResponse.getHeaders().put(PROTOCOL_VERSION_HEADER, singletonList(version));
DTO Versions
The UserDTO has three different versions. The Versioned interface forces a versioned DTO to implement a toVersion method. So if we take a look at our version 3 UserDTO, it simply steps down to a previous version if needed:
@Override
public Versioned toVersion(int version) {
if (version <= 2) {
return new UserDTOv2(firstName, lastName)
.toVersion(version);
} else {
return this;
}
}
And our version 2 UserDTOv2 does the exact same thing:
@Override
public Versioned toVersion(int version) {
if (version <= 1) {
return new UserDTOv1(firstName + " " + lastName);
} else {
return this;
}
}
There is an argument to be made against having 'logic' in a DTO. At first I had this logic in a 'mapper' component but to me it appeared quite cumbersome. I now favor this method since it makes later refactoring easier. If we would phase out clients using protocol version 1 and 2, making UserDTO the only version, all the refactoring I would need to do is remove the "Versioned" interface from UserDTO and simply deleting UserDTOv1 and v2.
Conclusion
You’ve seen the CURL responses at the start of this blog post but I also implemented 3 Java/OkHttp clients that use a 'base client' to that handles the protocol version bit. Just to give you an example that using this in the communication between MicroServices is relatively straightforward too. You can see them in action by running the client application.
So that’s it for this post. I hope you enjoyed reading this post as much as I enjoyed writing it! If you have any questions or feedback feel free to raise an issue in this repository!