Saltar a contenido

9.4.-CRUD, DAO y buenas prácticas

9.4. CRUD, DAO y buenas prácticas

Resumen

En este punto unimos las piezas anteriores para construir aplicaciones de gestión de información. Veremos operaciones CRUD completas, manejo de errores, transacciones y separación de responsabilidades mediante el patrón DAO.

Ya sabemos por qué una aplicación necesita una base de datos, cómo abrir una conexión JDBC y cómo ejecutar sentencias con PreparedStatement. Ahora falta organizar ese código para que no quede repartido por toda la aplicación.

Código Descripción
RA9 Gestiona información almacenada en bases de datos manteniendo la integridad y consistencia de los datos.
CE c Se ha escrito código para almacenar información en bases de datos.
CE d Se han creado programas para recuperar y mostrar información almacenada en bases de datos.
CE e Se han efectuado borrados y modificaciones sobre la información almacenada.
CE f Se han creado aplicaciones que muestren la información almacenada en bases de datos.
CE g Se han creado aplicaciones para gestionar la información presente en bases de datos.

1. Qué significa CRUD

Una aplicación de gestión de información debe permitir que la persona usuaria realice operaciones sobre los datos de forma clara y segura. El caso más habitual es un CRUD:

Operación SQL habitual Objetivo
Create INSERT Crear registros.
Read SELECT Leer o consultar registros.
Update UPDATE Modificar registros existentes.
Delete DELETE Eliminar registros.

Un ejemplo típico sería una aplicación de inventario. La interfaz permitiría crear productos, consultar existencias, modificar precios, eliminar productos descatalogados y buscar productos por nombre, categoría o precio.

Por debajo, cada acción de la interfaz se traduce en operaciones SQL ejecutadas desde una capa de acceso a datos.

2. Tabla y modelo de ejemplo

Usaremos una tabla clientes y una clase Cliente:

CREATE TABLE clientes (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) UNIQUE NOT NULL
);
data class Cliente(
    val id: Int,
    val nombre: String,
    val email: String
)

Para crear un cliente nuevo todavía no conocemos su id, porque lo genera la base de datos. Por eso puede ser útil separar el modelo de creación:

data class NuevoCliente(
    val nombre: String,
    val email: String
)

Lectura del ejemplo:

  • Cliente representa una fila ya existente.
  • NuevoCliente representa datos pendientes de insertar.
  • La base de datos protege email con una restricción UNIQUE.
  • El código debe comprobar si cada operación realmente afecta a una fila.

3. CRUD básico con JDBC

El siguiente ejemplo reúne las operaciones principales. Está pensado como paso intermedio: funciona, pero todavía no es la arquitectura final.

import java.sql.Connection

fun insertarCliente(connection: Connection, cliente: NuevoCliente): Int {
    val sql = "INSERT INTO clientes (nombre, email) VALUES (?, ?)"

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, cliente.nombre)
        statement.setString(2, cliente.email)
        return statement.executeUpdate()
    }
}

fun obtenerClientes(connection: Connection): List<Cliente> {
    val sql = "SELECT id, nombre, email FROM clientes ORDER BY id"
    val clientes = mutableListOf<Cliente>()

    connection.prepareStatement(sql).use { statement ->
        statement.executeQuery().use { resultSet ->
            while (resultSet.next()) {
                clientes.add(
                    Cliente(
                        id = resultSet.getInt("id"),
                        nombre = resultSet.getString("nombre"),
                        email = resultSet.getString("email")
                    )
                )
            }
        }
    }

    return clientes
}

fun actualizarCliente(connection: Connection, cliente: Cliente): Int {
    val sql = "UPDATE clientes SET nombre = ?, email = ? WHERE id = ?"

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, cliente.nombre)
        statement.setString(2, cliente.email)
        statement.setInt(3, cliente.id)
        return statement.executeUpdate()
    }
}

fun eliminarCliente(connection: Connection, id: Int): Int {
    val sql = "DELETE FROM clientes WHERE id = ?"

    connection.prepareStatement(sql).use { statement ->
        statement.setInt(1, id)
        return statement.executeUpdate()
    }
}

Lectura del ejemplo:

  • insertarCliente usa INSERT y devuelve filas insertadas.
  • obtenerClientes usa SELECT y transforma filas en objetos.
  • actualizarCliente usa UPDATE con WHERE id = ?.
  • eliminarCliente usa DELETE con WHERE id = ?.
  • Todas las operaciones cierran recursos con use.
  • Ninguna concatena datos externos dentro del SQL.

Busca los ejemplos InsertBasico, UpdateBasico y DeleteBasico y estúdialos o ejecútalos.

3.1. Probar el flujo completo

Una forma sencilla de probar el CRUD es ejecutar una secuencia controlada desde main. Este ejemplo no pretende ser la arquitectura final; sirve para comprobar que las funciones hacen lo esperado antes de moverlas a un DAO.

import java.sql.SQLException

fun main() {
    try {
        val nuevo = NuevoCliente("Juan Pérez", "juan@example.com")
        val insertadas = insertarCliente(connection, nuevo)
        println("Filas insertadas: $insertadas")

        obtenerClientes(connection).forEach { println(it) }

        val actualizadas = actualizarCliente(
            connection,
            Cliente(1, "Juan Pérez", "juan.perez@example.com")
        )
        println("Filas actualizadas: $actualizadas")

        val eliminadas = eliminarCliente(connection, 1)
        println("Filas eliminadas: $eliminadas")
    } catch (e: SQLException) {
        println("Error de base de datos: ${e.message}")
    }
}

Lectura del ejemplo:

  • Primero se inserta un cliente.
  • Después se consultan y muestran los clientes existentes.
  • A continuación se actualiza un registro concreto.
  • Finalmente se elimina un registro por identificador.
  • try-catch concentra el tratamiento técnico inicial.

Ejemplo didáctico

En clase puede ser útil tener una secuencia completa en main, pero una aplicación real no debería dejar toda la lógica de acceso a datos en el punto de entrada. El siguiente paso es separar responsabilidades con DAO y servicios.

3.2. Mejorar el CRUD paso a paso

El ejemplo anterior es útil para aprender, pero todavía puede mejorarse. Una primera mejora consiste en comprobar siempre el resultado de las operaciones de escritura:

val filasInsertadas = insertarCliente(
    connection,
    NuevoCliente("Ana", "ana@example.com")
)

if (filasInsertadas == 1) {
    println("Inserción correcta.")
} else {
    println("No se ha insertado ningún registro.")
}

Otra mejora consiste en evitar que la interfaz de usuario decida directamente qué SQL se ejecuta. Por ejemplo, un botón "Eliminar" no debería construir una sentencia SQL. Debería llamar a una función de acceso a datos, y esa función debería encargarse de preparar la consulta, asignar parámetros, ejecutar y comprobar el resultado.

De ejemplo a aplicación

Primero aprendemos el CRUD con funciones sencillas. Después lo refactorizamos hacia una capa de acceso a datos para que la aplicación sea más mantenible, más fácil de probar y menos dependiente de detalles concretos de JDBC.

4. Comprobar resultados y errores

En JDBC hay dos tipos de problemas que conviene diferenciar:

  • Error técnico: conexión caída, SQL mal escrito, falta de permisos o restricción incumplida.
  • Resultado funcional inesperado: la sentencia se ejecuta, pero afecta a 0 filas.

Ejemplo de comprobación:

val filas = actualizarCliente(
    connection,
    Cliente(id = 7, nombre = "Ana López", email = "ana.lopez@example.com")
)

if (filas == 1) {
    println("Cliente actualizado correctamente.")
} else {
    println("No existe ningún cliente con ese identificador.")
}

SQLException permite capturar errores técnicos:

import java.sql.SQLException

try {
    insertarCliente(connection, NuevoCliente("Ana", "ana@example.com"))
} catch (e: SQLException) {
    println("No se ha podido guardar el cliente.")
    println("Detalle técnico: ${e.message}")
}

No mostrar información sensible

No conviene mostrar a la persona usuaria trazas completas, nombres internos de tablas, cadenas de conexión, usuarios de base de datos o detalles de infraestructura.

Busca el ejemplo GestionSQLException y estúdialo o ejecútalo.

4.1. Errores de conexión

Los errores de conexión aparecen antes de ejecutar cualquier consulta. Pueden deberse a un servidor apagado, una URL JDBC incorrecta, un puerto cerrado, credenciales inválidas, ausencia del driver JDBC o problemas de red.

El objetivo no es ocultar el error, sino convertirlo en información útil. La persona usuaria necesita un mensaje comprensible; el equipo técnico necesita conservar la causa real para poder diagnosticar.

import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException

fun abrirConexion(url: String, user: String, password: String): Connection {
    try {
        return DriverManager.getConnection(url, user, password)
    } catch (e: SQLException) {
        throw SQLException(
            "No se ha podido conectar con la base de datos: ${e.message}",
            e
        )
    }
}

No cierres antes de devolver

Una función que devuelve una Connection no debe cerrarla en un bloque finally antes de devolverla. Si la cierra, quien la llama recibe una conexión inutilizable. El cierre debe hacerse después de terminar la operación, normalmente con use.

Uso correcto:

try {
    abrirConexion(url, user, password).use { connection ->
        println("Conexión válida: ${connection.isValid(2)}")
    }
} catch (e: SQLException) {
    println("No se ha podido abrir la conexión.")
    println("Detalle técnico: ${e.message}")
}

4.2. Errores de inserción

Al insertar datos pueden fallar restricciones de clave primaria, restricciones de unicidad, campos obligatorios, claves foráneas o tipos de datos incompatibles.

Por ejemplo, si la tabla exige que el correo electrónico sea único y la aplicación intenta insertar un correo ya registrado, el SGBDR rechazará la operación.

try {
    val filas = insertarCliente(
        connection,
        NuevoCliente("Ana López", "ana@example.com")
    )

    if (filas == 1) {
        println("Cliente insertado correctamente.")
    } else {
        println("No se ha insertado ningún cliente.")
    }
} catch (e: SQLException) {
    println("No se ha podido insertar el cliente.")
    println("Detalle técnico: ${e.message}")
}

Lectura del ejemplo:

  • La inserción puede fallar por una restricción de la base de datos.
  • executeUpdate() permite comprobar cuántas filas se insertaron.
  • El mensaje para la persona usuaria debe ser claro.
  • El detalle técnico debe conservarse para depuración.

Códigos de error

Los códigos de error no son universales. El mismo problema puede comunicarse de forma distinta en MySQL, PostgreSQL, H2 u Oracle. Conviene consultar la documentación del SGBDR utilizado y no basar toda la lógica de negocio en códigos específicos sin control.

4.3. Errores de eliminación

Eliminar un registro inexistente no siempre lanza una excepción. Muchas veces la operación se ejecuta correctamente, pero executeUpdate() devuelve 0.

try {
    val filasEliminadas = eliminarCliente(connection, 7)

    if (filasEliminadas == 1) {
        println("Cliente eliminado correctamente.")
    } else {
        println("No existe ningún cliente con ese identificador.")
    }
} catch (e: SQLException) {
    println("Se ha producido un error al eliminar el cliente.")
    println("Detalle técnico: ${e.message}")
}

En este caso, que no se elimine ninguna fila no es necesariamente un fallo técnico de la base de datos. Puede ser una situación funcional que la aplicación debe comunicar.

Borrados sin condición

La cláusula WHERE es obligatoria en este tipo de operaciones. Una sentencia DELETE FROM clientes sin condición eliminaría todos los registros de la tabla.

4.4. Errores de actualización

Actualizar registros exige controlar tanto errores técnicos como resultados funcionales. La consulta puede ser correcta y no actualizar ninguna fila porque el identificador no existe.

try {
    val filasActualizadas = actualizarCliente(
        connection,
        Cliente(7, "Ana López", "ana.lopez@example.com")
    )

    if (filasActualizadas == 1) {
        println("Cliente actualizado correctamente.")
    } else {
        println("No se ha encontrado ningún cliente con ese id.")
    }
} catch (e: SQLException) {
    println("Se ha producido un error al actualizar el cliente.")
    println("Detalle técnico: ${e.message}")
}

La actualización sigue el mismo patrón que la eliminación: consulta parametrizada, ejecución con executeUpdate() y comprobación de filas afectadas.

4.5. Qué mostrar y qué registrar

Dentro del bloque catch, se puede proporcionar información a la persona usuaria o registrar el error para su análisis posterior. Son objetivos distintos:

  • Mensaje para la persona usuaria: debe ser claro, breve y seguro.
  • Mensaje técnico o log: puede incluir detalles útiles para depurar.
  • Respuesta de la aplicación: debe dejar el sistema en un estado coherente.

Por ejemplo, ante un fallo de inserción por correo duplicado, la persona usuaria puede ver "El correo ya está registrado". En cambio, el log técnico puede conservar el código de error, la operación que se estaba ejecutando y el momento del fallo.

No mostrar información sensible

No conviene mostrar al usuario final trazas completas, nombres internos de tablas, cadenas de conexión, usuarios de base de datos o detalles de infraestructura. Esa información puede ser útil para depurar, pero también puede exponer datos sensibles.

4.6. Enseñanza clave del manejo de errores

El manejo de errores en JDBC debe combinar tres ideas:

  • Anticipar fallos previsibles.
  • Liberar siempre los recursos.
  • Comprobar el resultado real de cada operación.

Una sentencia SQL puede estar bien escrita y aun así no producir el efecto esperado. Una aplicación robusta no solo contempla el camino correcto: también define qué hacer cuando la conexión falla, cuando un dato incumple una restricción, cuando no se elimina ninguna fila o cuando una actualización no encuentra el registro esperado.

5. Transacciones

Una transacción agrupa varias operaciones para que se ejecuten como una unidad. Si todas funcionan, se confirma con commit. Si una falla, se deshace con rollback.

Ejemplo típico: registrar un pedido y descontar stock. Ambas operaciones deben completarse juntas.

fun registrarPedido(connection: Connection, pedido: Pedido) {
    connection.autoCommit = false

    try {
        insertarPedido(connection, pedido)
        descontarStock(connection, pedido.productoId, pedido.unidades)

        connection.commit()
    } catch (e: SQLException) {
        connection.rollback()
        throw e
    } finally {
        connection.autoCommit = true
    }
}

Lectura del ejemplo:

  • autoCommit = false evita confirmar cada sentencia por separado.
  • insertarPedido registra el pedido.
  • descontarStock actualiza existencias.
  • commit() confirma ambas operaciones.
  • rollback() deshace los cambios si algo falla.
  • finally restaura el modo habitual de la conexión.

Situación real

Si se registra el pedido pero falla el descuento de stock, el sistema queda incoherente. Una transacción evita ese estado intermedio: o se hacen ambas operaciones o no se hace ninguna.

Busca los ejemplos TransaccionCommit y TransaccionRollback y estúdialos o ejecútalos.

6. Por qué necesitamos una capa de acceso a datos

El CRUD básico funciona, pero no conviene que el SQL quede repartido por menús, botones, controladores o servicios.

Si la lógica de negocio conoce directamente JDBC:

  • Cambiar la tabla obliga a modificar muchas clases.
  • Las pruebas se vuelven más difíciles.
  • La interfaz puede acabar dependiendo de detalles técnicos.
  • Se mezclan responsabilidades que deberían estar separadas.

El siguiente diseño es poco mantenible:

fun registrarCliente(nombre: String, email: String) {
    val sql = "INSERT INTO clientes (nombre, email) VALUES (?, ?)"

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, nombre)
        statement.setString(2, email)
        statement.executeUpdate()
    }

    println("Cliente registrado.")
}

El problema no es el SQL. El problema es que una función de negocio sabe demasiado sobre JDBC, conexión, tabla y parámetros.

7. Patrón DAO

El patrón DAO (Data Access Object) propone separar la lógica de negocio de la lógica de acceso a datos.

Los componentes habituales son:

  • Servicio: contiene reglas de negocio.
  • DAO: encapsula las operaciones de acceso a datos.
  • Modelo o DTO: transporta datos entre capas.
  • DataSource: proporciona conexiones a la base de datos.
sequenceDiagram
    participant Servicio as Servicio
    participant DAO as ClienteDAO
    participant BD as Base de datos

    Servicio->>DAO: registrar cliente
    DAO->>BD: INSERT parametrizado
    BD-->>DAO: filas afectadas
    DAO-->>Servicio: resultado de la operación

La idea importante es que el servicio no recibe un ResultSet, ni una conexión, ni una sentencia SQL. Recibe objetos o resultados que tienen sentido para la aplicación.

8. DAO en Kotlin

Primero definimos una interfaz:

interface ClienteDAO {
    fun crear(cliente: NuevoCliente): Int
    fun buscarTodos(): List<Cliente>
    fun buscarPorId(id: Int): Cliente?
    fun actualizar(cliente: Cliente): Int
    fun eliminar(id: Int): Int
}

Después una implementación JDBC:

import javax.sql.DataSource

class ClienteDAOJdbc(private val dataSource: DataSource) : ClienteDAO {
    override fun crear(cliente: NuevoCliente): Int {
        val sql = "INSERT INTO clientes (nombre, email) VALUES (?, ?)"

        dataSource.connection.use { connection ->
            connection.prepareStatement(sql).use { statement ->
                statement.setString(1, cliente.nombre)
                statement.setString(2, cliente.email)
                return statement.executeUpdate()
            }
        }
    }

    override fun buscarTodos(): List<Cliente> {
        val sql = "SELECT id, nombre, email FROM clientes ORDER BY id"
        val clientes = mutableListOf<Cliente>()

        dataSource.connection.use { connection ->
            connection.prepareStatement(sql).use { statement ->
                statement.executeQuery().use { resultSet ->
                    while (resultSet.next()) {
                        clientes.add(
                            Cliente(
                                id = resultSet.getInt("id"),
                                nombre = resultSet.getString("nombre"),
                                email = resultSet.getString("email")
                            )
                        )
                    }
                }
            }
        }

        return clientes
    }

    override fun buscarPorId(id: Int): Cliente? {
        val sql = "SELECT id, nombre, email FROM clientes WHERE id = ?"

        dataSource.connection.use { connection ->
            connection.prepareStatement(sql).use { statement ->
                statement.setInt(1, id)

                statement.executeQuery().use { resultSet ->
                    return if (resultSet.next()) {
                        Cliente(
                            id = resultSet.getInt("id"),
                            nombre = resultSet.getString("nombre"),
                            email = resultSet.getString("email")
                        )
                    } else {
                        null
                    }
                }
            }
        }
    }

    override fun actualizar(cliente: Cliente): Int {
        val sql = "UPDATE clientes SET nombre = ?, email = ? WHERE id = ?"

        dataSource.connection.use { connection ->
            connection.prepareStatement(sql).use { statement ->
                statement.setString(1, cliente.nombre)
                statement.setString(2, cliente.email)
                statement.setInt(3, cliente.id)
                return statement.executeUpdate()
            }
        }
    }

    override fun eliminar(id: Int): Int {
        val sql = "DELETE FROM clientes WHERE id = ?"

        dataSource.connection.use { connection ->
            connection.prepareStatement(sql).use { statement ->
                statement.setInt(1, id)
                return statement.executeUpdate()
            }
        }
    }
}

Lectura del ejemplo:

  • La interfaz ClienteDAO define qué operaciones necesita la aplicación.
  • ClienteDAOJdbc concentra SQL y JDBC.
  • El resto de la aplicación no conoce PreparedStatement ni ResultSet.
  • Las conexiones salen del DataSource.
  • Cada método devuelve datos de dominio o filas afectadas, no objetos técnicos de JDBC.

Busca los ejemplos DaoBasico y DaoConServicio y estúdialos o ejecútalos.

9. DAO y Abstract Factory

Hasta ahora hemos supuesto una sola fuente de datos. Sin embargo, a veces una aplicación necesita trabajar con varias implementaciones: una base de datos SQL, un XML, una API REST o una base de datos en memoria para pruebas.

En ese caso se puede combinar DAO con Abstract Factory. La factoría decide qué familia de DAO crear.

interface DAOFactory {
    fun crearClienteDAO(): ClienteDAO
    fun crearAuditoriaDAO(): AuditoriaDAO
}

class SQLDAOFactory(private val dataSource: DataSource) : DAOFactory {
    override fun crearClienteDAO(): ClienteDAO = ClienteDAOJdbc(dataSource)
    override fun crearAuditoriaDAO(): AuditoriaDAO = AuditoriaDAOJdbc(dataSource)
}

Lectura del ejemplo:

  • DAOFactory define qué DAOs puede crear la aplicación.
  • SQLDAOFactory crea DAOs que trabajan contra SQL.
  • Podría existir otra factoría, por ejemplo MemoriaDAOFactory para pruebas.
  • El servicio no necesita saber qué implementación concreta recibe.

El flujo sería:

  1. La aplicación selecciona una factoría adecuada.
  2. La factoría crea el DAO concreto.
  3. El servicio usa la interfaz del DAO.
  4. El DAO accede a la fuente de datos real.
  5. El resultado vuelve como objetos de la aplicación.

10. Error frecuente: devolver objetos técnicos

Un error común al implementar DAO es devolver objetos propios de la API de acceso a datos, como ResultSet, Connection o clases específicas del driver. Eso rompe la separación que se intentaba conseguir.

No conviene hacer esto:

fun buscarTodos(): ResultSet

Es preferible devolver objetos del dominio o DTO:

fun buscarTodos(): List<Cliente>

Así, la capa de negocio no depende de JDBC y puede probarse con implementaciones falsas o en memoria.

11. Servicio de aplicación

El servicio usa el DAO y aplica reglas de negocio:

class ClienteService(private val clienteDAO: ClienteDAO) {
    fun registrar(nombre: String, email: String) {
        require(nombre.isNotBlank()) {
            "El nombre no puede estar vacío."
        }
        require(email.contains("@")) {
            "El email debe tener un formato mínimo válido."
        }

        val filas = clienteDAO.crear(NuevoCliente(nombre, email))

        if (filas != 1) {
            error("No se ha podido registrar el cliente.")
        }
    }
}

Lectura del ejemplo:

  • La validación pertenece a la lógica de negocio.
  • El servicio delega la persistencia en el DAO.
  • El servicio comprueba el resultado de la operación.
  • El servicio no prepara SQL ni recorre ResultSet.

Idea profesional

DAO no hace desaparecer JDBC. Lo coloca en una capa concreta. Esa separación facilita pruebas, mantenimiento y evolución del proyecto.

12. Buenas prácticas finales

  • Mantener SQL y JDBC dentro de la capa de acceso a datos.
  • Usar PreparedStatement para cualquier consulta con parámetros.
  • Comprobar siempre las filas afectadas en INSERT, UPDATE y DELETE.
  • Cerrar Connection, PreparedStatement y ResultSet con use.
  • Usar transacciones cuando una acción de negocio necesite varias operaciones inseparables.
  • Validar en Kotlin para dar buenos mensajes y reforzar reglas críticas en la base de datos.
  • No devolver ResultSet, Connection ni clases del driver desde el DAO.
  • Usar factorías cuando la aplicación deba alternar entre varias familias de DAOs.
  • No escribir credenciales directamente en código de producción.
  • Registrar errores técnicos sin exponer información sensible a la persona usuaria.

13. Cierre del punto

Una aplicación con base de datos no se diseña copiando consultas sueltas. Se construye separando responsabilidades:

flowchart LR
    A[Interfaz o controlador] --> B[Servicio]
    B --> C[DAO]
    C --> D[JDBC]
    D --> E[(Base de datos)]

La enseñanza principal de este punto es que el acceso a datos forma parte de la arquitectura de la aplicación. Cuanto más clara esté esa capa, más fácil será mantener, probar y evolucionar el software.

Fuentes y bibliografía

Presentación