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
);
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:
Lectura del ejemplo:
Clienterepresenta una fila ya existente.NuevoClienterepresenta datos pendientes de insertar.- La base de datos protege
emailcon una restricciónUNIQUE. - 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:
insertarClienteusaINSERTy devuelve filas insertadas.obtenerClientesusaSELECTy transforma filas en objetos.actualizarClienteusaUPDATEconWHERE id = ?.eliminarClienteusaDELETEconWHERE 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-catchconcentra 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
0filas.
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 = falseevita confirmar cada sentencia por separado.insertarPedidoregistra el pedido.descontarStockactualiza existencias.commit()confirma ambas operaciones.rollback()deshace los cambios si algo falla.finallyrestaura 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
ClienteDAOdefine qué operaciones necesita la aplicación. ClienteDAOJdbcconcentra SQL y JDBC.- El resto de la aplicación no conoce
PreparedStatementniResultSet. - 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:
DAOFactorydefine qué DAOs puede crear la aplicación.SQLDAOFactorycrea DAOs que trabajan contra SQL.- Podría existir otra factoría, por ejemplo
MemoriaDAOFactorypara pruebas. - El servicio no necesita saber qué implementación concreta recibe.
El flujo sería:
- La aplicación selecciona una factoría adecuada.
- La factoría crea el DAO concreto.
- El servicio usa la interfaz del DAO.
- El DAO accede a la fuente de datos real.
- 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:
Es preferible devolver objetos del dominio o DTO:
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
PreparedStatementpara cualquier consulta con parámetros. - Comprobar siempre las filas afectadas en
INSERT,UPDATEyDELETE. - Cerrar
Connection,PreparedStatementyResultSetconuse. - 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,Connectionni 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¶
- Ejemplos U9
- Programación - 08 Programación con Bases de Datos - José Luis González
- HikariCP
- The DTO Pattern - Baeldung
- Patrón de diseño Abstract Factory