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.

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.

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. 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.

10. 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.
  • No escribir credenciales directamente en código de producción.
  • Registrar errores técnicos sin exponer información sensible a la persona usuaria.

11. Cierre de la unidad

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 la unidad 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