Why Kotlin Serialization Is Replacing Gson and Moshi
Setup and Gradle Configuration
// build.gradle.kts (project-level)
plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
// build.gradle.kts (module-level)
plugins {
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Optional: Retrofit converter
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
// Optional: Ktor client
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0")
}
// libs.versions.toml
[versions]
kotlin = "2.1.0"
kotlinx-serialization = "1.7.3"
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }Defining Serializable Models
@Serializable
data class User(
val id: Long,
val email: String,
val name: String,
val role: UserRole = UserRole.VIEWER, // Default if missing from JSON
@SerialName("avatar_url") val avatarUrl: String? = null, // Renamed + nullable
@Transient val localTimestamp: Long = 0L // Excluded from serialization
)
@Serializable
enum class UserRole {
@SerialName("admin") ADMIN,
@SerialName("editor") EDITOR,
@SerialName("viewer") VIEWER
}
// Parsing -- null-safe, type-checked at compile time
val json = Json { ignoreUnknownKeys = true }
val user: User = json.decodeFromString("""
{"id": 42, "email": "[email protected]", "name": "Rocky"}
""")
// user.role == UserRole.VIEWER (default applied)
// user.avatarUrl == null (nullable, missing from JSON)
// Encoding
val jsonString: String = json.encodeToString(user)Sealed Classes and Polymorphic Serialization
@Serializable
sealed interface NetworkResult<out T> {
@Serializable
@SerialName("success")
data class Success<T>(val data: T) : NetworkResult<T>
@Serializable
@SerialName("error")
data class Error(
val code: Int,
val message: String
) : NetworkResult<Nothing>
}
// Polymorphic notifications from a WebSocket
@Serializable
sealed class PushNotification {
abstract val id: String
abstract val timestamp: Long
@Serializable
@SerialName("message")
data class ChatMessage(
override val id: String,
override val timestamp: Long,
val senderId: String,
val text: String
) : PushNotification()
@Serializable
@SerialName("order_update")
data class OrderUpdate(
override val id: String,
override val timestamp: Long,
val orderId: String,
val status: String
) : PushNotification()
}
// Deserialize mixed array automatically
val notifications: List<PushNotification> = json.decodeFromString("""
[
{"type": "message", "id": "1", "timestamp": 1711540800, "senderId": "u42", "text": "Hello!"},
{"type": "order_update", "id": "2", "timestamp": 1711540900, "orderId": "ord-99", "status": "shipped"}
]
""")Retrofit and Ktor Integration
// Retrofit setup
val contentType = "application/json".toMediaType()
val jsonConfig = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false // Don't send default values in requests
explicitNulls = false // Omit null fields from requests
}
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.addConverterFactory(jsonConfig.asConverterFactory(contentType))
.build()
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Long): User
@GET("users")
suspend fun listUsers(
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): PaginatedResponse<User>
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): User
}
@Serializable
data class PaginatedResponse<T>(
val data: List<T>,
val page: Int,
val totalPages: Int,
@SerialName("has_more") val hasMore: Boolean
)
@Serializable
data class CreateUserRequest(
val email: String,
val name: String,
val role: UserRole = UserRole.VIEWER
)Custom Serializers and Migration Strategies
// Custom serializer for java.time.Instant
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor(
"Instant", PrimitiveKind.STRING
)
override fun serialize(encoder: Encoder, value: Instant) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Instant {
return Instant.parse(decoder.decodeString())
}
}
// Usage: apply once in the model
@Serializable
data class Event(
val id: String,
val name: String,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)
// Or register globally in the Json configuration
val json = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(Instant::class, InstantSerializer)
}
}
// Migration: both converters on the same Retrofit instance
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.addConverterFactory(jsonConfig.asConverterFactory(contentType)) // Tries first
.addConverterFactory(GsonConverterFactory.create()) // Fallback
.build()Key Takeaways
- 1Kotlin Serialization validates nullability and types at compile time, eliminating an entire class of runtime crashes caused by Gson's silent null coercion.
- 2Sealed class polymorphism works natively with a discriminator field -- no custom adapters or factory classes required.
- 3Zero reflection means no ProGuard/R8 keep rules, faster parsing, and smaller APK size compared to Gson.
- 4Default parameter values are honored during deserialization, making API evolution safe without breaking existing clients.
- 5Migration from Gson or Moshi can be gradual -- both converters coexist on the same Retrofit instance during transition.
- 6As a Kotlin Multiplatform library, your serialization models work unchanged on Android, iOS, desktop, and backend targets.
Frequently Asked
How do I handle API versioning?
Use @Deprecated for old fields. Add new fields with defaults. Implement custom serializers for version-specific behavior. Never remove fields without API version coordination.
Can I use multiple JSON formats?
Yes. Create multiple Json instances with different configurations. Use named configurations for different APIs. Share serializers across configurations.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.