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