JSON-B does not have a versioning API but it has actually some versioning capabilities.

Let's review a few.

Versioning use case

This post will take these two versions of the same payload to illustrate the different parts.

Let's assume with have a movel in v1:

@Data
public class UserV1 {
    private int age;
    private String name;
    private String address;
}

and the same model in v2:

@Data
public class UserV2 {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    @Data
    public static class Address {
        private int number;
        private String street;
        private String city;
    }
}

 

In JSON, the same models look like:

// v1
{
  "age":33,
  "name":"Romain Manni",
  "address":"1 anything here"
}
// v2
{
  "age":33,
  "complexAddress":{
    "city":"here",
    "number":1,
    "street":"anything"
  },
  "firstName":"Romain",
  "lastName":"Manni"
}

Now we have an use case, let see what we can do to handle that at mapper level.

Fields can be mapped

When fields can be mapped from one version to the other, the simplest solution is to use setters. It will enable you to implement a custom logic to convert a version to another. In our case, we want to map name to firstName and lastName. There are common algorithms to do that, they are rarely 100% exact but if it is enough for your case you can implement a setName(String) which will propagate the value to firstName and lastName in the UserV2 class:

@Data
public class UserV2 {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    public void setName(final String name) { // simple impl for the demo
        final String[] strings = name.split(" ");
        setFirstName(strings[0]);
        setLastName(strings.length > 1 ? Stream.of(strings).skip(1).collect(joining(" ")) : "");
    }
}

This trivial logic to assume first name is the first string separated by a space and the last name the end of the string is a heuristic you can use to fill the payload without breaking the new API. Note that the opposite (merging multiple fields in a single one is generally more trivial with the same trick but it happens rarely).

Indeed, we should do the same kind of implementation for the address.

In practise, this kind of implementation must be enriched with a kind of dictionnary to ensure the split does not break a name too randomly.

Use getters

@Data
public class UserV2 {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    // v1
    private String name;

    public void getFirstName() {
        if (firstName == null && name != null) {
            // do migrate
        }
        return firstName
    }
}

Here we migrate lazily the values but it enforced us to store the old values. It also enforces us to know that a not initialized field cna be initialized from another one.

Typically, in previous snippet, if we want to support:

{"firstName":null}

then we will need to add a boolean to store that setFirstName(null) was not called and let the migration be done, otherwise we would need to skip the migration:

@Data
public class UserV2 {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    // v1
    private String name;
    // v2 state
    private boolean firstNameSet; // with setter

    public void getFirstName() {
        if (firstName == null && !firstNameSet && name != null) {
            // do migrate
        }
        return firstName
    }
}

... it can become quite verbose but is luckily rarely needed.

Johnzon fallback handler

If you use Apache Johnzon JSON-B implementation, you can also use the same kind of trick but in a dedicated hook and not in setters. Johnzon provide a @JohnzonAny callback for the unknown attributes of the incoming payload so you can handle the migration in this callback:

@Data
public class UserV2 {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    @JohnzonAny
    public void onUnknownProperty(final String key, final Object value) {
        if (value == null) {
            return;
        }
        switch (key) {
            case "name": {
                final String[] strings = String.valueOf(value).split(" ");
                setFirstName(strings[0]);
                setLastName(strings.length > 1 ? Stream.of(strings).skip(1).collect(joining(" ")) : "");
                break;
            }
            case "address": {
                // ... migrate
                break;
            }
            default: // unknown, log
        }
    }
}

The main advantage is to keep the migration in a single location, the drawback being it is a vendor specific API which is not always acceptable.

These solution work but have a drawback: they don't handle a migration accross multiple field properly.

Store the version in the payload

The next immediate solution to handle migrations is to enrich the payload (or request but it leads to the same) with the version.

But it is not enough to add a version field in the payload because when deserialized, you must also propagate the migrated fields once all fields are deserialized otherwise you can miss a value or even miss the version.

Here is what it can look like:

@Data
public class UserWithVersion {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    private int _version; // meta

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    private Map<String, Object> unknowns; // or keep v1 attributes in plain JSON-B mode

    // 1. post migration
    public void afterRead() { // must be called manually
        if (_version == 1) { // migrate name and address
            ofNullable(unknowns.get("name"))
                    .map(String::valueOf)
                    .ifPresent(name -> {
                        final String[] strings = name.split(" ");
                        setFirstName(strings[0]);
                        setLastName(strings.length > 1 ? Stream.of(strings).skip(1).collect(joining(" ")) : "");
                    });
            // same with address
        }
    }

    // 2. alternatively use getters
    public Address getComplexAddress() {
        if (complexAddress == null && _version == 1) {
            ofNullable(unknowns.get("address")) // read old value and map it to the new one - or just fail if unsupported but this should rather be done at API/root level, not mapper level
                    .map(String::valueOf)
                    .ifPresent(address -> {
                        final String[] strings = address.split(" ");
                        final Address addr = new Address();
                        addr.setNumber(Integer.parseInt(strings[0]));
                        addr.setStreet(strings[1]);
                        addr.setCity(Stream.of(strings).skip(2).collect(joining(" ")));
                        setComplexAddress(addr);
                    });
        }
        return complexAddress;
    }

    @JohnzonAny
    public void onUnknownProperty(final String key, final Object value) {
        if (unknowns == null) {
            unknowns = new HashMap<>();
        }
        unknowns.put(key, value);
    }
}
  1. We add an entity callback once the entity is deserialized which handles the migration knowing the current instance version. it works because we stored the old values (either with the fallback callback we saw previously or just storing previous attributes in the entity with the same "name"). Main drawback is it requires to call a post deserialization callback.
  2. Alternatively to this post callback, when there is no relationships between fields, the migration can be done lazily in a getter.

Last note is that if you feel that need in your v2, you can add the version and the lack of version in the payload means you are in v1 (or v0).

Inject the version in the constructor

Previous example issue was that we needed to have the version to be able to take a decision to handle an old field. The solution is to enforce the entity to have the version before any other field. In java it means setting the version in the constructor and we can do that with JSON-B:

@Data
public class UserWithVersionInConstructor {
    private int age;
    private String firstName;
    private String lastName;
    private Address complexAddress;

    private final int _version; // meta

    @JsonbCreator
    public UserWithVersionInConstructor(@JsonbProperty("_version") final int version) {
        this._version = version;
    }


    // migration with setters, @JohnzonAny or any other way but no more useless storage
}

This solution enables to get rid of the unknowns map of the previous example and more generally to have a post deserialization callback until fields have a relationships and can't be iteratively constructed. This is a real gain. It also split the meta fields from the model fields which is a nice modelling soluition.

Forget about in-place migration and have an higher level migration handler

The last option I will talk about is to get rid of the inline/inplace migration callbacks (setters, johnzon any etc) and just migrate on top of the model of each version.

In this solution we keep UserV1 and UserV2 and just select the one we want to map.

High level, the idea is to use JsonObject to load the instance and then map it to a POJO:

final JsonObject user = jsonb.fromJson(inputStream, JsonObject.class);
switch (user.getInt("_version")) {
    case 1:
        final UserV1 v1 = jsonb.fromJson(user.toString(), UserV1.class);
        return mapToV1(v1);
    case 2:
        return jsonb.fromJson(user.toString(), UserV2.class);
    default:
        throw new IllegalArgumentException("Invalid version");
}

If you don't have a version field, you can just guess the version from the attribute and do the same kind of mapping.

TIP: if you don't want to do an useless roudtrip (we load a JsonObject then serialize it through toString() then deserialize the POJO), Johnzon and Yasson have both a way to skip the useless serialization. With Johnzon, you can just use a JsonValueReader:

jsonb.fromJson(new JsonValueReader<>(user), UserV1.class)

The fact to use JsonValueReader will provide to Johnzon mapper the JsonObject and it will bypass any deserialization.

This solution looks more complex but when you think about real use cases where you want to migrate payloads, it is actually a real option since for a JAX-RS API it is trivial to put that code in a MessageBodyReader for instance.

Conclusion

Migrating a model to another one is never trivial and there are cases you just can't (missing data, aggregated to split model without any heuristic between both models etc...). However, in most cases, when you keep the same endpoint - to speak about an API - you have some compatibility between both model and you can do the migration on the fly - otherwise you want to change the endpoint somehow.

In such cases, there are already some solutions to handle the migration smoothly from transparent to invasive solutions.

However, you must always take into account a few points:

  • Complexity of the migration
  • Maintenance cost of the migration code
  • Impact on end user (does it require a new field which can be forgotten?)
  • Consistence if there are future versions

Depending all these facts, one solution or another can be more relevant - there is no silver bullet but there are bullets ;).

From the same author:

In the same category: