DEV Community

Cover image for Gson migration made easy!
Harrypulvirenti
Harrypulvirenti

Posted on • Updated on

Gson migration made easy!

If you are here, you probably know how tedious it could be to deal with old code written with old deprecated tools that, for one reason or another, we never had the time to tackle correctly and try to find a strategy to remove it safely. Sometimes it could even be a nightmare if the code that you have to refactor is not a simple isolated feature.

What if you have a giant network layer full of code that uses a deprecated tool like Gson that is serving other features in your application and, in some way, you have to get rid of it without breaking half of your application?
Well, I might not have a solution for the other kinds of migrations, but if you are dealing with this Gson nightmare, I have a migration strategy that might save your day. Let's start by understanding why it is such a big problem if you want to remove Gson from your codebase.

Nowadays, modern Android applications, are written in Kotlin language, which is the standard de facto in terms of programming language for Android developers. The problem is that Gson is written for Java so it lacks proper Kotlin support, especially for nullability.
Since Gson is built for Java it fails to understand the difference between nullable and non-nullable types of Kotlin. This means that if you have a DTO with some field declared as non-nullable and, one of these fields, is not returned in an API for any reason, your app will crash because of it.

A standard solution for this problem is to declare all the fields of the DTO as nullable and then map them to a domain object checking first the presence or absence of any field manually.

The result of this approach is a lot of boilerplate code like this:

// Gson Object
data class PersonDTO(
    val id: String?,
    val name: String?,
    val surname: String?,
    val address: String?
)

// Domain Object
data class Person(
    val id: String,
    val name: String,
    val surname: String,
    val address: String?
)

fun PersonDTO.toDomain(): Person {
    if (id == null || name == null || surname == null) {
        // throw an exception in a controlled manner 
        // or use default value if possible
        throw RuntimeException("Some field is null")
    } else {
        return Person(
            id,
            name,
            surname,
            address
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

This is an easy example but keep in mind that the more you complicate your network models the harder will become to deal with these kinds of checks.

For example, let's try to complicate a bit the previous code:

// Gson Object
data class PersonDTO(
    val id: String?,
    val name: String?,
    val surname: String?,
    val address: AddressDTO?
)

data class AddressDTO(
    val street: String?,
    val number: Int?,
)
Enter fullscreen mode Exit fullscreen mode

In this second example, if we want to map the PeopleDTO object to the domain class, we have to check also the fields of the AddressDTO class and so on if you add other nested objects in the response.

You can easily understand that this approach can carry on a lot of problems in terms of code complexity and boilerplate code in case of really complex network responses.

Now that we understood the problem let's assume that we want to migrate our network layer to a more Kotlin friendly deserializer like Kotlinx Serialization.

The DTO in our first example after the migration will be something like this:

// Kotlinx Serialization Object
@Serializable
data class PersonDTO(
    val id: String,
    val name: String,
    val surname: String,
    val address: String?
)
Enter fullscreen mode Exit fullscreen mode

This is an easy change to do, but for complex network responses with many nested objects, this can result in giant PRs with hundreds of files changed only for the migration of a single call.
And, trust me, once you start with the first object of the call these things will never end.
It's like opening Pandora's box.
And the more files are changed the higher is the risk of introducing some bug.

Also, we didn’t mention that technically you should migrate every network call inside your Retrofit interface to switch to a new deserializer.
Luckily, also for this problem, there are some strategies that you can adopt to migrate the responses call-by-call instead of migrating everything in once, but this is another problem (let me know in the comments if you want to read another article from me about this topic).

Well, as I said previously, this is a nightmare.

gif

There is a simple and sustainable strategy that we can apply to perform the migration.

If we compare the GSON version of the DTOs with the Kotlinx Serialization one, we might see some important differences: we have a lot of dirty DTOs classes with a lot of nullable fields that are not ready to be used with Kotlinx Serialization and a lot of checks that we are performing on these classes to see if what we received from the network is well-formed or not.

What we can do here is to migrate each DTO data class separately with the help of a custom JsonDeserializer and, using some extensions utils, performs all the checks that we were doing on the dirty DTOs class inside the deserializer.

Looks too complicated?

gif

Don't worry it will be easier than expected when we will see this approach in action so let's check the code!

Here we have the two classes that we want to migrate to Kotlinx Serialization:

// Gson Object
data class PersonDTO(
    val id: String?,
    val name: String?,
    val surname: String?,
    val address: AddressDTO?
)

data class AddressDTO(
    val street: String?,
    val number: Int?,
)
Enter fullscreen mode Exit fullscreen mode

The first step should be to update our two classes marking the fields as nullable only when there is an optional field and not everywhere.
So after this first step, our classes should look like this:

// Gson Object
data class PersonDTO(
    val id: String,
    val name: String,
    val surname: String,
    val address: AddressDTO?
)

data class AddressDTO(
    val street: String,
    val number: Int,
)
Enter fullscreen mode Exit fullscreen mode

Next, we have to create a custom deserializer for each DTO data class that we want to migrate:

class PersonDeserializer : JsonDeserializer<PersonDTO> {

    override fun deserialize(
        json: JsonElement,
        typeOfT: Type,
        context: JsonDeserializationContext
    ): PersonDTO = with(json.asJsonObject) {
        PersonDTO(
            id = getNotNull("id"),
            name = getNotNull("name"),
            surname = getNotNull("surname"),
            address = deserializeAddressDTO(context)
        )
    }

    private fun JsonObject.deserializeAddressDTO(context: JsonDeserializationContext): AddressDTO? =
        context.deserialize(getOrNull<JsonObject>("address"), getType<AddressDTO>())
}

class AddressDeserializer : JsonDeserializer<AddressDTO> {

    override fun deserialize(
        json: JsonElement,
        typeOfT: Type,
        context: JsonDeserializationContext
    ): AddressDTO = with(json.asJsonObject) {
        AddressDTO(
            street = getNotNull("street"),
            number = getNotNull("number")
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

These two deserializers are responsible for converting the received JSON to the desired DTO class checking that all the required fields are present.
But as you can see the code is not that complicated, right?
Well, this is because here I'm using some utils to simplify all the dirty work that we have to do in terms of checks and in particular I'm talking about the functions getNotNull, getOrNull and getType.

Let's see the code of these functions in details:

inline fun <reified T : Any> JsonObject.getNotNull(memberName: String): T =
    getOrNull(memberName)
        ?: throw JsonParseException("$memberName should be not null but is not present in the response or is null")

inline fun <reified T : Any> JsonObject.getOrNull(memberName: String): T? =
    when (T::class) {
        JsonObject::class -> toKotlinNullable(memberName)?.asJsonObject as? T
        JsonArray::class -> toKotlinNullable(memberName)?.asJsonArray as? T
        JsonPrimitive::class -> toKotlinNullable(memberName)?.asJsonPrimitive as? T
        String::class -> toKotlinNullable(memberName)?.asString as? T
        Boolean::class -> toKotlinNullable(memberName)?.asBoolean as? T
        Int::class -> toKotlinNullable(memberName)?.asInt as? T
        Long::class -> toKotlinNullable(memberName)?.asLong as? T
        else -> throw JsonParseException("Item type not supported")
    }

fun JsonObject.toKotlinNullable(memberName: String): JsonElement? =
    get(memberName)?.takeIf { !it.isJsonNull }

inline fun <reified T> getType(): Type = object : TypeToken<T>() {}.type
Enter fullscreen mode Exit fullscreen mode

As you can see from the utils in the snippet, the only thing done is just to ensure that, the desired type of the field, is not null and, if yes, the field is extracted and cast to the specific requested type.

Now that we have the custom deserializers implemented, the last thing that we need to do is to register both of them in the Gson instance that we are using with Retrofit.

To do so, we can implement and use an extension function like this:

fun GsonBuilder.registerDeserializers() =
    registerTypeAdapter(PersonDTO::class.java, PersonDeserializer())
        .registerTypeAdapter(AddressDTO::class.java, AddressDeserializer())

val gsonInstance: Gson =
    GsonBuilder()
        .registerDeserializers()
        .create()
Enter fullscreen mode Exit fullscreen mode

And that's it!
In this way, you can easily migrate every single DTO in a Kotlin friendly version in a separate PR without changing a lot of classes.

Once you are finished with the migration of all the DTO classes in your data layer, to start using Kotlinx Serialization, you just have to add the @Serializable annotation in all the DTO and replace the Gson instance used in Retrofit with Kotlinx Serialization.

All the custom deserializers that we implemented can be now deleted because no longer needed.

If you reached this step congratulations!
You successfully migrated your data layer to Kotlinx Serialization without (I hope) going crazy!

You can find all the provided code snippets in the following repository separated into different branches (one per step) as well as tests for all the custom deserializers.

GitHub logo Harrypulvirenti / GsonMigration

Sample repository for a practical strategy to remove Gson from your project.

Gson Migration Strategy - Practical guide

In this repository, you will find the code examples of a migration strategy from Gson to Kotlin x Serialization.

This strategy is detailed described in this article.

The examples are split into different branches per step:

  • Fix DTOs fields nullability
  • Implement a Custom Deserializers
  • Register the deserializers to the Gson instance and deserializers tests
  • Finalise the migration to Kotlin x Serialization

For details about the advantages of this strategy refer to the provided article above.

Hope that this article helped you!

gif

Discussion (0)