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.
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
Productinstances 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:
// 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:
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:
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:
@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.
// 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.
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:
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:
// 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:
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:
// 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:
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(), andcopy()from constructor parameters - Always use
valfor 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()






Comments
Loading comments…