✅ Introduction Link to heading
Data classes are one of Kotlin’s most beloved features — and for good reason. Yes, they cut out repetitive code. But more importantly, they encourage a style of programming that is safe, predictable, and simple to reason about. Used well, data classes help you avoid subtle bugs, protect shared state, and design cleaner systems.
This post breaks down how and where to use data classes effectively — and just as importantly, when not to.
🧱 What Is a Data Class? Link to heading
A Kotlin data class
is a class with a primary constructor whose purpose is to hold data. With one line of code, you get:
- Structural equality (
equals
andhashCode
) toString()
for better debuggingcopy()
for safe transformation- Destructuring support
data class User(val id: String, val name: String, val email: String)
This isn’t just about saving lines — it’s about encouraging the right defaults. When immutability, equality, and clarity come for free, you’re more likely to use them consistently.
You might recognize this as the so-called “anemic data model” — where classes hold data but don’t contain behavior. In traditional object-oriented design, this is sometimes considered an antipattern. But in Kotlin backend development, it’s a strength. Data classes should not act — other parts of your code should act on them. Keeping behavior out of your data model leads to greater predictability, easier testing, and stronger separation of concerns.
🧠 Immutability, Defaults, and Named Arguments Link to heading
A good rule of thumb: message and domain classes should always be data classes — and they should always be immutable.
data class SearchRequest(
val query: String = "",
val size: Int = 20
)
Defaults like these help avoid common runtime issues, especially when binding HTTP inputs or initializing domain logic. They also work hand-in-hand with Kotlin’s support for named arguments, which should be your default way of constructing data class instances. Named arguments improve readability and reduce errors — especially when arguments are added, reordered, or have default values. More importantly, they ensure that values are assigned to the correct fields. It’s surprisingly easy to swap similar-looking arguments like a username and an email, particularly in constructors with five or six parameters. These types of bugs might get caught in unit tests, but they’re entirely preventable with named arguments. You avoid the dreaded null
, ensure sane fallbacks, and make it easier to test.
And when you need to update a value?
val updated = original.copy(size = 50)
Don’t mutate the original — ever. Objects are frequently passed around as arguments, cached, or returned from other layers. You cannot trust that a reference is private. Using copy()
ensures safety and clarity.
Break this rule only with explicit intent. And even then — probably don’t.
🔁 Safer Transformation with copy()
Link to heading
The copy()
function lets you transform data without side effects. That’s powerful — because mutable state in backend code is where bugs breed.
fun promote(user: User): User = user.copy(role = "admin")
This protects upstream logic from unexpected changes and makes it easier to reason about object flow. It also makes unit testing more predictable by ensuring the inputs you pass in aren’t altered out from under you.
Immutability isn’t just a functional programming concept — it’s a reliability pattern. copy()
lets you express transformation without mutation, which greatly reduces the chance of unintended consequences.
🔄 Converters: The Missing Glue Link to heading
When you have data classes representing messages, commands, or transport models and need to translate them to domain objects, converters provide a natural, declarative bridge.
In Spring, you can register a Converter<S, T>
with a ConversionService
:
@Component
class SearchRequestConverter : Converter<SearchRequestMessage, SearchRequest> {
override fun convert(source: SearchRequestMessage): SearchRequest {
return SearchRequest(
query = source.query.trim(),
size = source.size.toIntOrNull() ?: 20
)
}
}
Then inject and use it:
val request: SearchRequest = conversionService.convert(message)
This pattern keeps each class focused on a single responsibility. Messages, domain objects, and entities should never reference each other directly. Keeping these layers isolated makes the code easier to refactor, test, and understand. Converters act as bridges between the layers, enforcing directionality and making transitions explicit.
🧪 Built-In equals
, hashCode
, and toString
Link to heading
Data classes shine in tests and logs. Instead of rolling your own equality logic:
assertEquals(expected, actual) // works out of the box
And for logging/debugging:
println(user) // prints User(id=123, name=Alice, email=alice@example.com)
When you’re diagnosing production bugs or reviewing logs in a pinch, a good toString()
is worth gold. Data classes provide it automatically.
These aren’t conveniences — they’re enablers of trust, observability, and quick debugging.
📦 Entity Classes: Use with Caution Link to heading
Data classes can work for JPA/Hibernate entities — but there’s a catch.
Because data classes generate equals
and hashCode
based on constructor parameters, this can break identity comparisons in ORMs that manage object graphs across sessions. Many developers override these methods manually in entity classes to avoid issues.
Learn more here: Kotlin and JPA: Best Practices for equals and hashCode
The rule of thumb:
- ✅ Use data classes for domain models, messages, and value objects.
- ⚠️ Use them for JPA entities only if you understand the implications and override
equals
/hashCode
appropriately.
🎯 Final Thoughts Link to heading
Kotlin data classes are one of the most effective tools you have for writing backend code that is safe, clear, and easy to maintain. The features they provide aren’t just syntactic conveniences — they enable practices that lead to more reliable software.
- Use them for messages and domain logic.
- Make them immutable.
- Prefer
copy()
over mutation. - Keep behavior out of your data classes. Let other layers act on them.
- Keep message, domain, and entity classes isolated. Use converters to connect them explicitly.
- Reach for the full
Converter
interface when working with Spring or large codebases. - Break the rules only when you need to — and even then, with caution.
Kotlin gives you the tools to write safer, simpler code. But it’s the discipline and clarity of your decisions that turn a good language into a great system.
If you haven’t leaned into data classes yet, now’s the time.