Mapeo de Filas a Data Classes Inmutables
Convierte filas crudas en objetos de dominio confiables con factories fromRow en companion object, lecturas de columnas null-safe y copy() para actualizaciones derivadas.
La frontera entre tu base de datos y tu dominio es donde nacen la mayoria de los bugs de datos. Un `ResultSet`, un `Row` de Spring o un `Document` de MongoDB es una bolsa de valores debilmente tipada: cada columna puede ser nula, cada tipo es una conjetura en tiempo de ejecucion y nada impide que leas la clave equivocada. El idioma de Kotlin consiste en convertir esa estructura mutable y no confiable en una `data class` inmutable exactamente una vez, en una costura unica y bien definida. Una vez que una fila se convierte en `User`, el resto de tu codigo puede confiar en sus tipos y nulabilidad sin volver a tocar el driver.
Coloca esa conversion en una factory de `companion object` llamada `fromRow`. Un companion object es un singleton ligado a la clase, asi que `User.fromRow(row)` se lee como un constructor con nombre mientras mantiene la logica de mapeo junto a los campos que rellena. Esto supera a una funcion top-level libre porque es descubrible a traves del tipo, supera a un constructor secundario porque mapear es una transformacion (no realmente 'construir'), y mantiene la declaracion de la `data class` limpia y declarativa. Agrupa el mapeo con la forma que produce y quien lea despues encontrara todo en un solo lugar.
La nulabilidad es el corazon del mapeo correcto, y Kotlin hace explicita en tus tipos la verdad de la base de datos. Una columna declarada `NOT NULL` deberia mapear a una propiedad Kotlin no nula, y una columna nulable a un `T?`. El peligro es que la mayoria de los accesores de los drivers mienten: `getLong("age")` de JDBC devuelve `0` para un NULL de SQL en vez de null, asi que debes verificar `wasNull()` o, mejor, leer mediante un accesor consciente de nulos. El `Readable.get(name, Type::class.java)` de Spring R2DBC (el `Row` que mapeas es un `Readable`) devuelve una referencia genuinamente nulable, que combina de forma natural con Kotlin. Usa `requireNotNull(row.get("id", ...)) { "id was null" }` para columnas que tu esquema garantiza, de modo que una suposicion rota falle ruidosamente en la frontera y no como un NPE misterioso tres capas mas abajo.
La anotacion `@JvmStatic` importa en el momento en que un framework Java o una herramienta reflexiva necesita llamar a tu factory. Por defecto, una funcion de companion compila a un metodo de instancia sobre un objeto sintetico `Companion`, asi que los llamadores Java deben escribir `User.Companion.fromRow(row)`. Anotar la funcion con `@JvmStatic` emite un metodo estatico real sobre `User` mismo, permitiendo que Java escriba el limpio `User.fromRow(row)` y que las librerias que buscan factories estaticas (algunos serializadores, mappers y referencias de metodo Java como `User::fromRow`) la descubran. Desde Kotlin puro no cambia nada; es puramente una comodidad de interoperabilidad y tooling, asi que agregala cuando tu factory pueda ser alcanzada desde el ecosistema JVM mas alla de Kotlin.
Una vez que las filas son inmutables, las actualizaciones se vuelven transformaciones en lugar de mutaciones, y ahi es donde `copy()` brilla. Cada `data class` recibe un `copy()` generado que produce una nueva instancia con campos seleccionados cambiados y el resto trasladado. Calcular un registro derivado, como marcar un usuario como verificado o recalcular un total, es una expresion pura: `user.copy(verified = true)`. Esto mantiene el original intacto (seguro para caches, lecturas concurrentes y logs de eventos), hace autodescriptivo el sitio del cambio y compone limpiamente porque cada paso devuelve un valor fresco que puedes pasar o persistir.
Se deliberado con la trampa famosa de `copy()`: realiza una copia superficial. Las colecciones referenciadas y los objetos mutables anidados se comparten entre el original y la copia, asi que `user.copy()` y `user` apuntan a la mismisima lista `roles`. La solucion es hacer inmutable todo el grafo. Usa `List`, `Set` y `Map` (las interfaces de solo lectura) para las propiedades, construyelas con `listOf`/`persistentListOf` y deja que los tipos anidados tambien sean `data class`es. Entonces una copia superficial es efectivamente una copia profunda porque nada debajo puede cambiar, y el sharing estructural la mantiene barata.
El mapeo brilla en la persistencia basada en coroutines. Con Spring Data R2DBC y `kotlinx.coroutines`, los metodos de repositorio son funciones `suspend` o funciones que devuelven `Flow<User>`, y tu `fromRow` encaja directamente en el puente de reactivo a suspend. Una consulta de `DatabaseClient` se vuelve `client.sql("select * from users where active = true").map(User::fromRow).flow()`, donde `User::fromRow` es una referencia de metodo directa a tu factory de companion. El mapeo permanece sincrono y puro, la suspension y el backpressure viven en el framework, y nunca bloqueas un hilo mientras las filas fluyen.
Unos pocos habitos mantienen limpia la costura. Manten `fromRow` total y sin efectos secundarios: nada de logging, nada de I/O, solo de columna a campo. Valida tambien las invariantes alli, de modo que un objeto que existe sea siempre valido (una fila ilegal lanza en vez de producir un registro a medio construir). Prefiere el acceso por nombre de columna sobre los indices posicionales, que se rompen en silencio cuando alguien reordena un `SELECT`. Y cuando una tabla alimenta varias vistas, escribe varias factories pequenas (`fromRow`, `summaryFromRow`) en lugar de una factory con un monton de campos opcionales nulables. El resultado es un modelo de dominio donde, pasado el repositorio, la nulabilidad y los tipos simplemente dicen la verdad.
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🧠Comprueba tu comprensión
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?