wandres.dev
ROOM · persistencia relacional

Qué añade Room sobre SQLite: entidades, DAOs y base de datos

Room no sustituye a SQLite: lo envuelve con un procesador de anotaciones que lee tu SQL y lo valida antes de que exista un dispositivo. Entidad, DAO y clase de base de datos forman un triángulo cuyo cuarto vértice, invisible, es el compilador.

⏱ 18 min

Android lleva SQLite dentro desde su primera versión, y durante casi una década programar contra él consistió en construir cadenas de texto y esperar lo mejor. Room no sustituye ese motor: lo envuelve. Lo que aporta no es velocidad ni funciones nuevas de SQL, sino algo bastante más valioso: desplaza al tiempo de compilación una clase entera de errores que antes solo se manifestaban con la app ya instalada. Entender Room es entender que su contribución central no es una API más cómoda, sino un procesador de anotaciones que lee tu SQL y tiene autoridad para decirte que no.

🎯 Al terminar esta lección sabrás
  • Situar con precisión qué capa ocupa Room sobre SQLite y qué sigue igual debajo.
  • Declarar una entidad con @Entity, con su clave primaria, columnas e índices.
  • Definir un DAO con @Dao y la clase de base de datos con @Database.
  • Entender la verificación de consultas en compilación y el esquema exportado.

Lo que hay debajo: SQLite y el coste de la cadena cruda

SQLite es una biblioteca en C, embebida en el proceso de tu app, que implementa un motor relacional transaccional completo sobre un único fichero. No hay servidor, no hay red, no hay proceso separado: una llamada a SQLite es una llamada a función. Room no cambia nada de esto. El fichero sigue siendo un fichero SQLite, las consultas siguen siendo SQL y el planificador de consultas sigue siendo el de SQLite.

Lo que Room elimina es la API de acceso que Android exponía antes: SQLiteOpenHelper, Cursor, ContentValues. Aquella API tenía tres defectos estructurales. Primero, el SQL vivía en cadenas de texto que ningún compilador miraba. Segundo, la traducción entre filas y objetos se escribía a mano, columna por columna, buscando índices con getColumnIndex. Tercero, el Cursor era un recurso que había que cerrar, y olvidarlo filtraba memoria de forma silenciosa.

// El mundo anterior: nada de esto se verifica hasta que se ejecuta.
val cursor = db.rawQuery(
    "SELECT id, titulo FROM notas WHERE titulo LIKE ?",
    arrayOf("%$filtro%")
)
val notas = mutableListOf<Nota>()
cursor.use {
    while (it.moveToNext()) {
        notas += Nota(
            id = it.getLong(it.getColumnIndexOrThrow("id")),
            titulo = it.getString(it.getColumnIndexOrThrow("titulo")),
        )
    }
}

Un error tipográfico en notas, una columna renombrada, un tipo que no encaja: todo eso compila sin una queja y revienta en producción. Room ataca exactamente ese hueco.

El triángulo: entidad, DAO y base de datos

Room se articula sobre tres anotaciones que se corresponden con tres conceptos relacionales. Una entidad es una tabla. Un DAO es el conjunto de operaciones permitidas sobre ella. La base de datos es el contenedor que las une y gestiona la conexión.

🗄️

@Entity — la tabla

Una data class anotada se convierte en una tabla. Cada propiedad es una columna, con su tipo SQLite inferido. Necesita al menos una @PrimaryKey.

🔌

@Dao — el contrato

Una interfaz cuyos métodos declaran operaciones. Room genera la implementación: el SQL, el enlace de parámetros y el mapeo de filas a objetos.

🏛️

@Database — el contenedor

Una clase abstracta que enumera las entidades, fija la versión del esquema y expone los DAOs. Es la que abre el fichero.

@Entity(
    tableName = "notas",
    indices = [Index(value = ["titulo"])]
)
data class Nota(
    @PrimaryKey(autoGenerate = true) val id: Long = 0L,
    @ColumnInfo(name = "titulo") val titulo: String,
    @ColumnInfo(name = "cuerpo", defaultValue = "") val cuerpo: String = "",
    @ColumnInfo(name = "creada_en") val creadaEn: Long,
)

@Dao
interface NotaDao {
    @Insert
    suspend fun insertar(nota: Nota): Long

    @Update
    suspend fun actualizar(nota: Nota)

    @Delete
    suspend fun borrar(nota: Nota)

    @Query("SELECT * FROM notas ORDER BY creada_en DESC")
    fun observarTodas(): Flow<List<Nota>>
}

@Database(entities = [Nota::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun notaDao(): NotaDao
}

El detalle a subrayar es que NotaDao es una interfaz sin cuerpo. Room genera en compilación una clase NotaDao_Impl con el SQL literal, el enlace de parámetros por posición y el mapeo columna a propiedad. Ese código generado es el que se ejecuta, y puedes leerlo: vive bajo el directorio de salida de KSP.

💡
KSP y no kapt

Desde hace varias versiones, Room se procesa con KSP en lugar de con kapt. KSP entiende Kotlin de forma nativa en vez de generar stubs de Java, y el procesado es notablemente más rápido. Si heredas un proyecto con kapt, migrarlo a KSP suele ser un cambio de dos líneas en el fichero de build y una mejora inmediata en los tiempos de compilación incremental.

La verificación en compilación: el rasgo que lo justifica todo

Aquí está la razón real de existir de Room. Durante la compilación, el procesador abre una base de datos SQLite en memoria, crea en ella el esquema que deducen tus @Entity, y le pasa cada cadena de tus @Query para que SQLite misma las prepare. Si el motor rechaza la consulta, tu build falla.

flowchart TD
A[Codigo con anotaciones de Room] --> B[KSP construye el esquema en memoria]
B --> C{SQLite prepara cada consulta}
C -->|Rechaza| D[Error de compilacion]
C -->|Acepta| E{Las columnas encajan con el tipo de retorno}
E -->|No| F[Error o aviso de campo no usado]
E -->|Si| G[Genera las implementaciones de DAO y base de datos]
G --> H[SQLite real en el dispositivo]
style D fill:#f38ba8,color:#11111b
style F fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b

Esto significa que una tabla mal escrita, una columna inexistente, un JOIN sintácticamente inválido o un SELECT que devuelve columnas que no encajan con el tipo de retorno son errores de build, no fallos en tiempo de ejecución. Room va más lejos y emite un aviso cuando la consulta devuelve columnas que tu objeto no consume, o cuando tu objeto tiene campos que la consulta no rellena: el clásico SELECT con columnas de sobra deja de ser invisible.

⚠️
La verificación cubre el SQL, no tu intención

Que la consulta compile no significa que sea correcta. Room valida sintaxis, existencia de tablas y columnas, y correspondencia de tipos; no valida que tu WHERE exprese la regla de negocio que tenías en la cabeza, ni que la consulta use un índice. Para lo primero necesitas tests con una base en memoria; para lo segundo, EXPLAIN QUERY PLAN.

El objeto de base de datos: coste de apertura y esquema en disco

Construir una RoomDatabase no es barato: abre el fichero, verifica el hash del esquema y, si procede, ejecuta migraciones. Por eso debe existir una sola instancia por proceso, viva durante toda la vida de la app, normalmente provista por un contenedor de inyección de dependencias o por un singleton perezoso.

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .build()

Con exportSchema = true —el valor recomendado— Room escribe en el directorio de esquemas un fichero JSON por versión: la descripción canónica de tablas, columnas, índices y su hash de identidad. Ese JSON debe ir al control de versiones, porque es la única fuente fiable para escribir y verificar migraciones. Room guarda además ese hash dentro del propio fichero de base de datos; si al abrirla el hash almacenado no coincide con el que el código espera, la apertura falla en lugar de operar sobre un esquema desconocido.

Room no es un ORM: es un compilador de SQL a Kotlin

La tentación es clasificar a Room junto a Hibernate o Core Data y llamarlo ORM, pero esa etiqueta oculta lo que lo hace distinto. Un ORM clásico aspira a que el desarrollador olvide que existe una base relacional: sustituye el SQL por un lenguaje de objetos, mantiene un grafo de entidades en memoria, decide por su cuenta cuándo materializar una relación y cuándo escribir, e introduce un modelo de identidad y un caché de primer nivel que son un universo conceptual paralelo. Room hace deliberadamente lo contrario. No oculta el SQL: te obliga a escribirlo. No mantiene un grafo vivo de objetos ni un caché de sesión: cada consulta va al motor y devuelve datos inmutables. No inventa un lenguaje de consulta propio: usa el de SQLite, con todas sus peculiaridades y toda su potencia. Lo que aporta no está en el eje “menos SQL”, sino en un eje ortogonal: el mismo SQL, pero verificado. Por eso el hallazgo de diseño más elegante de Room es también el más obvio en retrospectiva: para saber si una consulta es válida, no hay que reimplementar un parser de SQL en el procesador de anotaciones —siempre incompleto, siempre desalineado con el motor real—, basta con levantar SQLite en el propio proceso del compilador y preguntárselo a ella. La verdad sobre el SQL la tiene el motor, así que el compilador va y le pregunta. Esa decisión convierte el desfase entre lo que escribes y lo que la base entiende, la fuente histórica número uno de crashes de persistencia en Android, en un error de build. Room no te ahorra aprender SQL. Te ahorra aprenderlo a base de informes de fallos.

📝
Lo esencial del nivel

Room es una capa de generación de código sobre SQLite, no un motor nuevo. Tres anotaciones lo estructuran: @Entity describe la tabla, @Dao declara las operaciones y @Database reúne entidades, versión y DAOs. El procesador levanta SQLite en compilación para validar cada @Query y genera el mapeo de filas a objetos. La instancia de base de datos es cara: una por proceso. Y exportSchema produce el JSON versionado sin el cual las migraciones son adivinación.

⚔️ Del cursor crudo al esquema verificado
  1. Crea una entidad Tarea con clave primaria autogenerada, un titulo, un hecha booleano y una marca temporal, y añade un índice sobre el campo por el que vayas a filtrar.
  2. Escribe un DAO con inserción, borrado y una consulta que devuelva solo las tareas pendientes ordenadas por fecha.
  3. Introduce a propósito un error tipográfico en el nombre de una columna dentro de la @Query y comprueba que el build falla; lee el mensaje del procesador con atención.
  4. Localiza el fichero generado TareaDao_Impl en la salida de KSP y contrasta su mapeo de columnas con el código que habrías escrito a mano con un Cursor.
  5. Activa exportSchema, compila y abre el JSON de la versión 1: identifica el hash de identidad y razona qué ocurriría al abrir esa base con un código que espera otro esquema.