Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 005 / 100

Kotlin data classes: a practical Android & Kotlin guide

Models without boilerplate.

Expona Admin8 min read

Why data classes matter in Android development

When we're building Android apps, we spend a surprising amount of time writing model classes. A typical API response model or UI state holder needs equals(), hashCode(), toString(), and often a way to create modified copies. Kotlin data classes solve this problem elegantly—they generate all this boilerplate for us, letting us focus on actual business logic. This practical guide to Kotlin data classes: a practical Android & Kotlin guide will show you how to use them effectively in production apps.

The generated methods: equals, hashCode, toString, and copy

Mark a class with the data keyword and Kotlin automatically generates four essential methods based on the constructor parameters. Let's start with a concrete example from an e-commerce app.

kotlin
data class Product(
    val id: Long,
    val name: String,
    val price: Double,
    val inStock: Boolean
)

With that one keyword, we get:

  • equals() — compares all constructor parameters, so two Product instances are equal if all fields match
  • hashCode() — generates a hash based on all parameters, making data classes safe for use in sets and as map keys
  • toString() — produces a readable string like Product(id=42, name=Widget, price=9.99, inStock=true)
  • copy() — creates a shallow copy with optional field overrides

Without data classes, you'd write dozens of lines of boilerplate for each model. Compare the generated implementation to what you'd normally write:

kotlin
// Without data class (what we avoid)
class ProductManual(
    val id: Long,
    val name: String,
    val price: Double,
    val inStock: Boolean
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ProductManual) return false
        return id == other.id && name == other.name && 
               price == other.price && inStock == other.inStock
    }

    override fun hashCode(): Int {
        return Objects.hash(id, name, price, inStock)
    }

    override fun toString(): String {
        return "ProductManual(id=$id, name='$name', price=$price, inStock=$inStock)"
    }
}

The data class version is far more maintainable. When you add a field, the generated methods update automatically—you can't accidentally forget to update equals() or hashCode().

Perfect for UI state and API models

In modern Android architecture, data classes shine in two specific roles: representing UI state and deserializing API responses.

UI state models

Whether you're using MVI, MVVM, or Redux-like patterns, UI state should be immutable and easily comparable. Consider a login screen:

kotlin
data class LoginState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isLoggedIn: Boolean = false
)

Your ViewModel or state management layer can emit new state objects. Composable functions can efficiently compare old and new state:

kotlin
class LoginViewModel : ViewModel() {
    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow = _state.asStateFlow()

    fun updateEmail(newEmail: String) {
        _state.value = _state.value.copy(email = newEmail)
    }

    fun attemptLogin() {
        _state.value = _state.value.copy(isLoading = true)
        // API call, then:
        _state.value = _state.value.copy(
            isLoading = false,
            isLoggedIn = true
        )
    }
}

The copy() method ensures we create a new instance for state updates, which is crucial for reactive frameworks to detect changes.

API response models

When deserializing JSON with libraries like Kotlinx Serialization or Gson, data classes are the natural fit:

kotlin
@Serializable
data class UserResponse(
    @SerialName("user_id")
    val userId: Long,
    val username: String,
    val email: String,
    @SerialName("created_at")
    val createdAt: String,
    val avatar: String? = null
)

The library automatically handles all field mapping and your models stay clean and readable. If your API adds a new optional field, just add it with a default value—no constructor changes needed.

Use val for immutability

Always declare data class properties with val, never var. Immutable objects are predictable, thread-safe, and easier to reason about in concurrent environments like Android's multithreaded architecture.

kotlin
// Good: immutable
data class Order(
    val orderId: String,
    val items: List,
    val total: Double,
    val status: OrderStatus
)

// Risky: mutable fields undermine the benefits
data class OrderMutable(
    var orderId: String,
    var items: List,
    var total: Double,
    var status: OrderStatus
)

With mutable fields, equals() and hashCode() become unreliable because their results can change after creation. If an object is a map key and you mutate it, you've corrupted the map. In a multi-threaded app, mutations create race conditions.

If you need to represent state that changes, use immutable data classes and emit new instances. This pattern scales from single ViewModels to full Redux-style architectures.

Using copy() for updated versions

The copy() method is where data classes shine in practice. Instead of modifying an existing object, you create a new one with selective changes. This is central to functional programming patterns on Android.

kotlin
data class CartItem(
    val productId: Long,
    val name: String,
    val price: Double,
    val quantity: Int
)

// When user increases quantity
val originalItem = CartItem(
    productId = 1,
    name = "Widget",
    price = 9.99,
    quantity = 2
)

val updatedItem = originalItem.copy(quantity = 3)

In a shopping cart flow, this becomes powerful:

kotlin
data class ShoppingCart(
    val items: List = emptyList(),
    val subtotal: Double = 0.0,
    val taxRate: Double = 0.1
) {
    fun updateItemQuantity(productId: Long, newQuantity: Int): ShoppingCart {
        val updatedItems = items.map { item ->
            if (item.productId == productId) {
                item.copy(quantity = newQuantity)
            } else {
                item
            }
        }
        val newSubtotal = updatedItems.sumOf { it.price * it.quantity }
        return this.copy(
            items = updatedItems,
            subtotal = newSubtotal
        )
    }
}

Notice how copy() creates new lists, new cart instances—everything is immutable. Any old reference to the original cart is safe; the new copy exists independently. This is exactly what reactive state management requires.

Common mistakes

Mixing var and val

Avoid mutable properties. They break the contract that data classes provide and can lead to subtle bugs:

kotlin
// Problematic
data class User(
    val id: Long,
    var name: String,  // ❌ mutable
    val email: String
)

// Better
data class User(
    val id: Long,
    val name: String,
    val email: String
)

Including non-constructor properties in generated methods

Only constructor parameters are used in equals(), hashCode(), and copy(). Any property declared outside the constructor is ignored:

kotlin
data class Article(
    val id: Long,
    val title: String,
    val content: String
) {
    val wordCount: Int = content.split("\\s+".toRegex()).size
    // wordCount is NOT compared in equals()
    // Two articles with same id, title, content but different wordCount are equal
}

If you need a property to affect equality, put it in the constructor. For computed or cached values, either accept that they won't participate in equality or move them outside the data class.

Overriding generated methods

Overriding equals() or hashCode() in a data class usually means you should reconsider your design:

kotlin
// Avoid this—defeats the purpose of data class
data class Product(
    val id: Long,
    val name: String
) {
    override fun equals(other: Any?): Boolean {
        // Custom logic
        return true
    }
}

If you need custom equality logic, either use a regular class or question whether immutability and structural equality fit your use case.

Forgetting that copy() is shallow

copy() creates a shallow copy—nested objects are not deep-cloned. This usually isn't a problem because you're using immutable collections and data classes all the way down, but it's worth knowing:

kotlin
data class Container(
    val id: Long,
    val items: MutableList
)

val c1 = Container(1, mutableListOf("a", "b"))
val c2 = c1.copy()
c2.items.add("c")
println(c1.items)  // ["a", "b", "c"] — modified!

Use immutable collections—List instead of MutableList—and this concern disappears entirely.

Key takeaways

  • Data classes eliminate boilerplate by auto-generating equals(), hashCode(), toString(), and copy() from constructor parameters
  • Always use val for all properties to maintain immutability and thread safety
  • Use copy() to create modified instances—this pattern is central to reactive state management
  • Data classes are ideal for UI state holders and API response models
  • Only constructor parameters are included in generated methods; additional properties have no impact on equality or copying
  • Avoid mutating data class instances; instead, create new instances with copy()
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles