Java – How Should I Design JSON Serializable Data Classes To Respect Future @NonNull Fields

androidjavajson

I have an app that uses Gson to serialize/deserialize data classes and persist data between runs.

My code uses @NonNull annotations for many fields/parameters/method returns and one thing that was What if I (or another developer) adds a new @NonNull annotated field (or even just annotates an existing field) to a data class and Gson sets it to null because the JSON was saved in a previous version of the app?

Well I answered that question today. As the user guide says and my tests confirm:

  • This implementation handles nulls correctly
    • While serialization, a null field is skipped from the output
    • While deserialization, a missing entry in JSON results in setting the corresponding field in the object to null

Indeed, if I add a new field to my data class, it might be initialized to null.

So I looked for a solution. I found that if I add a private no-args constructor to the data class, there is enough information for the IDE to flag that the field could indeed be null. An example of this implementation is listed below.

public void testDataClass1() throws Exception {
    int value1 = 23;
    int value2 = 48;
    final DataClass1Version1 inputClass = new DataClass1Version1(value1, value2);
    final String dataClass1Json = getGson().toJson(inputClass);
    final DataClass1Version2 outputClass = getGson().fromJson(dataClass1Json, DataClass1Version2.class);
    assertEquals(outputClass.getValue1(), value1);
    assertEquals(outputClass.getValue2(), value2);
    assertNotNull("New @NonNull field shouldn't be null.", outputClass.getSomeNewValue());
}

DataClass1Version1.java:

public class DataClass1Version1 {
    private int value1;
    private int value2;

    public DataClass1Version1(int value1, int value2) {
        this.value1 = value1;
        this.value2 = value2;
    }

    public int getValue1() {
        return value1;
    }

    public void setValue1(int value1) {
        this.value1 = value1;
    }

    public int getValue2() {
        return value2;
    }

    public void setValue2(int value2) {
        this.value2 = value2;
    }

    @Override
    public String toString() {
        return "DataClass1Version1{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                '}';
    }
}

DataClass1Version2.java:

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

public class DataClass1Version2 {
    private int value1;
    private int value2;
    @Nullable
    private String someNullableString;
    //@NonNull
    //private String someNewValue; //you'll get a warning for not initializing this value
    @NonNull
    private String someNewValue = ""; //if you don't initialize this field here, it could be null after json deserialization

    private DataClass1Version2() { 
        //adding this constructor caused a warning when someNewValue didn't have an initializer
    }

    public DataClass1Version2(int value1, int value2, @Nullable String someNullableString, @NonNull String someNewValue) {
        this.value1 = value1;
        this.value2 = value2;
        this.someNullableString = someNullableString;
        this.someNewValue = someNewValue;
    }

    public int getValue1() {
        return value1;
    }

    public void setValue1(int value1) {
        this.value1 = value1;
    }

    public int getValue2() {
        return value2;
    }

    public void setValue2(int value2) {
        this.value2 = value2;
    }

    @Nullable
    public String getSomeNullableString() {
        return someNullableString;
    }

    public void setSomeNullableString(@Nullable String someNullableString) {
        this.someNullableString = someNullableString;
    }

    @NonNull
    public String getSomeNewValue() {
        return someNewValue;
    }

    public void setSomeNewValue(@NonNull String someNewValue) {
        this.someNewValue = someNewValue;
    }

    @Override
    public String toString() {
        return "DataClass1Version2{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                ", someNullableString='" + someNullableString + '\'' +
                ", someNewValue='" + someNewValue + '\'' +
                '}';
    }
}

The problem with this solution is that I generally use final fields in my data classes and as such, a no-args constructor won't work. What should I do?

While writing this up, I thought of just initializing to some default values and then I guess since Gson uses reflection, it will overwrite those defaults, even though they are final fields. I still want to know what others think. Does this add extra weight to deserialization; if I have a bunch of ArrayLists and I create new instances in the no-args constructor, won't the new instances be instantly replaced with the instance Gson generated?

Here's DataClass2Version2 with final fields and default initializers:

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

public class DataClass2Version2 {
    private final int value1;
    private final int value2;
    @Nullable
    private final String someNullableString;
    @NonNull
    private final String someNewValue;

    private DataClass2Version2() {
        value1 = 0;
        value2 = 0;
        someNullableString = null;
        someNewValue = "";
    }

    public DataClass2Version2(int value1, int value2, @Nullable String someNullableString, @NonNull String someNewValue) {
        this.value1 = value1;
        this.value2 = value2;
        this.someNullableString = someNullableString;
        this.someNewValue = someNewValue;
    }

    public int getValue1() {
        return value1;
    }

    public int getValue2() {
        return value2;
    }

    @Nullable
    public String getSomeNullableString() {
        return someNullableString;
    }

    @NonNull
    public String getSomeNewValue() {
        return someNewValue;
    }

    @Override
    public String toString() {
        return "DataClass2Version2{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                ", someNullableString='" + someNullableString + '\'' +
                ", someNewValue='" + someNewValue + '\'' +
                '}';
    }
}

Best Answer

If there are files in the wild which do not contain the field (or which have the field as null) and which you need to parse, then by definition the field is nullable. @NotNull is inappropriate in such cases.

So what can you do?

For starters, you can put a version number at the top level of the file, like this:

{
 "version": 1,
 ...other fields...
}

Then, you have one class for parsing version 1 files, one class for version 2 files, and so on. You throw nullable fields onto the old classes and non-nullable fields onto the new classes. Then you just need a common interface and (perhaps) a method which converts old classes into the newest class.

This has an important downside: if you do this a lot, you will end up with a lot of classes. Unfortunately, the best advice I can offer is "don't do this a lot." The fact is, schema changes are difficult once you have live (production) data. It does not matter if you're using SQL, JSON, or something entirely different. You have to be prepared to deal with legacy data in one way or another, and that will always be challenging, regardless of how you go about it.