One of the most common questions Kotlin developers ask is: "Should this be a data class or a regular class?" The answer matters more than it seems — choosing wrong leads to subtle bugs, unnecessary boilerplate, or bloated model classes. This guide gives you a clear, practical framework for making the right choice every time.


A Quick Recap

Regular class — a general-purpose class. No auto-generated functions. You control everything.

class UserRepository(private val api: ApiService)

Data class — a class designed to hold data. Kotlin auto-generates toString(), equals(), hashCode(), copy(), and componentN() functions based on the constructor properties.

data class User(val name: String, val age: Int)

Both are valid Kotlin classes. The difference is intent and behavior.


What Data Class Gives You Automatically

Let's see exactly what you get for free with data class:

data class Article(
    val id: String,
    val title: String,
    val category: String,
    val isPublished: Boolean
)

val a1 = Article("1", "Kotlin Guide", "Kotlin", true)
val a2 = Article("1", "Kotlin Guide", "Kotlin", true)

// toString() — readable representation
println(a1)
// Article(id=1, title=Kotlin Guide, category=Kotlin, isPublished=true)

// equals() — content comparison
println(a1 == a2)    // true — same content
println(a1 === a2)   // false — different objects

// copy() — modified copy
val updated = a1.copy(isPublished = false)
println(updated)
// Article(id=1, title=Kotlin Guide, category=Kotlin, isPublished=false)

// componentN() — destructuring
val (id, title, category, isPublished) = a1
println("$id: $title")   // 1: Kotlin Guide

A regular class gives you none of this automatically — you get reference equality, a useless toString(), and no copy().

class Article(val id: String, val title: String)

val a1 = Article("1", "Kotlin Guide")
val a2 = Article("1", "Kotlin Guide")

println(a1 == a2)    // false — different objects (reference comparison)
println(a1)          // Article@1a2b3c4d — not useful
// no copy() available

The Core Decision: Data or Behavior?

The clearest way to decide:

Does this class primarily hold data, or primarily perform actions?

  • Holds datadata class
  • Performs actions → regular class
    // Holds data — use data class
    data class UserProfile(val name: String, val email: String, val age: Int)
    data class ApiResponse<T>(val data: T?, val error: String?, val code: Int)
    data class LoginUiState(val isLoading: Boolean, val error: String?)
    
    // Performs actions — use regular class
    class UserRepository(private val api: ApiService, private val db: UserDao)
    class LoginViewModel(private val authService: AuthService) : ViewModel()
    class ImageLoader(private val context: Context)
    class NotificationManager(private val context: Context)

Side by Side Comparison

Feature data class Regular class
toString() ✅ Auto-generated, readable ❌ Default: ClassName@hashcode
equals() ✅ Content comparison ❌ Reference comparison
hashCode() ✅ Consistent with equals ❌ Based on object identity
copy() ✅ Auto-generated ❌ Not available
Destructuring ✅ Auto componentN() ❌ Need manual componentN()
open / inheritable ❌ Cannot be open ✅ Can be open
Inheritance ✅ Can extend classes ✅ Can extend classes
Abstract ❌ Cannot be abstract ✅ Can be abstract
Mutable state ⚠️ Possible but discouraged ✅ Natural fit
Complex logic ⚠️ Should be minimal ✅ Natural fit

When to Use data class

1. Model classes (API responses, DB entities)

// API response model
data class ArticleResponse(
    val id: Int,
    val title: String,
    val content: String,
    val author: AuthorResponse,
    val publishedAt: String,
    val tags: List<String>
)

data class AuthorResponse(
    val id: Int,
    val name: String,
    val avatarUrl: String?
)

// Room database entity
@Entity(tableName = "articles")
data class ArticleEntity(
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "content") val content: String,
    @ColumnInfo(name = "category") val category: String,
    @ColumnInfo(name = "is_published") val isPublished: Boolean
)

2. UI state

data class ArticleListUiState(
    val isLoading: Boolean = false,
    val articles: List<Article> = emptyList(),
    val errorMessage: String? = null,
    val selectedCategory: String = "All",
    val hasMorePages: Boolean = true
)

// ViewModel uses copy() to update state immutably
_uiState.value = _uiState.value.copy(
    isLoading = false,
    articles = newArticles
)

3. Value objects and DTOs

data class Money(val amount: Double, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Currency mismatch" }
        return copy(amount = amount + other.amount)
    }
}

data class Coordinate(val lat: Double, val lng: Double)

data class DateRange(val start: LocalDate, val end: LocalDate) {
    val days: Long get() = ChronoUnit.DAYS.between(start, end)
}

4. Events and actions

// One-time UI events
sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
    data class Navigate(val route: String) : UiEvent()
    object NavigateBack : UiEvent()
}

// User actions
sealed class ArticleAction {
    data class Like(val articleId: String) : ArticleAction()
    data class Bookmark(val articleId: String) : ArticleAction()
    data class Share(val article: Article) : ArticleAction()
    object Refresh : ArticleAction()
}

When to Use Regular class

1. Repositories and data sources

class ArticleRepository(
    private val apiService: ApiService,
    private val articleDao: ArticleDao,
    private val preferencesManager: PreferencesManager
) {
    suspend fun getArticles(category: String): List<Article> { /* ... */ }
    suspend fun saveArticle(article: Article) { /* ... */ }
    fun searchArticles(query: String): Flow<List<Article>> { /* ... */ }
}

2. ViewModels

class ArticleViewModel(
    private val repository: ArticleRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ArticleListUiState())
    val uiState = _uiState.asStateFlow()

    fun loadArticles(category: String) {
        viewModelScope.launch { /* ... */ }
    }
}

3. Managers, helpers, services

class SessionManager(private val prefs: SharedPreferences) {
    fun saveToken(token: String) { /* ... */ }
    fun getToken(): String? { /* ... */ }
    fun clearSession() { /* ... */ }
}

class AnalyticsService(private val context: Context) {
    fun trackEvent(name: String, params: Map<String, Any> = emptyMap()) { /* ... */ }
    fun trackScreen(screenName: String) { /* ... */ }
}

4. Classes that need inheritance

// Base class — cannot be data class
open class BaseAdapter<T>(
    protected var items: List<T> = emptyList()
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    fun updateItems(newItems: List<T>) {
        items = newItems
        notifyDataSetChanged()
    }
}

class ArticleAdapter : BaseAdapter<Article>() {
    // extends BaseAdapter — fine because BaseAdapter is a regular class
}

5. Classes with significant mutable internal state

class DownloadManager {
    private val activeDownloads = mutableMapOf<String, DownloadJob>()
    private var totalBytesDownloaded = 0L
    private var isRunning = false

    fun startDownload(url: String) {
        isRunning = true
        activeDownloads[url] = DownloadJob(url)
    }

    fun cancelDownload(url: String) {
        activeDownloads.remove(url)
        if (activeDownloads.isEmpty()) isRunning = false
    }
}

The Gray Areas — Tricky Decisions

Classes with both data and behavior

Sometimes a class holds data AND has behavior. The question is — which is primary?

// More data than behavior — data class makes sense
data class User(
    val id: String,
    val name: String,
    val email: String
) {
    val initials: String get() = name.split(" ")
        .mapNotNull { it.firstOrNull() }
        .joinToString("")
        .uppercase()

    fun isValidEmail() = email.contains("@") && email.contains(".")
}

// More behavior than data — regular class makes sense
class UserValidator(private val existingEmails: Set<String>) {
    fun validate(user: User): ValidationResult { /* ... */ }
    fun checkEmailUnique(email: String): Boolean { /* ... */ }
    fun checkPasswordStrength(password: String): PasswordStrength { /* ... */ }
}

Configuration objects

// These are borderline — both work, but data class gives you equals() for free
// which is useful for detecting settings changes

data class NetworkConfig(
    val baseUrl: String = "https://api.example.com",
    val timeout: Long = 30,
    val maxRetries: Int = 3,
    val useSSL: Boolean = true
)

// Can detect config changes easily
if (currentConfig != newConfig) {
    rebuildNetworkClient(newConfig)
}

Common Mistakes

Mistake 1: Making everything a data class

// ❌ Wrong — ViewModel is not data, it has lifecycle and behavior
data class ArticleViewModel(val repository: ArticleRepository) : ViewModel()

// ✅ Regular class
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel()

Mistake 2: Using var in data class unnecessarily

// ❌ Mutable data class — loses immutability benefits
data class User(var name: String, var email: String)

// External code can do:
user.name = "Hacker"   // breaks predictability

// ✅ Immutable — use copy() to "change" values
data class User(val name: String, val email: String)
val updated = user.copy(name = "New Name")

Mistake 3: Putting business logic in data classes

// ❌ Data class doing too much
data class Order(val items: List<Item>, val userId: String) {
    fun processPayment(card: CreditCard) { /* ... */ }
    fun sendConfirmationEmail() { /* ... */ }
    fun updateInventory() { /* ... */ }
}

// ✅ Data class stays simple; logic goes in a service/use case
data class Order(val items: List<Item>, val userId: String)

class OrderProcessor(
    private val paymentService: PaymentService,
    private val emailService: EmailService
) {
    fun process(order: Order, card: CreditCard) { /* ... */ }
}

Mistake 4: Expecting data class to work with inheritance

// ❌ data class cannot be open (inherited from)
open data class Base(val x: Int)  // compile error

// ✅ Use sealed class or regular class for inheritable types
sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()

Mistake 5: Ignoring that body properties are excluded from equals()

data class Product(val id: Int, val name: String) {
    var isInCart: Boolean = false   // NOT part of equals/hashCode/copy
}

val p1 = Product(1, "Phone")
val p2 = Product(1, "Phone")
p1.isInCart = true

println(p1 == p2)   // true — isInCart is ignored!
// This might be surprising — only constructor properties are compared

Quick Decision Checklist

Ask yourself these questions:

 Does it hold data (properties) as its primary purpose?
  └─ Yes → lean toward data class

Does it need to be extended (open/abstract)?
  └─ Yes → must be regular class

Does it have significant mutable internal state (not just UI state)?
  └─ Yes → lean toward regular class

Does it perform actions (network calls, DB writes, business logic)?
  └─ Yes → regular class

Do you need equals() to compare by content?
  └─ Yes → data class

Do you need copy() to create modified versions?
  └─ Yes → data class (especially for UI state)

Is it an Android component (ViewModel, Fragment, Activity, Adapter)?
  └─ Yes → always regular class
 
 

Summary

Use data class for Use regular class for
API response models Repositories
Database entities ViewModels
UI state objects Managers, helpers, services
Events and actions Adapters and UI components
Value objects Classes needing inheritance
DTOs and transfer objects Classes with heavy mutable state
  • data class auto-generates toString(), equals(), hashCode(), copy(), and componentN()
  • equals() in data class compares content — in regular class it compares reference
  • Only constructor properties are included in generated functions — body properties are excluded
  • Data classes cannot be open, abstract, sealed, or inner
  • Prefer val over var in data classes — use copy() to produce modified versions
  • Keep data classes simple — minimal behavior, no business logic, no side effects
  • When in doubt: if it maps to a JSON object, database row, or UI state — it's a data class

Choosing between data class and regular class is one of the first design decisions you make when adding a new type to your app. The clearer your intent, the better your architecture.

Happy coding!