Mapping Rows to Immutable Data Classes
Turn raw rows into trustworthy domain objects with companion fromRow factories, null-safe column reads, and copy() for derived updates.
The boundary between your database and your domain is where most data bugs are born. A `ResultSet`, a Spring `Row`, or a MongoDB `Document` is a loosely typed bag of values: every column is potentially null, every type is a runtime guess, and nothing stops you from reading the wrong key. The Kotlin idiom is to convert that untrusted, mutable structure into an immutable `data class` exactly once, at a single well-defined seam. Once a row becomes a `User`, the rest of your codebase can trust its types and nullability without ever touching the driver again.
Put that conversion in a `companion object` factory named `fromRow`. A companion object is a singleton tied to the class, so `User.fromRow(row)` reads like a named constructor while keeping the mapping logic next to the fields it populates. This beats a free-standing top-level function because it is discoverable through the type, beats a secondary constructor because mapping is a transformation (not really 'construction'), and keeps the `data class` declaration itself clean and declarative. Group the mapping with the shape it produces and future readers find everything in one place.
Nullability is the heart of correct mapping, and Kotlin makes the database's truth explicit in your types. A column declared `NOT NULL` should map to a non-null Kotlin property, and a nullable column should map to a `T?`. The danger is that most driver accessors lie: JDBC's `getLong("age")` returns `0` for SQL NULL instead of null, so you must check `wasNull()` or, better, read through a null-aware accessor. Spring R2DBC's `Readable.get(name, Type::class.java)` (the `Row` you map is a `Readable`) returns a genuine nullable reference, which pairs naturally with Kotlin. Use `requireNotNull(row.get("id", ...)) { "id was null" }` for columns your schema guarantees, so a broken assumption fails loudly at the boundary rather than as a mysterious NPE three layers deep.
The `@JvmStatic` annotation matters the moment a Java framework or reflective tool needs to call your factory. By default, a companion function compiles to an instance method on a synthetic `Companion` object, so Java callers must write `User.Companion.fromRow(row)`. Annotating the function with `@JvmStatic` emits a real static method on `User` itself, letting Java write the clean `User.fromRow(row)` and letting libraries that scan for static factory methods (some serializers, mappers, and Java method references like `User::fromRow`) discover it. From pure Kotlin it changes nothing; it is purely an interop and tooling affordance, so add it when your factory might be reached from the JVM ecosystem beyond Kotlin.
Once rows are immutable, updates become transformations rather than mutations, and that is where `copy()` shines. Every `data class` gets a generated `copy()` that produces a new instance with selected fields changed and the rest carried over. Computing a derived record, such as marking a user verified or recalculating a total, is a pure expression: `user.copy(verified = true)`. This keeps the original untouched (safe for caches, concurrent reads, and event logs), makes the change site self-documenting, and composes cleanly because each step returns a fresh value you can pass along or persist.
Be deliberate about `copy()`'s one famous trap: it performs a shallow copy. Referenced collections and nested mutable objects are shared between the original and the copy, so `user.copy()` and `user` point at the very same `roles` list. The fix is to make the whole graph immutable. Use `List`, `Set`, and `Map` (the read-only interfaces) for properties, build them with `listOf`/`persistentListOf`, and let nested types be `data class`es too. Then a shallow copy is effectively a deep copy because nothing underneath can change, and structural sharing keeps it cheap.
Mapping shines in coroutine-based persistence. With Spring Data R2DBC and `kotlinx.coroutines`, repository methods are `suspend` functions or functions returning `Flow<User>`, and your `fromRow` slots directly into the reactive-to-suspend bridge. A `DatabaseClient` query becomes `client.sql("select * from users where active = true").map(User::fromRow).flow()`, where `User::fromRow` is a method reference straight to your companion factory. The mapping stays synchronous and pure, the suspension and backpressure live in the framework, and you never block a thread while rows stream in.
A few habits keep the seam clean. Keep `fromRow` total and side-effect free: no logging, no I/O, just column to field. Validate invariants there too, so an object that exists is always valid (an illegal row throws instead of producing a half-built record). Prefer named column access over positional indices, which silently break when someone reorders a `SELECT`. And when one table feeds several views, write several small factories (`fromRow`, `summaryFromRow`) rather than one factory with a pile of nullable optional fields. The result is a domain model where, past the repository, nullability and types simply tell the truth.
import io.r2dbc.spi.Readableimport java.time.Instantdata class User(val id: Long,val email: String,val displayName: String?, // nullable columnval verified: Boolean,val createdAt: Instant,) {companion object {@JvmStaticfun fromRow(row: Readable): User = User(id = requireNotNull(row.get("id", Long::class.javaObjectType)) {"id was null"},email = requireNotNull(row.get("email", String::class.java)) {"email was null"},displayName = row.get("display_name", String::class.java), // may be nullverified = row.get("verified", Boolean::class.javaObjectType) ?: false,createdAt = requireNotNull(row.get("created_at", Instant::class.java)) {"created_at was null"},)}}
An R2DBC companion factory taking io.r2dbc.spi.Readable (the supertype of Row), so User::fromRow works as a method reference in DatabaseClient.map. NOT NULL columns become non-null via requireNotNull, the nullable column stays T?, and @JvmStatic exposes a clean static method for JVM/method-reference callers.
import kotlinx.coroutines.flow.Flowimport org.springframework.r2dbc.core.DatabaseClientimport org.springframework.r2dbc.core.awaitOneOrNullimport org.springframework.r2dbc.core.flowimport org.springframework.stereotype.Repository@Repositoryclass UserRepository(private val client: DatabaseClient) {fun findActive(): Flow<User> =client.sql("select * from users where verified = true").map(User::fromRow) // method reference to the companion factory.flow()suspend fun findById(id: Long): User? =client.sql("select * from users where id = :id").bind("id", id).map(User::fromRow).awaitOneOrNull()}
Wiring the factory into a coroutine repository: User::fromRow is a method reference into DatabaseClient.map, flow() streams many rows, and awaitOneOrNull() suspends for a single optional result, all without blocking a thread.
data class User(val id: Long,val email: String,val verified: Boolean = false,val roles: List<String> = emptyList(),)fun main() {val original = User(id = 1, email = "ada@calc.io")val verified = original.copy(verified = true)val promoted = verified.copy(roles = verified.roles + "admin")println(original.verified) // false (untouched)println(promoted.verified) // trueprintln(promoted.roles) // [admin]// Shallow copy is safe here because roles is a read-only List.}
copy() for derived updates is pure stdlib and fully runnable. The original is never mutated; each transformation returns a fresh instance.
Arena IDE🧠Check your understanding
0/1 · 0/1 answered1. You read an integer column with JDBC's `rs.getInt("age")` and map it straight to a non-null `Int` property. The column is nullable and a row has SQL NULL. What happens?